diff --git a/biome.jsonc b/biome.jsonc index f80b99417..2c2814cbb 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -32,6 +32,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..b89140897 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,9 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", + "@opentui/core": "^0.2.0", + "@opentui/react": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", @@ -19,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", @@ -34,6 +36,7 @@ "picomatch": "^4.0.3", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", + "react": "^19.2.5", "semver": "^7.7.3", "string-width": "^8.2.0", "tinyglobby": "^0.2.15", @@ -91,6 +94,8 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -151,6 +156,62 @@ "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.4", "", {}, "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], @@ -173,6 +234,22 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], + "@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], + + "@opentui/react": ["@opentui/react@0.2.0", "", { "dependencies": { "@opentui/core": "0.2.0", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-wXDpBoj3GQuQJG5MrIfyYRshU3bwaBYuSC6ThYiVHSDgt8PGhy2v2xPzFVvJZDSx7hp9gUaaNzWPsXIRLwrlCQ=="], + "@peggyjs/from-mem": ["@peggyjs/from-mem@3.1.3", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg=="], "@sentry/api": ["@sentry/api@0.113.0", "", {}, "sha512-28W0Oykb/O+6kH8F+OEd8070N4z7ctawlyUtEvnNZNlaLviDC9Is1X/0JiK2Xb9y2ZNbkWf+/H1y5hXr0WTIOw=="], @@ -199,6 +276,8 @@ "@stricli/core": ["@stricli/core@1.2.5", "", {}, "sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -233,12 +312,16 @@ "@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=="], "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -257,6 +340,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -265,14 +350,32 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "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=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -311,6 +414,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=="], @@ -321,13 +426,15 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -353,10 +460,14 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], @@ -371,6 +482,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -393,6 +506,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -421,8 +536,12 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "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=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -441,8 +560,12 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -471,7 +594,7 @@ "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -501,6 +624,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -513,6 +638,14 @@ "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -529,16 +662,26 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "peggy": ["peggy@5.1.0", "", { "dependencies": { "@peggyjs/from-mem": "3.1.3", "commander": "^14.0.3", "source-map-generator": "2.0.6" }, "bin": { "peggy": "bin/peggy.js" } }, "sha512-IEo5aYRZ2kXH4Qby06cjtL114PZnwLoTiA41vUmg2vPZgANn+c87m5BUurhuDr5/cu758ZlpgsAfBVx+hhO5+w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], @@ -555,6 +698,16 @@ "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.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "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=="], @@ -565,6 +718,10 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "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 +738,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,32 +748,46 @@ "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=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "source-map-generator": ["source-map-generator@2.0.6", "", {}, "sha512-IlassDs1Ve8nV6uyQZXF9kdkJpVKnMte2JZQXu13M0A5zwc+vu6+LNHfmxsHBMDtoZE21RHiKI0/xvpecZRCNg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], + "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=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "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=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "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=="], @@ -629,6 +802,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -639,6 +814,8 @@ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -649,6 +826,14 @@ "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=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "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 +844,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=="], @@ -679,6 +866,10 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "@opentui/core/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@peggyjs/from-mem/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -699,20 +890,32 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "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=="], + "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "trpc-cli/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "yargs/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=="], "zod-from-json-schema/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], @@ -737,8 +940,12 @@ "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "@opentui/core/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "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 +964,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "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 8dcf0a878..e96f1651c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", + "@opentui/core": "^0.2.0", + "@opentui/react": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", @@ -21,6 +22,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", @@ -36,6 +38,7 @@ "picomatch": "^4.0.3", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", + "react": "^19.2.5", "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 a6ad7a0a0..2ee9cfd79 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 OpenTUI full-screen interface (default on the Bun binary). Pass --no-tui to disable.` **Examples:** diff --git a/script/build.ts b/script/build.ts index c320d69dc..d1ad7532e 100644 --- a/script/build.ts +++ b/script/build.ts @@ -124,7 +124,34 @@ async function bundleJs(): Promise { platform: "node", target: "esnext", format: "esm", - external: ["bun:*"], + // Externalize the OpenTUI + React stack from the esbuild + // bundling step. Two reasons: + // + // 1. `@opentui/core` ships Bun-specific + // `import "..." with { type: "file" }` syntax for + // tree-sitter assets (`*.scm`, `*.wasm`) that esbuild + // doesn't understand. Bun.compile downstream resolves + // them natively and embeds the assets into the binary. + // + // 2. `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. We + // sidestep this by keeping React out of esbuild AND + // reaching it only through the embedded `opentui-app.tsx` + // asset (see `src/lib/init/ui/opentui-ui.ts`'s + // `with { type: "file" }` import) — Bun's runtime + // resolves React fresh at first invocation, outside the + // buggy bundler path. + external: [ + "bun:*", + "@opentui/core", + "@opentui/core/*", + "@opentui/react", + "@opentui/react/*", + "react", + "react/*", + ], sourcemap: "linked", // Minify syntax and whitespace but NOT identifiers. Bun.build minify: true, @@ -480,8 +507,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 `opentui-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/opentui-app.tsx`; // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/bundle.ts b/script/bundle.ts index 0949163bb..c88eaba69 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -215,8 +215,23 @@ 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 the OpenTUI + React stack. + // OpenTUI ships native Zig bindings that only load under the Bun + // runtime, so the npm/Node distribution must NOT bundle them. The + // factory in `src/lib/init/ui/factory.ts` lazy-imports the OpenTUI + // path and falls back to LoggingUI on import failure, so marking + // these external means a Node user simply gets the non-TUI flow + // without a crash. The Bun compile (`script/build.ts`) bundles + // them into the native binary, where the loader is available. + external: [ + "node:*", + "@opentui/core", + "@opentui/core/*", + "@opentui/react", + "@opentui/react/*", + "react", + "react/*", + ], metafile: true, plugins, }); @@ -278,6 +293,19 @@ 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 `opentui-app.tsx` sidecar that the text-import-plugin +// drops into `dist/` when it sees the `with { type: "file" }` import +// in `src/lib/init/ui/opentui-ui.ts`. The npm distribution doesn't +// run the OpenTuiUI factory at all (it's gated to the Bun binary), +// 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/opentui-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..ea6c81148 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -1,17 +1,35 @@ /** - * 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. + * `script/bundle.ts` (CJS library bundle) so: + * + * 1. The grep-worker source in `src/lib/scan/worker-pool.ts` loads + * correctly in both dev and compiled builds (`text` branch). + * 2. `src/lib/init/ui/opentui-app.tsx` ships embedded into the + * Bun binary as a file resource (`file` branch). `OpenTuiUI` + * then `await import(path)`s it at runtime, sidestepping a Bun + * bundler bug that mangles React's CJS jsx-runtime wrapping + * when reached through static imports inside `__commonJS` + * scope. Embedding the .tsx as raw bytes pushes resolution to + * Bun's runtime (not bundler), which doesn't have the bug. */ -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 +39,53 @@ 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. + // + // The npm bundle path (`script/bundle.ts`) also reaches this + // branch — `opentui-ui.ts` has the import at module top — + // but `@opentui/*` and `react` are externalized there, so + // the OpenTuiUI factory never runs and the embedded copy is + // unused at runtime. We still produce it because esbuild + // resolves all reachable imports regardless of whether they + // execute. The `mkdirSync` below guards against the + // bundle's `outdir` not yet existing when the plugin fires. + 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..3b4fd95b3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -45,6 +45,14 @@ type InitFlags = { readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; + /** + * Default `true` (OpenTUI is the default UI). Stricli auto-generates + * a negated `--no-tui` flag that flips this to `false` — that's the + * escape hatch users invoke when the OpenTUI path misbehaves. The + * positive `--tui` flag is also accepted for symmetry but is a no-op + * versus the default. + */ + readonly tui: boolean; }; /** @@ -226,6 +234,12 @@ export const initCommand = buildCommand< brief: "Team slug to create the project under", optional: true, }, + tui: { + kind: "boolean", + brief: + "Use the OpenTUI full-screen interface (default on the Bun binary). Pass --no-tui to disable.", + default: true, + }, }, aliases: { ...DRY_RUN_ALIASES, @@ -285,6 +299,10 @@ 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. diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index 4a135a971..c16297295 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,79 @@ 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 OpenTUI 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 OpenTUI 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", +]; + +/** + * 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..8b90d0a37 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 + * showed literal markup like `~` and pipe-cells in + * `OpenTuiUI` because TextRenderable can'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..15d46b5e4 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 clack, OpenTUI, and the + * non-interactive `LoggingUI` 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..29ae0ebe6 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 `ClackUI` (interactive), `LoggingUI` (CI), + * and the upcoming OpenTUI implementation. */ 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/types.ts b/src/lib/init/types.ts index 182b3a58b..6ab708532 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-OpenTUI fallback (`LoggingUI`). Mapped from + * `--no-tui`. Acts as an escape hatch when the OpenTUI path + * 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..01af74da9 --- /dev/null +++ b/src/lib/init/ui/factory.ts @@ -0,0 +1,123 @@ +/** + * 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 OpenTUI 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`. OpenTUI ships native Zig bindings that the npm + * `dist/bin.cjs` distribution can't load. 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) — `OpenTuiUI`. + * + * The previous `ClackUI` implementation was removed in PR 4 once the + * OpenTUI implementation became the default. `@clack/prompts` is no + * longer a dependency. + */ + +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 OpenTUI's native bindings are present) 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 OpenTUI. + */ +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 `OpenTuiUI` for interactive runs on the Bun + * binary, otherwise `LoggingUI`. The async form exists because + * instantiating `OpenTuiUI` requires a lazy `import("@opentui/core")` + * (the package isn't bundled into the npm/Node distribution and would + * crash 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 { createOpenTuiUI } = await import("./opentui-ui.js"); + return await createOpenTuiUI(); + } catch { + // Fall through to LoggingUI so a missing/broken native binding + // doesn't take down the wizard. This branch is 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..e1720e967 --- /dev/null +++ b/src/lib/init/ui/file-tree.ts @@ -0,0 +1,238 @@ +/** + * Changed-files tree builder. + * + * Both `OpenTuiUI`'s React `` and `LoggingUI.summary()` + * (plus the post-dispose stderr 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 new TUI can't render those because OpenTUI + * strips ANSI from `TextRenderable.content`. 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 OpenTUI + * `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 OpenTUI + * `FilesPanel`'s sticky-bottom scrollbox 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/logging-ui.ts b/src/lib/init/ui/logging-ui.ts new file mode 100644 index 000000000..4b4a93704 --- /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 OpenTuiUI panel + post-dispose stderr 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 OpenTuiUI / 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/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx new file mode 100644 index 000000000..8cddb52cf --- /dev/null +++ b/src/lib/init/ui/opentui-app.tsx @@ -0,0 +1,843 @@ +/** + * OpenTuiUI React App + * + * Renders the full-screen wizard layout. The component subscribes to a + * `WizardStore` (see `opentui-store.ts`) via `useSyncExternalStore` so + * imperative `WizardUI` method calls (`log.info`, `spinner.start`, + * etc.) trigger React re-renders without React state being the source + * of truth. + * + * Layout (left-aligned columns from outer chrome inwards): + * + * ┌─ Sentry init ──────────────────────────────────────────────────┐ + * │ ╔═══════════════════════════╗ ╔══════════════════════════╗ │ + * │ ║ banner ║ ║ Did you know? ║ │ + * │ ║ ────────── ║ ║ ────────────── ║ │ + * │ ║ ● log line ║ ║ ║ │ + * │ ║ ▲ log line ║ ║ ║ │ + * │ ║ ◐ Reading foo.ts (3) ║ ║ ║ │ + * │ ║ ◒ spinner... ║ ║ Tip 3 of 12 ║ │ + * │ ║ ║ ╚══════════════════════════╝ │ + * │ ╚═══════════════════════════╝ │ + * └────────────────────────────────────────────────────────────────┘ + * + * The file-read status line is a single transient row above the + * spinner — replaces the previous bordered "Files analyzed" panel + * that pushed the tip card off-screen on shorter terminals. + * + * Why an external store rather than React state owned by the App? + * The `WizardUI` interface is imperative (the wizard runner calls + * `ui.log.info(...)` from a generator). Threading those calls through + * React's state setters from outside React would require keeping a + * mutable reference to a setter that gets bound on first render — + * fragile, especially with concurrent mode. An external store keeps + * the imperative side decoupled from React's lifecycle. + */ + +import { basename } from "node:path"; +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; +import { useState, useSyncExternalStore } from "react"; +import { + buildFileTree, + buildReadTree, + type FileTreeRow, + flattenTree, +} from "./file-tree.js"; +import type { + ActivePrompt, + FileReadEntry, + LogEntry, + LogSeverity, + SpinnerState, + StepEntry, + WizardStore, +} from "./opentui-store.js"; +import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; +import type { WizardSummary } from "./types.js"; + +// ──────────────────────────── Visual constants ──────────────────────── + +const ACCENT = "#A77DC3"; +const MUTED = "#6E6C7E"; +const FOREGROUND = "#E8E6F0"; + +const COLOR_INFO = "#7DD3FC"; +const COLOR_WARN = "#FBBF24"; +const COLOR_ERROR = "#F87171"; +const COLOR_SUCCESS = "#86EFAC"; + +const SPINNER_FRAMES = process.platform.startsWith("win") + ? ["●", "o", "O", "0"] + : ["◒", "◐", "◓", "◑"]; + +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: FOREGROUND }, + }; + +// ────────────────────────────── App entry ───────────────────────────── + +export type AppProps = { + store: WizardStore; +}; + +/** + * Width of the sidebar's outer box, including its border + padding. + * Used both as the renderable's `width` prop and as part of the + * minimum-terminal-width threshold below which we hide the sidebar. + */ +const SIDEBAR_WIDTH = 36; + +/** + * Minimum terminal columns required to show the sidebar alongside the + * main column. Below this we drop the sidebar entirely so the banner, + * log lines, and prompts get the full row width. + * + * Reasoning: the banner is ~55 chars wide, the outer wizard chrome + * eats 2 cols of border + 2 cols of padding (4 total), the inner gap + * between columns is 2, plus the sidebar's own 36 cols → 55 + 4 + 2 + + * 36 = 97. We round up slightly to leave room for prompts and longer + * log lines without wrapping ugly. + */ +const SIDEBAR_BREAKPOINT = 100; + +/** + * Fixed height for the tip card. Pinned (rather than `flexGrow`) so + * the panels below it (progress checklist, files-read tree) can never + * push the tip out of view as more content streams in. Sized to fit: + * + * 1 row – top border + * 1 row – top padding + * 1 row – tip title + * 1 row – gap + * 4 rows – tip body (wrapping room) + * 1 row – bottom padding (filler before counter) + * 1 row – "Tip n of N" counter + * 1 row – bottom padding + * 1 row – bottom border + * + * Bumping this knob is cheap; no other layout depends on it directly. + */ +const TIP_PANEL_HEIGHT = 12; + +/** + * Root component. Subscribes to the store once at the top, then drills + * the snapshot fields into individual presentational components. + * + * The sidebar auto-hides on narrow terminals (see `SIDEBAR_BREAKPOINT`) + * — `useTerminalDimensions` re-renders on resize, so dragging a + * window between widths flips the layout live. + */ +export function App({ store }: AppProps): React.ReactNode { + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot + ); + const { width } = useTerminalDimensions(); + const showSidebar = width >= SIDEBAR_BREAKPOINT; + + return ( + + + + {showSidebar ? ( + + ) : null} + + + ); +} + +// ──────────────────────────── Main column ───────────────────────────── + +type MainColumnProps = { + bannerRows: { content: string; color: string }[]; + filesRead: FileReadEntry[]; + logs: LogEntry[]; + spinner: SpinnerState; + prompt: ActivePrompt | null; + summary: WizardSummary | null; + /** + * Whether to render the inline file-read status row above the + * spinner. We only show this when the sidebar is hidden (narrow + * terminals); otherwise the sidebar's `FilesPanel` gives a richer + * tree view and the inline row would be a noisy duplicate. + */ + showFileReadInline: boolean; +}; + +function MainColumn({ + bannerRows, + filesRead, + logs, + spinner, + prompt, + summary, + showFileReadInline, +}: MainColumnProps): React.ReactNode { + // Hide the file-read status once the wizard finishes — the summary + // panel is the canonical "what happened" surface at that point, and + // a stale "47 files analyzed" line below it would just be noise. + const showFileStatus = showFileReadInline && !summary && filesRead.length > 0; + return ( + +
+ + + {logs.map((log) => ( + + ))} + + {showFileStatus ? : null} + {spinner.active ? : null} + {summary ? : null} + {prompt ? : null} + + ); +} + +function Header({ + bannerRows, +}: { + bannerRows: { content: string; color: string }[]; +}): React.ReactNode { + // The box already advertises "sentry init" in its top border title, + // and the banner itself reads "SENTRY", so we don't repeat the + // command name underneath the banner. Earlier versions had an + // intro line here ("▸ sentry init") which felt redundant. + return ( + + {bannerRows.map((row, i) => ( + // ASCII banner rows are positional, stable, and never re-ordered — + // the index key is correct here. + // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows + + {row.content} + + ))} + + ); +} + +function Divider(): React.ReactNode { + return ( + + ); +} + +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 { + const frame = + SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length] ?? + SPINNER_FRAMES[0] ?? + "•"; + return ( + + + {frame} + + + {state.message} + + + ); +} + +/** + * Single-line file-read status, shown above the spinner. Replaces the + * old bordered "Files analyzed" sidebar panel which had a fixed + * `flexShrink={0}` height of ~13 rows and pushed the tip card off- + * screen on shorter terminals. + * + * Rendering rules: + * - If any file is currently `reading`: show a yellow ● glyph plus + * up to two recent basenames and the running counter, e.g. + * `● Reading package.json, sentry.config.ts (3/12 analyzed)`. + * - Otherwise: collapse to a green ✔ recap, e.g. + * `✔ Analyzed 12 files`. + * + * The component never wraps to a second line — long basenames are + * truncated by the terminal, which is fine: the goal is a glance-able + * indicator, not a log. + */ +function FileReadStatus({ + filesRead, +}: { + filesRead: FileReadEntry[]; +}): React.ReactNode { + const reading = filesRead.filter((entry) => entry.status === "reading"); + const analyzed = filesRead.length - reading.length; + + if (reading.length > 0) { + // Show the most-recent 2 basenames being read; anything more turns + // into a `+ N more` hint so the line stays single-row. + const recent = reading.slice(-2).map((entry) => basename(entry.path)); + const overflow = reading.length - recent.length; + const namesPart = + overflow > 0 + ? `${recent.join(", ")} + ${overflow} more` + : recent.join(", "); + return ( + + + ● + + + Reading {namesPart} + + + {analyzed}/{filesRead.length} analyzed + + + ); + } + + return ( + + + ✔ + + + Analyzed {analyzed} {analyzed === 1 ? "file" : "files"} + + + ); +} + +// ────────────────────────────── Summary ─────────────────────────────── + +/** + * Compact summary panel rendered after the workflow finishes. Replaces + * the old approach of pushing pre-rendered markdown through + * `ui.log.message`, which OpenTuiUI couldn't display correctly because + * it strips ANSI and shows tag literals like `~`. + * + * Each field is a single row: small dim label cell followed by the + * value. Changed-files get a one-line-per-file rendering with an + * action glyph (+ ~ −). + */ +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} + + ); +} + +/** + * Render the changed-files list as a nested directory tree. Files + * sharing a parent directory collapse into a single group, and the + * box-drawing prefix (`├─` / `└─` / `│ `) tracks ancestor pipes the + * way `tree(1)` does. The tree shape is computed by `buildFileTree` + * — this component is purely presentational. + */ +function ChangedFilesTree({ + files, +}: { + files: { action: string; path: string }[]; +}): React.ReactNode { + const tree = buildFileTree(files); + const rows = flattenTree(tree); + return ( + + Changed files + {rows.map((row, i) => ( + // Tree rows are positionally stable for a given summary — + // the tree is rebuilt fresh each render from immutable + // `files`, so the index makes a fine key. + // 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} + + ); +} + +/** + * Map a change action to its glyph + color. Stays here next to the row + * component because both pieces of styling are coupled to the same + * action enum (create / delete / modify-or-other). + */ +function changedFileStyle(action: string): { glyph: string; color: string } { + if (action === "create") { + return { glyph: "+", color: COLOR_SUCCESS }; + } + if (action === "delete") { + return { glyph: "−", color: COLOR_ERROR }; + } + return { glyph: "~", color: COLOR_WARN }; +} + +// ─────────────────────────────── Prompts ────────────────────────────── + +function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { + if (prompt.kind === "select") { + return ; + } + return ; +} + +function SelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + // OpenTUI's SelectRenderable allocates 2 rows per option when + // `showDescription` is on (1 for the label + 1 for the hint), + // 1 row otherwise. Allocating the wrong height clips visible + // rows behind the scroll. We size based on the actual line cost + // and cap at the screen-friendly maxima the wizard expects + // (8 fully-shown items for select, 10 for multiselect). + const hasDescriptions = prompt.options.some((option) => option.hint); + const linesPerItem = hasDescriptions ? 2 : 1; + const maxVisibleItems = 8; + const visibleItems = Math.min(prompt.options.length, maxVisibleItems); + return ( + + {prompt.message} + setHighlighted(index)} + options={decoratedOptions} + selectedBackgroundColor={ACCENT} + selectedTextColor="#FFFFFF" + showDescription={hasDescriptions} + showScrollIndicator={prompt.options.length > maxVisibleItems} + textColor={FOREGROUND} + /> + + ); +} + +// ────────────────────────────── Sidebar ─────────────────────────────── + +/** + * The sidebar stacks three panels top-to-bottom: + * + * 1. {@link TipPanel} — fixed height (`TIP_PANEL_HEIGHT`). Pinned so + * it can never be squashed by the panels below. + * 2. {@link ProgressPanel} — auto height (one row per visible step). + * Bounded by `CHECKLIST_VISIBLE_STEPS.length` (~9 rows). + * 3. {@link FilesPanel} — `flexGrow=1`, scrollable. Consumes + * whatever vertical space is left over. + * + * On narrow terminals (`width < SIDEBAR_BREAKPOINT`) the whole + * sidebar is hidden by the parent App; the inline `FileReadStatus` + * line in `MainColumn` takes over the file-read indicator role. + */ +function Sidebar({ + tipIndex, + steps, + filesRead, +}: { + tipIndex: number; + steps: StepEntry[]; + filesRead: FileReadEntry[]; +}): React.ReactNode { + return ( + + + + + + ); +} + +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 ( + + {tip.title} + {tip.body} + + + Tip {oneIndexed} of {total} + + + ); +} + +/** + * Static checklist of workflow steps. Each row reflects a + * `StepEntry.status`: + * + * - `pending` — muted ◯ + * - `in_progress` — accent ▶ + * - `completed` — success ✓ + * - `skipped` — muted-dim ◌ (lighter than pending so the eye + * can tell "we walked past this" from "we haven't reached this + * yet") + * - `failed` — error ✖ + * + * The label cell is sized to fit the 36-col sidebar after the + * 2-col border + 2-col padding + 2-col glyph cell. + */ +function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { + const completedCount = steps.filter( + (entry) => entry.status === "completed" + ).length; + const totalCount = steps.length; + return ( + + {steps.map((entry) => ( + + ))} + + ); +} + +function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { + const { glyph, glyphColor, labelColor } = progressStyle(entry.status); + return ( + + + {glyph} + + + {entry.label} + + + ); +} + +function progressStyle(status: StepEntry["status"]): { + glyph: string; + glyphColor: string; + labelColor: string; +} { + if (status === "in_progress") { + return { glyph: "▶", glyphColor: ACCENT, labelColor: FOREGROUND }; + } + if (status === "completed") { + return { glyph: "✓", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; + } + if (status === "failed") { + return { glyph: "✖", glyphColor: COLOR_ERROR, labelColor: COLOR_ERROR }; + } + if (status === "skipped") { + return { glyph: "◌", glyphColor: MUTED, labelColor: MUTED }; + } + // pending + return { glyph: "◯", glyphColor: MUTED, labelColor: MUTED }; +} + +/** + * Scrollable directory tree of every file the wizard has read. Uses + * `` (OpenTUI's `ScrollBoxRenderable`) with sticky-bottom + * tracking — newly-read files always come into view, like a + * `tail -f`. + * + * Visual rules: + * - Directories: muted gray box-drawing branches + name with `/`. + * - Active reads (`status === "reading"`): accent purple `◐` glyph, + * foreground filename. The eye picks these out instantly. + * - Analyzed (`status === "analyzed"`): muted-green `✓` glyph, + * dimmed filename. Done work recedes; in-flight work pops. + * + * Hidden when no files have been recorded yet — the empty box would + * just be visual noise during the auth/discover phase. + */ +function FilesPanel({ + filesRead, +}: { + filesRead: FileReadEntry[]; +}): React.ReactNode { + if (filesRead.length === 0) { + return null; + } + const tree = buildReadTree(filesRead); + const rows = flattenTree(tree); + const analyzedCount = filesRead.filter( + (entry) => entry.status === "analyzed" + ).length; + return ( + + + {rows.map((row, i) => ( + // Tree rows are positionally stable for a given filesRead + // snapshot — `buildReadTree` walks `filesRead` in insertion + // order and never reorders, so the index makes a fine key. + // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows + + ))} + + + ); +} + +/** + * One row of the files-read tree. Mirrors {@link FileTreeLine} but + * styled for the read-progress flavour (status icons + dim-on-done) + * rather than the changed-files flavour (action glyphs). + */ +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: "◐", glyphColor: ACCENT, labelColor: FOREGROUND }; + } + // "analyzed" or undefined (defensive — should never appear for + // file rows but treat as analyzed) + return { glyph: "✓", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; +} diff --git a/src/lib/init/ui/opentui-store.ts b/src/lib/init/ui/opentui-store.ts new file mode 100644 index 000000000..aa36542fa --- /dev/null +++ b/src/lib/init/ui/opentui-store.ts @@ -0,0 +1,436 @@ +/** + * OpenTuiUI State Store + * + * Tiny external store that bridges the imperative `WizardUI` methods + * to React's render loop. The `OpenTuiUI` class mutates this store + * (intro text, log entries, spinner state, active prompt) and the + * React `App` subscribes via `useSyncExternalStore`. + * + * This avoids the alternative of holding component state inside the + * `App` itself and exposing setter callbacks back to the class — which + * would create a chicken-and-egg between mounting the React tree and + * binding the WizardUI instance. + * + * The store is intentionally minimal: snapshots are plain immutable + * objects so React's default `Object.is` reference check is enough + * to detect changes. + */ + +import { + CANONICAL_STEP_ORDER, + CHECKLIST_VISIBLE_STEPS, + shortStepLabel, +} from "../clack-utils.js"; +import type { SpinnerExitCode, WizardSummary } from "./types.js"; + +export type LogSeverity = "info" | "warn" | "error" | "success" | "message"; + +export type LogEntry = { + /** Stable id used as React key. Monotonic per store instance. */ + id: number; + severity: LogSeverity; + text: string; +}; + +export type SpinnerState = { + active: boolean; + /** The spinner frame index. Bumped by the renderer's interval. */ + frame: number; + message: string; +}; + +/** + * One entry tracking a file the wizard has read from disk during the + * session. Status transitions `reading` → `analyzed` once the tool + * returns. Surfaced by the inline file-read status line in `OpenTuiUI` + * (see `FileReadStatus` in `opentui-app.tsx`). + */ +export type FileReadEntry = { + path: string; + status: "reading" | "analyzed"; +}; + +/** + * Status of a single workflow step in the sidebar progress checklist. + * + * - `pending` — runner hasn't reached this step yet. + * - `in_progress` — runner is suspended on this step. + * - `completed` — runner has resumed past this step. + * - `skipped` — workflow's branching bypassed this step + * (back-filled implicitly when a later step starts). + * - `failed` — runner aborted while this step was active. + */ +export type StepStatus = + | "pending" + | "in_progress" + | "completed" + | "skipped" + | "failed"; + +/** One row in the sidebar progress checklist. */ +export type StepEntry = { + /** Mastra step id (e.g. `"discover-context"`). */ + id: string; + /** Sidebar-friendly short label (already abbreviated). */ + label: string; + status: StepStatus; +}; + +/** Generic option shape passed to mounted prompts. */ +export type PromptOption = { + value: string; + label: string; + hint?: string; +}; + +/** + * Discriminated union for the currently-mounted prompt. `null` when no + * prompt is active. Each variant carries the data the matching React + * component needs plus a `resolve` callback that the component invokes + * with the user's choice (or with `null` to indicate cancellation — + * the bridge in `opentui-ui.ts` translates `null` to the shared + * `CANCELLED` sentinel before handing the value back to the wizard). + */ +export type ActivePrompt = + | { + kind: "select"; + message: string; + options: PromptOption[]; + initialIndex: number; + resolve: (value: string | null) => void; + } + | { + kind: "multiselect"; + message: string; + options: PromptOption[]; + initialSelected: string[]; + required: boolean; + resolve: (values: string[] | null) => void; + }; + +export type WizardSnapshot = { + bannerRows: { content: string; color: string }[]; + logs: LogEntry[]; + spinner: SpinnerState; + prompt: ActivePrompt | null; + /** Index of the currently-displayed Sentry tip in the sidebar. */ + tipIndex: number; + /** Final structured summary, rendered after the workflow completes. */ + summary: WizardSummary | null; + /** + * Persistent list of every file the wizard has read from disk. Each + * entry carries a status that transitions `reading` → `analyzed` as + * the workflow progresses. Surfaced by the inline file-read status + * line in `OpenTuiUI` so the user can see what context the wizard + * inspected — without the previous spinner-message approach, which + * flashed each batch for half a second before the next tool + * overwrote it. + */ + filesRead: FileReadEntry[]; + /** + * Workflow step progress checklist. Pre-populated from + * `CHECKLIST_VISIBLE_STEPS` with every entry as `pending`; the + * runner advertises status changes via `WizardUI.setStep()` and + * the store updates the matching entry in place. Steps not present + * in the visible-step allowlist (e.g. `select-target-app`, + * `resolve-dir`) are silently ignored so the sidebar stays compact. + */ + steps: StepEntry[]; +}; + +export type Listener = () => void; + +/** + * Minimal external store with the React 18+ `useSyncExternalStore` + * subscription contract. + */ +export class WizardStore { + private snapshot: WizardSnapshot; + private nextLogId = 1; + private readonly listeners = new Set(); + + constructor(initial: Partial = {}) { + this.snapshot = { + bannerRows: initial.bannerRows ?? [], + logs: initial.logs ?? [], + spinner: initial.spinner ?? { active: false, frame: 0, message: "" }, + prompt: initial.prompt ?? null, + tipIndex: initial.tipIndex ?? 0, + summary: initial.summary ?? null, + filesRead: initial.filesRead ?? [], + steps: + initial.steps ?? + CHECKLIST_VISIBLE_STEPS.map((id) => ({ + id, + label: shortStepLabel(id), + status: "pending" as StepStatus, + })), + }; + } + + getSnapshot = (): WizardSnapshot => this.snapshot; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + // ── Mutators ────────────────────────────────────────────────────── + + setBanner(rows: { content: string; color: string }[]): void { + this.update({ bannerRows: rows }); + } + + appendLog(severity: LogSeverity, text: string): LogEntry { + const entry: LogEntry = { + id: this.nextLogId, + severity, + text, + }; + this.nextLogId += 1; + this.update({ logs: [...this.snapshot.logs, entry] }); + return entry; + } + + startSpinner(message: string): void { + this.update({ + spinner: { active: true, frame: 0, message }, + }); + } + + setSpinnerMessage(message: string): void { + if (!this.snapshot.spinner.active) { + return; + } + this.update({ + spinner: { ...this.snapshot.spinner, message }, + }); + } + + tickSpinner(): void { + if (!this.snapshot.spinner.active) { + return; + } + this.update({ + spinner: { + ...this.snapshot.spinner, + frame: this.snapshot.spinner.frame + 1, + }, + }); + } + + stopSpinner(): void { + this.update({ + spinner: { active: false, frame: 0, message: "" }, + }); + } + + setPrompt(prompt: ActivePrompt | null): void { + this.update({ prompt }); + } + + setTipIndex(index: number): void { + if (this.snapshot.tipIndex === index) { + return; + } + this.update({ tipIndex: index }); + } + + setSummary(summary: WizardSummary | null): void { + this.update({ summary }); + } + + /** + * Record that the wizard is currently reading a batch of files. + * Existing entries (read in earlier batches) keep their status so + * the file-read status line preserves history; new entries land + * with status `reading` and flip to `analyzed` via + * `markFilesAnalyzed()` when the tool returns. + */ + recordFilesReading(paths: string[]): void { + if (paths.length === 0) { + return; + } + const byPath = new Map( + this.snapshot.filesRead.map((entry) => [entry.path, entry]) + ); + for (const path of paths) { + const existing = byPath.get(path); + // Don't downgrade an already-analyzed entry back to `reading` + // if the same file is read again later in the run. + if (!existing || existing.status === "reading") { + byPath.set(path, { path, status: "reading" }); + } + } + this.update({ filesRead: [...byPath.values()] }); + } + + /** + * Update the status of a workflow step in the sidebar progress + * checklist. + * + * Behavior: + * + * - If `id` is not in {@link CHECKLIST_VISIBLE_STEPS}, the call is + * a no-op — keeps the sidebar compact for plumbing-only steps. + * + * - When transitioning a step to `in_progress`, any earlier + * `pending` step (per {@link CANONICAL_STEP_ORDER}) is + * back-filled to `skipped`. The workflow can only move forward, + * so an earlier pending step that the runner walked past was + * bypassed by an `if`-branch. + * + * - Re-entering an already-`in_progress` step is a no-op (a step + * can suspend multiple times — read-files, analyze, etc. — and + * the checklist should only flip on the first entry). + * + * - `completed` / `failed` always overwrite. `skipped` only + * applies if the step is currently `pending` (avoid clobbering + * a completed step). + */ + setStepStatus(id: string, status: StepStatus): void { + const canonicalIndex = CANONICAL_STEP_ORDER.indexOf(id); + + let nextSteps = this.snapshot.steps; + if (status === "in_progress" && canonicalIndex >= 0) { + nextSteps = backfillSkippedSteps(nextSteps, canonicalIndex); + } + if (CHECKLIST_VISIBLE_STEPS.includes(id)) { + nextSteps = applyStepStatus(nextSteps, id, status); + } + + if (nextSteps !== this.snapshot.steps) { + this.update({ steps: nextSteps }); + } + } + + /** + * Flip the matching entries in `filesRead` from `reading` to + * `analyzed`. Paths not present in the store are added as + * pre-analyzed (defensive — covers tools that return file lists + * without a prior `recordFilesReading` call). + */ + markFilesAnalyzed(paths: string[]): void { + if (paths.length === 0) { + return; + } + const byPath = new Map( + this.snapshot.filesRead.map((entry) => [entry.path, entry]) + ); + for (const path of paths) { + byPath.set(path, { path, status: "analyzed" }); + } + this.update({ filesRead: [...byPath.values()] }); + } + + // ── Internal ────────────────────────────────────────────────────── + + /** + * Replace the snapshot (immutable update), then notify all + * subscribers. Listeners are called synchronously — fine for the + * single-React-root setup the wizard uses. + */ + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) { + listener(); + } + } + + // Severity-to-prefix mapping kept here (alongside the entry type) so + // both the React renderer and the post-dispose stderr replay agree on + // the format. Used by `OpenTuiUI` when assembling its transcript. + static prefixFor(severity: LogSeverity, code?: SpinnerExitCode): string { + if (severity === "message") { + return " "; + } + if (severity === "info") { + return "●"; + } + if (severity === "warn") { + return "▲"; + } + if (severity === "error") { + return "✖"; + } + if (severity === "success") { + return "✔"; + } + // Spinner stop codes get mapped through this same function for + // transcript replay; default to the success glyph. + if (code === 1) { + return "✖"; + } + if (code === 2) { + return "▲"; + } + return "✔"; + } +} + +/** + * Back-fill any `pending` step whose canonical position is earlier + * than `startedIndex` to `skipped`. The workflow can only move + * forward, so a still-pending earlier step that the runner walked + * past was bypassed by an `if`-branch. + * + * Returns the original array reference if nothing changed — the + * store relies on this to skip subscriber notifications for no-op + * mutations. + */ +function backfillSkippedSteps( + steps: StepEntry[], + startedIndex: number +): StepEntry[] { + let changed = false; + const candidate = steps.map((entry) => { + if (entry.status !== "pending") { + return entry; + } + const entryIndex = CANONICAL_STEP_ORDER.indexOf(entry.id); + if (entryIndex >= 0 && entryIndex < startedIndex) { + changed = true; + return { ...entry, status: "skipped" as StepStatus }; + } + return entry; + }); + return changed ? candidate : steps; +} + +/** + * Apply a status update to the matching step entry, with idempotency + * and clobber-protection rules: + * + * - Re-entering an already-`in_progress` step is a no-op (the same + * step can suspend multiple times). + * - Explicit `skipped` only wins when the row is currently + * `pending` — protects against accidentally clobbering a + * completed step. + * - `completed` / `failed` always overwrite. + * + * Returns the original array reference when the update is a no-op + * so subscribers aren't notified. + */ +function applyStepStatus( + steps: StepEntry[], + id: string, + status: StepStatus +): StepEntry[] { + const targetIndex = steps.findIndex((entry) => entry.id === id); + if (targetIndex === -1) { + return steps; + } + const current = steps[targetIndex]; + if (!current) { + return steps; + } + if (status === current.status) { + return steps; + } + if (status === "skipped" && current.status !== "pending") { + return steps; + } + const updated = [...steps]; + updated[targetIndex] = { ...current, status }; + return updated; +} diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts new file mode 100644 index 000000000..7e22cb082 --- /dev/null +++ b/src/lib/init/ui/opentui-ui.ts @@ -0,0 +1,593 @@ +/** + * OpenTuiUI — React-based full-screen `WizardUI` implementation. + * + * The class itself is a thin bridge between the imperative `WizardUI` + * surface (which the wizard runner calls into) and a React tree + * mounted via `@opentui/react`'s `createRoot`. State lives in a + * `WizardStore` (see `opentui-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 React rather than imperative Renderable mutation? + * + * - Multi-select with toggle state was racy under direct + * `SelectRenderable.setOptions()` calls — keystrokes could land + * between the toggle and the redraw, leaving the visible markers + * out of sync with the internal set. + * - The Sentry-tips sidebar rotates on a timer; React's prop diff + * handles the swap with no manual `text.content =` plumbing. + * - The completion summary uses structured data (key/value rows, + * changed-files list) rather than pre-rendered markdown, which + * OpenTUI's TextRenderable can't display correctly. React's + * declarative composition is the natural way to lay it out. + * + * **Bun-only.** OpenTUI's native bindings ship as Zig — they don't run + * on the npm/Node distribution. The factory in `factory.ts` only + * routes here when running inside the Bun-compiled binary. + * + * **Lazy import.** `@opentui/core`, `@opentui/react`, and `react` are + * all dynamically imported by `createOpenTuiUI()` so the npm bundle + * (which excludes them from the bundle graph) never sees the imports + * at module-load time. + */ + +import chalk from "chalk"; +import { stripAnsi } from "../../formatters/plain-detect.js"; +import { buildFileTree, flattenTree } from "./file-tree.js"; +import { WizardStore } from "./opentui-store.js"; +import { SENTRY_TIPS } from "./sentry-tips.js"; + +// Brand palette mirrored from `opentui-app.tsx` — kept in sync so the +// post-dispose stderr report (rendered via chalk, not OpenTUI) feels +// like a continuation of the wizard's live screen rather than a +// separate, plainer surface. +const REPORT_MUTED = "#6E6C7E"; +const REPORT_SUCCESS = "#86EFAC"; +const REPORT_ERROR = "#F87171"; +const REPORT_WARN = "#FBBF24"; + +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOptions, + type SpinnerExitCode, + type SpinnerHandle, + type WizardLog, + type WizardSummary, + type WizardUI, +} from "./types.js"; + +/** Spinner cadence — matches `LoggingUI`/legacy spinner cadence. */ +const SPINNER_INTERVAL_MS = process.platform.startsWith("win") ? 80 : 120; + +/** 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 the OpenTUI UI. Kept narrowly typed so + * callers can't pass arbitrary strings into `appendLog`. Mirrors the + * keys of `ICON_BY_SEVERITY` in `opentui-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 `opentui-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? The React tree statically imports + * `react` + `@opentui/react`. When Bun.compile bundles those imports + * through its `__commonJS` + `__esm` async-init wrappers it generates + * malformed code (a TDZ `init_react` symbol embedded in expression + * scope), and the resulting binary crashes at startup with a parse + * error. Embedding the .tsx as raw bytes pushes the React 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 `createOpenTuiUI()` (the + * factory routes there only on the Bun binary), so this import is + * harmless for the npm bundle. + */ +// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun +import opentuiAppPath from "./opentui-app.tsx" with { type: "file" }; + +/** + * Async factory for `OpenTuiUI`. Imports `@opentui/core`, + * `@opentui/react`, `react`, and the local `App` component lazily, + * mounts the React tree, and returns the bridge instance. Throws if + * any of the native bindings are missing (e.g. accidentally invoked + * from Node). + */ +export async function createOpenTuiUI(): Promise { + // Serialize the imports — `@opentui/react` re-exports core + // primitives via its own bundle and the parallel-import path + // tripped a TDZ error inside their `chunk-*.js` because the + // re-export landed before core's class declarations. + const core = await import("@opentui/core"); + const reactBindings = await import("@opentui/react"); + const react = await import("react"); + // See the comment on the `opentuiAppPath` import above for why + // this goes through the embedded-file path rather than a plain + // `import("./opentui-app.js")`. The cast preserves typing against + // the source module so `app.App` keeps its component signature. + // + // 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( + `${opentuiAppPath}?bridge=1` + )) as typeof import("./opentui-app.js"); + + const renderer = await core.createCliRenderer({ + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + + const store = new WizardStore({ + bannerRows: BANNER_ROWS.map((content, i) => ({ + content, + color: BANNER_GRADIENT[i] ?? BANNER_GRADIENT[0] ?? "#FFFFFF", + })), + }); + + const root = reactBindings.createRoot(renderer); + // `react.createElement` is the typed JSX factory; we cast the App + // component reference so TypeScript accepts the `{ store }` props + // bag without dragging the React types into the bridge module. + root.render(react.createElement(app.App, { store })); + + // Cast the root to our local `RenderRoot` shape. The shape matches + // structurally (`render(node)` + `unmount()`); the cast just opts + // out of React's stricter `ReactNode` parameter to keep the + // imperative bridge free of React types. + return new OpenTuiUI(renderer, root as unknown as RenderRoot, store); +} + +// Locally-scoped type aliases for the bridge — these all come from +// dynamic imports so we keep them as `unknown`-ish constraints rather +// than depending on the upstream packages' types directly. +type RenderRoot = { + render: (node: unknown) => void; + unmount: () => void; +}; + +// ──────────────────────────── Implementation ────────────────────────── + +/** + * Bridge between the imperative `WizardUI` surface and the React + * `App` component. Mutations land in the `WizardStore`; React + * re-renders. + */ +export class OpenTuiUI implements WizardUI { + // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary + private readonly renderer: any; + private readonly root: RenderRoot; + private readonly store: WizardStore; + private spinnerTimer: ReturnType | undefined; + private tipTimer: ReturnType | undefined; + private tipIndex = 0; + private activePromptCancel: (() => void) | undefined; + /** + * Final wizard outcome captured by the bridge. + * + * The OpenTUI alternate-screen buffer is wiped the moment + * `renderer.destroy()` runs, so anything we want the user to see in + * their scrollback has to be re-emitted to stderr after destroy. + * Earlier versions replayed every log/intro/outro line — that + * produced a noisy wall of `▸ sentry init`, `● This wizard uses + * AI…`, and intermediate spinner stops. We now keep just enough + * state to print a focused completion report: + * + * - `outroMessage` — the success line (set by `outro()`). + * - `failureMessage` — the error/cancel line (set by `cancel()` + * or by `log.error()` for a fatal abort). + * - The store's `summary` snapshot — already structured. + * + * Whichever pair is populated wins on dispose. If neither is set + * (e.g. early abort before any outcome was recorded) we print + * nothing, matching the previous "no transcript" behavior. + */ + private outroMessage: string | undefined; + private failureMessage: string | undefined; + + constructor( + // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary + renderer: any, + root: RenderRoot, + store: WizardStore + ) { + this.renderer = renderer; + this.root = root; + this.store = store; + this.startTipRotation(); + this.installCancelHandler(); + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + banner(_art: string): void { + // No-op — `App` paints the banner inside its alternate-screen + // header from the gradient rows pre-loaded into the store. The + // runner-supplied ANSI string is discarded (OpenTUI can't render + // embedded escape codes). + } + + intro(_title: string): void { + // No-op. The box's top-border title and the gradient banner + // already announce the wizard; an extra "▸ sentry init" line + // underneath felt redundant in user feedback. We keep the method + // on the interface for parity with `LoggingUI`, where the + // command-line shell makes a separate intro line useful. + } + + outro(message: string): void { + // Show the success line live in the log pane, and remember it for + // the post-dispose scrollback report. + 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); + } + + // ── 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 (!this.spinnerTimer) { + this.spinnerTimer = setInterval(() => { + this.store.tickSpinner(); + }, SPINNER_INTERVAL_MS); + } + }, + message: (message?: string) => { + if (message !== undefined) { + this.store.setSpinnerMessage(stripAnsi(message)); + } + }, + stop: (message?: string, code: SpinnerExitCode = 0) => { + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + const finalMessage = message + ? stripAnsi(message) + : this.store.getSnapshot().spinner.message; + this.store.stopSpinner(); + // Promote the spinner's final state into the log pane so it + // survives subsequent `start()` calls. + 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[]); + } + }, + }); + }); + } + + async confirm(opts: ConfirmOptions): Promise { + const result = await this.select<"yes" | "no">({ + message: opts.message, + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + initialValue: (opts.initialValue ?? true) ? "yes" : "no", + }); + if (result === CANCELLED) { + return CANCELLED; + } + return result === "yes"; + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + if (this.tipTimer) { + clearInterval(this.tipTimer); + this.tipTimer = undefined; + } + try { + this.root.unmount(); + } catch { + // Ignore — disposal must never throw. + } + try { + this.renderer.destroy(); + } catch { + // Ignore. + } + const report = this.buildPostDisposeReport(); + if (report) { + process.stderr.write(`${report}\n`); + } + return Promise.resolve(); + } + + /** + * Build the compact scrollback report shown after `destroy()` wipes + * the alternate 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 (e.g. error mid-run + * after a partial summary was emitted). + * + * The report is colored via chalk (not OpenTUI) — by the time it + * runs, `renderer.destroy()` has already restored the main screen + * and chalk's TTY detection picks up where it left off. Keeping + * the palette aligned with the live UI's brand colors makes the + * scrollback handoff feel intentional. + */ + private buildPostDisposeReport(): string | undefined { + if (this.failureMessage) { + const icon = chalk.hex(REPORT_ERROR)("✖"); + return `\n${icon} ${chalk.hex(REPORT_ERROR).bold(this.failureMessage)}`; + } + 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); + } + + /** + * Wire the global Ctrl+C / Escape handler. Cooperative cancellation + * — resolve the active prompt with `CANCELLED` rather than yanking + * the process down, so `wizard-runner.ts` can drive its normal + * cleanup path (telemetry, exit code, etc.). + */ + private installCancelHandler(): void { + this.renderer.keyInput.on( + "keypress", + (event: { name: string; ctrl?: boolean }) => { + const isCancel = + (event.ctrl && event.name === "c") || event.name === "escape"; + if (!isCancel) { + return; + } + const cancelFn = this.activePromptCancel; + if (cancelFn) { + cancelFn(); + } + } + ); + } +} + +/** + * 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. We keep both copies (vs. extracting a + * shared module) because each impl wants different rendering — chalk + * here, raw text there — and the helpers are tiny. + */ +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/sentry-tips.ts b/src/lib/init/ui/sentry-tips.ts new file mode 100644 index 000000000..1a5f29b34 --- /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 + * `OpenTuiUI` 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/types.ts b/src/lib/init/ui/types.ts new file mode 100644 index 000000000..48554469f --- /dev/null +++ b/src/lib/init/ui/types.ts @@ -0,0 +1,246 @@ +/** + * WizardUI Abstraction Layer + * + * Defines the I/O surface used by the init wizard. Concrete implementations + * provide the actual rendering: + * + * - `OpenTuiUI` — alternate-buffer full-screen UI built on `@opentui/core`. + * Default for interactive runs on the Bun-compiled binary. + * - `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. + * - `OpenTuiUI` mounts a colored panel inside the alternate-screen + * layout with proper alignment and per-action glyphs. + * + * Previously `formatResult` built terminal markdown and called + * `ui.log.message(markdown)` — this leaked literal `` tags + * into the OpenTUI panel because OpenTUI's `TextRenderable` has no + * markdown parser, only a `stripAnsi` step. + */ +export type WizardSummary = { + /** Flat list of `