diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d120e5c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 80, + "tabWidth": 2 +} diff --git a/CLAUDE.md b/CLAUDE.md index 4b355a0..add2397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,12 @@ # Code Conventions +## Linting & Formatting + +- ESLint: `npm run lint` (includes `tsc --noEmit` + ESLint), `npm run lint:fix` to auto-fix +- Prettier: `npm run format` to write, `npm run format:check` to verify +- Config: `eslint.config.js` (flat config), `.prettierrc` +- Unused variables must be prefixed with `_` + ## JavaScript/TypeScript style - Use `Number.parseInt()` instead of global `parseInt()` diff --git a/README.md b/README.md index d237451..05a3da6 100644 --- a/README.md +++ b/README.md @@ -52,23 +52,31 @@ crucible inspect my-queue --seq 12345 crucible search my-queue --body "OrderId:123" crucible search my-queue --property "CorrelationId=abc" -# Dead-letter management +# Dead-letter management (queues) crucible deadletter my-queue --reasons crucible replay my-queue --dry-run crucible replay my-queue --count 10 --backup before-replay.json crucible purge my-queue --dlq +# Dead-letter management (topic subscriptions) +crucible deadletter my-topic/my-sub --reasons +crucible replay my-topic/my-sub --dry-run +crucible purge my-topic/my-sub --dlq + # Send messages crucible send my-queue --body '{"orderId": 123}' +crucible send my-topic/my-sub --body '{"event": "retry"}' crucible send my-queue --file payload.json --count 100 --delay 50 # Export / Import crucible export my-queue --dlq --format json > dlq-messages.json +crucible export my-topic/my-sub --dlq --format json > dlq-messages.json crucible export my-queue --format csv > messages.csv crucible import my-queue --file dlq-messages.json # Namespace topology crucible topology # tree view +crucible topology --rules # include filter expressions crucible topology --format mermaid > topology.md # Snapshot & drift detection @@ -85,6 +93,17 @@ crucible watch my-queue --dlq-threshold 10 --notify crucible watch my-queue --dlq-threshold 100 --exec 'curl -X POST https://hooks.slack.com/... -d "{\"text\":\"DLQ alert: $CRUCIBLE_ENTITY has $CRUCIBLE_DLQ messages\"}"' ``` +## Entity Format + +Most commands accept an `` argument. Use the queue name for queues, or `topic/subscription` for topic subscriptions: + +| Format | Target | +|---|---| +| `my-queue` | Queue named `my-queue` | +| `my-topic/my-sub` | Subscription `my-sub` on topic `my-topic` | + +For example, `crucible deadletter my-queue` targets a queue's DLQ, while `crucible deadletter my-topic/my-sub` targets the subscription's DLQ. + ## Commands ### Foundation @@ -112,7 +131,7 @@ crucible watch my-queue --dlq-threshold 100 --exec 'curl -X POST https://hooks.s | `crucible watch` | Local DLQ alerts — `--dlq-threshold`, `--exec`, `--notify` | | `crucible export` | Export messages as JSON or CSV (pipe-friendly) | | `crucible import` | Bulk send from JSON file | -| `crucible topology` | Namespace tree — `--format tree\|json\|mermaid` | +| `crucible topology` | Namespace tree — `--format tree\|json\|mermaid`, `--rules` to show filter expressions | ### Power Features | Command | Description | diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c965e19 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import prettierConfig from "eslint-config-prettier"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + ignores: ["dist/", "node_modules/"], + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_" }, + ], + }, + } +); diff --git a/package-lock.json b/package-lock.json index 24fedad..8acef5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,13 +22,21 @@ "crucible": "dist/cli.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^22.13.0", "@types/node-notifier": "^8.0.5", "@types/react": "^19.2.14", "esbuild": "^0.25.0", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", "tsx": "^4.19.0", "typescript": "^5.7.0", + "typescript-eslint": "^8.57.2", "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -757,6 +765,186 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1171,6 +1359,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1187,6 +1382,13 @@ "@types/node": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -1216,6 +1418,236 @@ "csstype": "^3.2.2" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", @@ -1345,6 +1777,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1354,6 +1809,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1427,6 +1899,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1447,6 +1929,19 @@ ], "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1719,6 +2214,21 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1753,6 +2263,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1949,14 +2466,195 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" } }, "node_modules/events": { @@ -1978,6 +2676,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -2031,6 +2750,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2141,6 +2911,19 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2256,6 +3039,26 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -2432,6 +3235,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2460,6 +3273,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -2554,6 +3380,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2606,6 +3453,46 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2689,6 +3576,22 @@ "node": ">=6" } }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2714,6 +3617,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-notifier": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", @@ -2788,6 +3698,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -2797,6 +3757,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", @@ -2812,6 +3782,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2837,9 +3817,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2887,6 +3867,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -2896,6 +3902,16 @@ "node": ">= 0.6.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3095,6 +4111,29 @@ "node": ">= 0.4" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", @@ -3317,6 +4356,19 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3827,6 +4879,19 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", @@ -3856,12 +4921,46 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -4650,6 +5749,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -4753,6 +5862,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", diff --git a/package.json b/package.json index ad572bd..8b672bd 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "dev": "tsx src/cli.ts", "test": "vitest run", "test:watch": "vitest", - "lint": "tsc --noEmit", + "lint": "tsc --noEmit && eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "prepublishOnly": "npm run build" }, "keywords": [ @@ -52,12 +55,20 @@ "react": "^19.2.4" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^22.13.0", "@types/node-notifier": "^8.0.5", "@types/react": "^19.2.14", "esbuild": "^0.25.0", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", "tsx": "^4.19.0", "typescript": "^5.7.0", + "typescript-eslint": "^8.57.2", "vitest": "^3.0.0" + }, + "overrides": { + "picomatch": "4.0.4" } } diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 34dac54..15b2dd8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { getActiveProfile, type CrucibleConfig } from "../lib/config.js"; describe("getActiveProfile", () => { diff --git a/src/commands/config.ts b/src/commands/config.ts index 1f5f026..dd10fe9 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,37 +11,40 @@ configCommand .description("Add a namespace profile") .option("--connection-string ", "Service Bus connection string") .option("--namespace ", "Service Bus namespace FQDN (for Entra ID)") - .action(async (name: string, opts: { connectionString?: string; namespace?: string }) => { - if (!opts.connectionString && !opts.namespace) { - console.error( - chalk.red("Provide --connection-string or --namespace") - ); - process.exit(1); - } - - const config = await loadConfig(); - const existing = config.profiles.findIndex((p) => p.name === name); - - const profile = { - name, - connectionString: opts.connectionString, - namespace: opts.namespace, - }; - - if (existing >= 0) { - config.profiles[existing] = profile; - console.log(chalk.yellow(`Updated profile "${name}"`)); - } else { - config.profiles.push(profile); - console.log(chalk.green(`Added profile "${name}"`)); + .action( + async ( + name: string, + opts: { connectionString?: string; namespace?: string } + ) => { + if (!opts.connectionString && !opts.namespace) { + console.error(chalk.red("Provide --connection-string or --namespace")); + process.exit(1); + } + + const config = await loadConfig(); + const existing = config.profiles.findIndex((p) => p.name === name); + + const profile = { + name, + connectionString: opts.connectionString, + namespace: opts.namespace, + }; + + if (existing >= 0) { + config.profiles[existing] = profile; + console.log(chalk.yellow(`Updated profile "${name}"`)); + } else { + config.profiles.push(profile); + console.log(chalk.green(`Added profile "${name}"`)); + } + + if (config.profiles.length === 1) { + config.activeProfile = name; + } + + await saveConfig(config); } - - if (config.profiles.length === 1) { - config.activeProfile = name; - } - - await saveConfig(config); - }); + ); configCommand .command("list") @@ -50,7 +53,9 @@ configCommand const config = await loadConfig(); if (config.profiles.length === 0) { - console.log(chalk.dim("No profiles configured. Run: crucible config add ")); + console.log( + chalk.dim("No profiles configured. Run: crucible config add ") + ); return; } diff --git a/src/commands/costs.ts b/src/commands/costs.ts index 4ed3215..900057e 100644 --- a/src/commands/costs.ts +++ b/src/commands/costs.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import chalk from "chalk"; import Table from "cli-table3"; import { createClients } from "../lib/client.js"; +import type { ServiceBusAdministrationClient } from "@azure/service-bus"; // Azure Service Bus pricing (Standard tier, approximate USD) // https://azure.microsoft.com/en-us/pricing/details/service-bus/ @@ -21,85 +22,187 @@ interface EntityCost { issues: string[]; } +function estimateMonthlyCost(opsPerDay: number, entityCount: number): number { + return ( + PRICING.perTopicOrQueue * 30 * entityCount + + (opsPerDay * 30 * PRICING.perMillionOps) / 1_000_000 + ); +} + +function detectQueueIssues( + totalMessages: number, + activeCount: number, + dlqCount: number +): string[] { + const issues: string[] = []; + if (totalMessages === 0) issues.push("empty — possibly unused"); + if (dlqCount > activeCount && dlqCount > 10) { + issues.push("DLQ larger than active — check consumer health"); + } + return issues; +} + +function detectTopicIssues( + totalMessages: number, + subCount: number, + activeCount: number, + dlqCount: number +): string[] { + const issues: string[] = []; + if (totalMessages === 0 && subCount === 0) + issues.push("no subscriptions — unused topic"); + if (totalMessages === 0 && subCount > 0) + issues.push("all subscriptions empty — possibly unused"); + if (subCount > 10) + issues.push(`${subCount} subscriptions — fan-out may be expensive`); + if (dlqCount > activeCount && dlqCount > 10) + issues.push("DLQ larger than active across subscriptions"); + return issues; +} + +async function gatherQueueCosts( + admin: ServiceBusAdministrationClient +): Promise { + const entities: EntityCost[] = []; + for await (const queue of admin.listQueues()) { + const rt = await admin.getQueueRuntimeProperties(queue.name); + const totalMessages = + rt.activeMessageCount + + rt.deadLetterMessageCount + + rt.scheduledMessageCount; + const estimatedOpsPerDay = Math.max(totalMessages * 10, 100); + + entities.push({ + type: "queue", + name: queue.name, + activeMessages: rt.activeMessageCount, + dlqMessages: rt.deadLetterMessageCount, + estimatedOpsPerDay, + monthlyCost: estimateMonthlyCost(estimatedOpsPerDay, 1), + issues: detectQueueIssues( + totalMessages, + rt.activeMessageCount, + rt.deadLetterMessageCount + ), + }); + } + return entities; +} + +async function gatherTopicCosts( + admin: ServiceBusAdministrationClient +): Promise { + const entities: EntityCost[] = []; + for await (const topic of admin.listTopics()) { + let topicTotalActive = 0; + let topicTotalDlq = 0; + let subCount = 0; + + for await (const sub of admin.listSubscriptions(topic.name)) { + const rt = await admin.getSubscriptionRuntimeProperties( + topic.name, + sub.subscriptionName + ); + topicTotalActive += rt.activeMessageCount; + topicTotalDlq += rt.deadLetterMessageCount; + subCount++; + } + + const totalMessages = topicTotalActive + topicTotalDlq; + const estimatedOpsPerDay = Math.max(totalMessages * 10, 100) * subCount; + + entities.push({ + type: "topic", + name: `${topic.name} (${subCount} subs)`, + activeMessages: topicTotalActive, + dlqMessages: topicTotalDlq, + estimatedOpsPerDay, + monthlyCost: estimateMonthlyCost(estimatedOpsPerDay, 1 + subCount), + issues: detectTopicIssues( + totalMessages, + subCount, + topicTotalActive, + topicTotalDlq + ), + }); + } + return entities; +} + +function renderSummary(totalMonthlyCost: number): void { + console.log(chalk.bold("Estimated Monthly Cost\n")); + console.log( + ` Namespace base: ${chalk.cyan("$" + PRICING.basePerMonth.toFixed(2))}` + ); + console.log( + ` Entity + ops: ${chalk.cyan( + "$" + (totalMonthlyCost - PRICING.basePerMonth).toFixed(2) + )}` + ); + console.log( + ` ${chalk.bold( + "Total: $" + totalMonthlyCost.toFixed(2) + "/month" + )}` + ); + console.log( + chalk.dim( + "\n * Estimates based on Standard tier pricing and current message counts." + ) + ); + console.log( + chalk.dim(" Actual costs depend on throughput, tier, and region.\n") + ); +} + +function renderTable(entities: EntityCost[]): void { + const table = new Table({ + head: ["Type", "Name", "Active", "DLQ", "Est. $/mo"].map((h) => + chalk.bold(h) + ), + }); + + for (const e of entities) { + table.push([ + chalk.dim(e.type), + e.name, + e.activeMessages.toString(), + e.dlqMessages > 0 ? chalk.yellow(e.dlqMessages.toString()) : "0", + "$" + e.monthlyCost.toFixed(2), + ]); + } + console.log(table.toString()); +} + +function renderOptimizations(entities: EntityCost[]): void { + const suggestions = entities.filter((e) => e.issues.length > 0); + if (suggestions.length === 0) { + console.log(chalk.green("\nNo optimization suggestions.")); + return; + } + console.log(chalk.bold("\nOptimization Suggestions:\n")); + for (const e of suggestions) { + for (const issue of e.issues) { + console.log(` ${chalk.yellow("!")} ${chalk.bold(e.name)} — ${issue}`); + } + } +} + export const costsCommand = new Command("costs") .description("Estimate monthly cost and surface optimization opportunities") .option("--optimize", "Show optimization suggestions") .option("--json", "Output as JSON") .option("--namespace ", "Override namespace") .action( - async (opts: { optimize?: boolean; json?: boolean; namespace?: string }) => { + async (opts: { + optimize?: boolean; + json?: boolean; + namespace?: string; + }) => { const { admin } = await createClients(opts.namespace); - const entities: EntityCost[] = []; - - // Queues - for await (const queue of admin.listQueues()) { - const rt = await admin.getQueueRuntimeProperties(queue.name); - const totalMessages = rt.activeMessageCount + rt.deadLetterMessageCount + rt.scheduledMessageCount; - // Rough estimate: messages present suggest throughput - const estimatedOpsPerDay = Math.max(totalMessages * 10, 100); - const monthlyCost = - PRICING.perTopicOrQueue * 30 + - (estimatedOpsPerDay * 30 * PRICING.perMillionOps) / 1_000_000; - - const issues: string[] = []; - if (totalMessages === 0) issues.push("empty — possibly unused"); - if (rt.deadLetterMessageCount > rt.activeMessageCount && rt.deadLetterMessageCount > 10) { - issues.push("DLQ larger than active — check consumer health"); - } - - entities.push({ - type: "queue", - name: queue.name, - activeMessages: rt.activeMessageCount, - dlqMessages: rt.deadLetterMessageCount, - estimatedOpsPerDay, - monthlyCost, - issues, - }); - } - - // Topics + Subscriptions - for await (const topic of admin.listTopics()) { - let topicTotalActive = 0; - let topicTotalDlq = 0; - let subCount = 0; - - for await (const sub of admin.listSubscriptions(topic.name)) { - const rt = await admin.getSubscriptionRuntimeProperties( - topic.name, - sub.subscriptionName - ); - topicTotalActive += rt.activeMessageCount; - topicTotalDlq += rt.deadLetterMessageCount; - subCount++; - } - - const totalMessages = topicTotalActive + topicTotalDlq; - const estimatedOpsPerDay = Math.max(totalMessages * 10, 100) * subCount; - const monthlyCost = - PRICING.perTopicOrQueue * 30 * (1 + subCount) + - (estimatedOpsPerDay * 30 * PRICING.perMillionOps) / 1_000_000; - - const issues: string[] = []; - if (totalMessages === 0 && subCount === 0) - issues.push("no subscriptions — unused topic"); - if (totalMessages === 0 && subCount > 0) - issues.push("all subscriptions empty — possibly unused"); - if (subCount > 10) - issues.push(`${subCount} subscriptions — fan-out may be expensive`); - if (topicTotalDlq > topicTotalActive && topicTotalDlq > 10) - issues.push("DLQ larger than active across subscriptions"); - - entities.push({ - type: "topic", - name: `${topic.name} (${subCount} subs)`, - activeMessages: topicTotalActive, - dlqMessages: topicTotalDlq, - estimatedOpsPerDay, - monthlyCost, - issues, - }); - } + const entities = [ + ...(await gatherQueueCosts(admin)), + ...(await gatherTopicCosts(admin)), + ]; const totalMonthlyCost = PRICING.basePerMonth + @@ -120,61 +223,8 @@ export const costsCommand = new Command("costs") return; } - // Summary - console.log(chalk.bold("Estimated Monthly Cost\n")); - console.log( - ` Namespace base: ${chalk.cyan("$" + PRICING.basePerMonth.toFixed(2))}` - ); - console.log( - ` Entity + ops: ${chalk.cyan("$" + (totalMonthlyCost - PRICING.basePerMonth).toFixed(2))}` - ); - console.log( - ` ${chalk.bold("Total: $" + totalMonthlyCost.toFixed(2) + "/month")}` - ); - console.log( - chalk.dim( - "\n * Estimates based on Standard tier pricing and current message counts." - ) - ); - console.log( - chalk.dim(" Actual costs depend on throughput, tier, and region.\n") - ); - - // Entity breakdown - const table = new Table({ - head: ["Type", "Name", "Active", "DLQ", "Est. $/mo"].map((h) => - chalk.bold(h) - ), - }); - - for (const e of entities) { - table.push([ - chalk.dim(e.type), - e.name, - e.activeMessages.toString(), - e.dlqMessages > 0 - ? chalk.yellow(e.dlqMessages.toString()) - : "0", - "$" + e.monthlyCost.toFixed(2), - ]); - } - console.log(table.toString()); - - // Optimization suggestions - if (opts.optimize) { - const suggestions = entities.filter((e) => e.issues.length > 0); - if (suggestions.length === 0) { - console.log(chalk.green("\nNo optimization suggestions.")); - } else { - console.log(chalk.bold("\nOptimization Suggestions:\n")); - for (const e of suggestions) { - for (const issue of e.issues) { - console.log( - ` ${chalk.yellow("!")} ${chalk.bold(e.name)} — ${issue}` - ); - } - } - } - } + renderSummary(totalMonthlyCost); + renderTable(entities); + if (opts.optimize) renderOptimizations(entities); } ); diff --git a/src/commands/deadletter.ts b/src/commands/deadletter.ts index 6697e12..5106b68 100644 --- a/src/commands/deadletter.ts +++ b/src/commands/deadletter.ts @@ -34,7 +34,9 @@ export const deadletterCommand = new Command("deadletter") }); try { - const messages = await receiver.peekMessages(Number.parseInt(opts.count, 10)); + const messages = await receiver.peekMessages( + Number.parseInt(opts.count, 10) + ); if (messages.length === 0) { console.log(chalk.green("No dead-letter messages")); @@ -53,9 +55,15 @@ export const deadletterCommand = new Command("deadletter") return; } - console.log(chalk.bold(`Dead-letter reasons (${messages.length} messages):\n`)); - for (const [reason, count] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) { - console.log(` ${chalk.yellow(count.toString().padStart(4))} ${reason}`); + console.log( + chalk.bold(`Dead-letter reasons (${messages.length} messages):\n`) + ); + for (const [reason, count] of [...reasons.entries()].sort( + (a, b) => b[1] - a[1] + )) { + console.log( + ` ${chalk.yellow(count.toString().padStart(4))} ${reason}` + ); } return; } diff --git a/src/commands/diff.ts b/src/commands/diff.ts index 53f12a2..b98677e 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -17,116 +17,115 @@ interface DiffEntry { after?: unknown; } -function diffQueues( - before: QueueSnapshot[], - after: QueueSnapshot[] +interface Named { + name: string; +} + +/** + * Generic diff for named entities. Detects added, removed, and changed items. + * @param prefix - path prefix for diff entries (e.g. "queue" or "topic/orders") + * @param before - entities in the baseline + * @param after - entities in the current state + * @param keyFn - extract the map key from an entity + * @param compareFn - produce diff entries for two matched entities (optional) + */ +function diffNamedEntities( + prefix: string, + before: T[], + after: T[], + keyFn: (item: T) => string, + compareFn?: (path: string, b: T, a: T) => DiffEntry[] ): DiffEntry[] { const diffs: DiffEntry[] = []; - const beforeMap = new Map(before.map((q) => [q.name, q])); - const afterMap = new Map(after.map((q) => [q.name, q])); + const beforeMap = new Map(before.map((item) => [keyFn(item), item])); + const afterMap = new Map(after.map((item) => [keyFn(item), item])); - for (const [name, q] of afterMap) { + for (const name of afterMap.keys()) { if (!beforeMap.has(name)) { - diffs.push({ type: "added", path: `queue/${name}` }); + diffs.push({ type: "added", path: `${prefix}/${name}` }); } } - for (const [name, q] of beforeMap) { - if (!afterMap.has(name)) { - diffs.push({ type: "removed", path: `queue/${name}` }); - } else { - const a = afterMap.get(name)!; - for (const key of Object.keys(q) as Array) { - if (key === "name") continue; - if (JSON.stringify(q[key]) !== JSON.stringify(a[key])) { - diffs.push({ - type: "changed", - path: `queue/${name}.${key}`, - before: q[key], - after: a[key], - }); - } - } + + for (const [name, b] of beforeMap) { + const a = afterMap.get(name); + if (!a) { + diffs.push({ type: "removed", path: `${prefix}/${name}` }); + } else if (compareFn) { + diffs.push(...compareFn(`${prefix}/${name}`, b, a)); } } + return diffs; } -function diffTopics( - before: TopicSnapshot[], - after: TopicSnapshot[] +/** Compare all own properties of two objects (skipping listed keys). */ +function diffProperties( + path: string, + before: object, + after: object, + skip: string[] ): DiffEntry[] { const diffs: DiffEntry[] = []; - const beforeMap = new Map(before.map((t) => [t.name, t])); - const afterMap = new Map(after.map((t) => [t.name, t])); - - for (const [name] of afterMap) { - if (!beforeMap.has(name)) { - diffs.push({ type: "added", path: `topic/${name}` }); + const b = before as Record; + const a = after as Record; + for (const key of Object.keys(b)) { + if (skip.includes(key)) continue; + if (JSON.stringify(b[key]) !== JSON.stringify(a[key])) { + diffs.push({ + type: "changed", + path: `${path}.${key}`, + before: b[key], + after: a[key], + }); } } - for (const [name, t] of beforeMap) { - if (!afterMap.has(name)) { - diffs.push({ type: "removed", path: `topic/${name}` }); - continue; - } - const a = afterMap.get(name)!; - - // Topic-level config - for (const key of Object.keys(t) as Array) { - if (key === "name" || key === "subscriptions") continue; - if (JSON.stringify(t[key]) !== JSON.stringify(a[key])) { - diffs.push({ - type: "changed", - path: `topic/${name}.${key}`, - before: t[key], - after: a[key], - }); - } - } - - // Subscriptions - diffs.push(...diffSubscriptions(name, t.subscriptions, a.subscriptions)); - } return diffs; } +function diffQueues( + before: QueueSnapshot[], + after: QueueSnapshot[] +): DiffEntry[] { + return diffNamedEntities( + "queue", + before, + after, + (q) => q.name, + (path, b, a) => diffProperties(path, b, a, ["name"]) + ); +} + +function diffTopics( + before: TopicSnapshot[], + after: TopicSnapshot[] +): DiffEntry[] { + return diffNamedEntities( + "topic", + before, + after, + (t) => t.name, + (path, b, a) => [ + ...diffProperties(path, b, a, ["name", "subscriptions"]), + ...diffSubscriptions(b.name, b.subscriptions, a.subscriptions), + ] + ); +} + function diffSubscriptions( topicName: string, before: SubscriptionSnapshot[], after: SubscriptionSnapshot[] ): DiffEntry[] { - const diffs: DiffEntry[] = []; - const beforeMap = new Map(before.map((s) => [s.name, s])); - const afterMap = new Map(after.map((s) => [s.name, s])); - - for (const [name] of afterMap) { - if (!beforeMap.has(name)) { - diffs.push({ type: "added", path: `topic/${topicName}/${name}` }); - } - } - for (const [name, s] of beforeMap) { - if (!afterMap.has(name)) { - diffs.push({ type: "removed", path: `topic/${topicName}/${name}` }); - continue; - } - const a = afterMap.get(name)!; - - for (const key of Object.keys(s) as Array) { - if (key === "name" || key === "rules") continue; - if (JSON.stringify(s[key]) !== JSON.stringify(a[key])) { - diffs.push({ - type: "changed", - path: `topic/${topicName}/${name}.${key}`, - before: s[key], - after: a[key], - }); - } - } - - // Rules - diffs.push(...diffRules(topicName, name, s.rules, a.rules)); - } - return diffs; + return diffNamedEntities( + `topic/${topicName}`, + before, + after, + (s) => s.name, + (path, b, a) => [ + ...diffProperties(path, b, a, ["name", "rules"]), + ...diffRules(topicName, b.name, b.rules, a.rules), + ] + ); } function diffRules( @@ -135,40 +134,13 @@ function diffRules( before: RuleSnapshot[], after: RuleSnapshot[] ): DiffEntry[] { - const diffs: DiffEntry[] = []; - const prefix = `topic/${topicName}/${subName}/rule`; - const beforeMap = new Map(before.map((r) => [r.name, r])); - const afterMap = new Map(after.map((r) => [r.name, r])); - - for (const [name] of afterMap) { - if (!beforeMap.has(name)) { - diffs.push({ type: "added", path: `${prefix}/${name}` }); - } - } - for (const [name, r] of beforeMap) { - if (!afterMap.has(name)) { - diffs.push({ type: "removed", path: `${prefix}/${name}` }); - } else { - const a = afterMap.get(name)!; - if (JSON.stringify(r.filter) !== JSON.stringify(a.filter)) { - diffs.push({ - type: "changed", - path: `${prefix}/${name}.filter`, - before: r.filter, - after: a.filter, - }); - } - if (JSON.stringify(r.action) !== JSON.stringify(a.action)) { - diffs.push({ - type: "changed", - path: `${prefix}/${name}.action`, - before: r.action, - after: a.action, - }); - } - } - } - return diffs; + return diffNamedEntities( + `topic/${topicName}/${subName}/rule`, + before, + after, + (r) => r.name, + (path, b, a) => diffProperties(path, b, a, ["name"]) + ); } function renderDiffs(diffs: DiffEntry[], json?: boolean): void { @@ -205,9 +177,15 @@ export const diffCommand = new Command("diff") "Compare current namespace state against a snapshot file, or compare two namespaces" ) .argument("", "Snapshot file or namespace FQDN") - .argument("[b]", "Second namespace FQDN (for namespace-to-namespace comparison)") + .argument( + "[b]", + "Second namespace FQDN (for namespace-to-namespace comparison)" + ) .option("--json", "Output as JSON") - .option("--namespace ", "Override namespace (when comparing against a snapshot file)") + .option( + "--namespace ", + "Override namespace (when comparing against a snapshot file)" + ) .action( async ( a: string, diff --git a/src/commands/export.ts b/src/commands/export.ts index 1e30e84..bba9f7d 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -72,9 +72,7 @@ export const exportCommand = new Command("export") for (const r of rows) { const bodyStr = - typeof r.body === "string" - ? r.body - : JSON.stringify(r.body); + typeof r.body === "string" ? r.body : JSON.stringify(r.body); const fields = [ r.sequenceNumber, r.messageId, @@ -102,9 +100,7 @@ export const exportCommand = new Command("export") console.log(JSON.stringify(rows, null, 2)); } - console.error( - chalk.dim(`Exported ${rows.length} messages`) - ); + console.error(chalk.dim(`Exported ${rows.length} messages`)); } finally { await receiver.close(); await client.close(); diff --git a/src/commands/import.ts b/src/commands/import.ts index 265c86e..06ffc30 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -53,7 +53,9 @@ export const importCommand = new Command("import") correlationId: m.correlationId, contentType: m.contentType, subject: m.subject, - applicationProperties: m.applicationProperties as Record | undefined, + applicationProperties: m.applicationProperties as + | Record + | undefined, }); sent++; diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index e3416ef..eeccdf4 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,8 +1,109 @@ import { Command } from "commander"; import chalk from "chalk"; +import type { + ServiceBusReceivedMessage, + ServiceBusClient, +} from "@azure/service-bus"; import { createClients } from "../lib/client.js"; import { parseEntity } from "../lib/entity.js"; +function createReceiver( + client: ServiceBusClient, + entity: string, + dlq?: boolean +) { + const { queue, topic, subscription } = parseEntity(entity); + const subQueue = dlq ? "deadLetter" : undefined; + return topic + ? client.createReceiver(topic, subscription!, { + subQueueType: subQueue, + receiveMode: "peekLock", + }) + : client.createReceiver(queue!, { + subQueueType: subQueue, + receiveMode: "peekLock", + }); +} + +function buildJsonOutput(m: ServiceBusReceivedMessage): object { + return { + sequenceNumber: m.sequenceNumber?.toString(), + messageId: m.messageId, + correlationId: m.correlationId, + contentType: m.contentType, + subject: m.subject, + to: m.to, + replyTo: m.replyTo, + enqueuedTime: m.enqueuedTimeUtc?.toISOString(), + expiresAt: m.expiresAtUtc?.toISOString(), + timeToLive: m.timeToLive, + deliveryCount: m.deliveryCount, + deadLetterReason: m.deadLetterReason, + deadLetterDescription: m.deadLetterErrorDescription, + deadLetterSource: m.deadLetterSource, + sessionId: m.sessionId, + partitionKey: m.partitionKey, + applicationProperties: m.applicationProperties, + body: m.body, + }; +} + +function renderMessage(m: ServiceBusReceivedMessage): void { + console.log(chalk.bold(`Sequence Number: ${m.sequenceNumber}`)); + console.log(); + + const props: Array<[string, unknown]> = [ + ["Message ID", m.messageId], + ["Correlation ID", m.correlationId], + ["Content Type", m.contentType], + ["Subject", m.subject], + ["To", m.to], + ["Reply To", m.replyTo], + ["Session ID", m.sessionId], + ["Partition Key", m.partitionKey], + ["Enqueued", m.enqueuedTimeUtc?.toISOString()], + ["Expires", m.expiresAtUtc?.toISOString()], + ["TTL", m.timeToLive ? `${m.timeToLive}ms` : undefined], + ["Delivery Count", m.deliveryCount], + ]; + + if (m.deadLetterReason) { + props.push(["DLQ Reason", chalk.red(m.deadLetterReason)]); + props.push(["DLQ Description", m.deadLetterErrorDescription]); + props.push(["DLQ Source", m.deadLetterSource]); + } + + for (const [label, value] of props) { + if (value !== undefined && value !== null && value !== "") { + console.log(` ${chalk.cyan(label + ":")} ${value}`); + } + } + + renderApplicationProperties(m); + renderBody(m); +} + +function renderApplicationProperties(m: ServiceBusReceivedMessage): void { + if ( + !m.applicationProperties || + Object.keys(m.applicationProperties).length === 0 + ) + return; + console.log(); + console.log(chalk.bold("Application Properties:")); + for (const [key, val] of Object.entries(m.applicationProperties)) { + console.log(` ${chalk.cyan(key + ":")} ${val}`); + } +} + +function renderBody(m: ServiceBusReceivedMessage): void { + console.log(); + console.log(chalk.bold("Body:")); + const body = + typeof m.body === "string" ? m.body : JSON.stringify(m.body, null, 2); + console.log(body); +} + export const inspectCommand = new Command("inspect") .description("Inspect a single message by sequence number") .argument("", "Queue name or topic/subscription") @@ -21,27 +122,17 @@ export const inspectCommand = new Command("inspect") } ) => { const { client } = await createClients(opts.namespace); - const { queue, topic, subscription } = parseEntity(entity); - - const receiver = topic - ? client.createReceiver(topic, subscription!, { - subQueueType: opts.dlq ? "deadLetter" : undefined, - receiveMode: "peekLock", - }) - : client.createReceiver(queue!, { - subQueueType: opts.dlq ? "deadLetter" : undefined, - receiveMode: "peekLock", - }); + const receiver = createReceiver(client, entity, opts.dlq); try { const seqNum = BigInt(opts.seq); - // The SDK accepts Long.Long but BigInt works at runtime const messages = await receiver.peekMessages(1, { fromSequenceNumber: seqNum as never, }); const m = messages.find( - (msg) => msg.sequenceNumber !== undefined && + (msg) => + msg.sequenceNumber !== undefined && BigInt(msg.sequenceNumber.toString()) === seqNum ); @@ -53,86 +144,10 @@ export const inspectCommand = new Command("inspect") } if (opts.json) { - console.log( - JSON.stringify( - { - sequenceNumber: m.sequenceNumber?.toString(), - messageId: m.messageId, - correlationId: m.correlationId, - contentType: m.contentType, - subject: m.subject, - to: m.to, - replyTo: m.replyTo, - enqueuedTime: m.enqueuedTimeUtc?.toISOString(), - expiresAt: m.expiresAtUtc?.toISOString(), - timeToLive: m.timeToLive, - deliveryCount: m.deliveryCount, - deadLetterReason: m.deadLetterReason, - deadLetterDescription: m.deadLetterErrorDescription, - deadLetterSource: m.deadLetterSource, - sessionId: m.sessionId, - partitionKey: m.partitionKey, - applicationProperties: m.applicationProperties, - body: m.body, - }, - null, - 2 - ) - ); - return; - } - - console.log(chalk.bold(`Sequence Number: ${m.sequenceNumber}`)); - console.log(); - - // System properties - const props: Array<[string, unknown]> = [ - ["Message ID", m.messageId], - ["Correlation ID", m.correlationId], - ["Content Type", m.contentType], - ["Subject", m.subject], - ["To", m.to], - ["Reply To", m.replyTo], - ["Session ID", m.sessionId], - ["Partition Key", m.partitionKey], - ["Enqueued", m.enqueuedTimeUtc?.toISOString()], - ["Expires", m.expiresAtUtc?.toISOString()], - ["TTL", m.timeToLive ? `${m.timeToLive}ms` : undefined], - ["Delivery Count", m.deliveryCount], - ]; - - if (m.deadLetterReason) { - props.push(["DLQ Reason", chalk.red(m.deadLetterReason)]); - props.push(["DLQ Description", m.deadLetterErrorDescription]); - props.push(["DLQ Source", m.deadLetterSource]); + console.log(JSON.stringify(buildJsonOutput(m), null, 2)); + } else { + renderMessage(m); } - - for (const [label, value] of props) { - if (value !== undefined && value !== null && value !== "") { - console.log(` ${chalk.cyan(label + ":")} ${value}`); - } - } - - // Application properties - if ( - m.applicationProperties && - Object.keys(m.applicationProperties).length > 0 - ) { - console.log(); - console.log(chalk.bold("Application Properties:")); - for (const [key, val] of Object.entries(m.applicationProperties)) { - console.log(` ${chalk.cyan(key + ":")} ${val}`); - } - } - - // Body - console.log(); - console.log(chalk.bold("Body:")); - const body = - typeof m.body === "string" - ? m.body - : JSON.stringify(m.body, null, 2); - console.log(body); } finally { await receiver.close(); await client.close(); diff --git a/src/commands/login.ts b/src/commands/login.ts index 3811449..0c2e798 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -7,71 +7,68 @@ export const loginCommand = new Command("login") .description("Login to Azure via interactive browser flow") .option("--tenant ", "Azure AD tenant ID") .option("--profile ", "Save as named profile", "default") - .action( - async (opts: { tenant?: string; profile: string }) => { - console.log(chalk.dim("Opening browser for Azure login...")); + .action(async (opts: { tenant?: string; profile: string }) => { + console.log(chalk.dim("Opening browser for Azure login...")); - const credential = new InteractiveBrowserCredential({ - tenantId: opts.tenant, - }); + const credential = new InteractiveBrowserCredential({ + tenantId: opts.tenant, + }); - try { - // Force an interactive login by requesting a Service Bus token - const token = await credential.getToken( - "https://servicebus.azure.net/.default" - ); + try { + // Force an interactive login by requesting a Service Bus token + const token = await credential.getToken( + "https://servicebus.azure.net/.default" + ); - if (!token) { - console.error(chalk.red("Login failed — no token received")); - process.exit(1); - } + if (!token) { + console.error(chalk.red("Login failed — no token received")); + process.exit(1); + } - console.log(chalk.green("Login successful")); + console.log(chalk.green("Login successful")); - // If a tenant was specified, save it as a profile using Entra ID auth - if (opts.tenant) { - const config = await loadConfig(); - const existing = config.profiles.findIndex( - (p) => p.name === opts.profile - ); + // If a tenant was specified, save it as a profile using Entra ID auth + if (opts.tenant) { + const config = await loadConfig(); + const existing = config.profiles.findIndex( + (p) => p.name === opts.profile + ); - const profile = { - name: opts.profile, - namespace: undefined as string | undefined, - connectionString: undefined as string | undefined, - }; + const profile = { + name: opts.profile, + namespace: undefined as string | undefined, + connectionString: undefined as string | undefined, + }; - if (existing >= 0) { - // Keep existing namespace/connectionString, just confirm login works - console.log( - chalk.dim( - `Profile "${opts.profile}" already exists — login verified` - ) - ); - } else { - config.profiles.push(profile); - if (config.profiles.length === 1) { - config.activeProfile = opts.profile; - } - await saveConfig(config); - console.log( - chalk.dim( - `Profile "${opts.profile}" created. Run: crucible config add ${opts.profile} --namespace ` - ) - ); + if (existing >= 0) { + // Keep existing namespace/connectionString, just confirm login works + console.log( + chalk.dim( + `Profile "${opts.profile}" already exists — login verified` + ) + ); + } else { + config.profiles.push(profile); + if (config.profiles.length === 1) { + config.activeProfile = opts.profile; } + await saveConfig(config); + console.log( + chalk.dim( + `Profile "${opts.profile}" created. Run: crucible config add ${opts.profile} --namespace ` + ) + ); } - - console.log( - chalk.dim( - "DefaultAzureCredential will now pick up your cached login for future commands." - ) - ); - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Unknown error"; - console.error(chalk.red(`Login failed: ${message}`)); - process.exit(1); } + + console.log( + chalk.dim( + "DefaultAzureCredential will now pick up your cached login for future commands." + ) + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + console.error(chalk.red(`Login failed: ${message}`)); + process.exit(1); } - ); + }); diff --git a/src/commands/monitor.tsx b/src/commands/monitor.tsx index 8be97e4..d3e5062 100644 --- a/src/commands/monitor.tsx +++ b/src/commands/monitor.tsx @@ -1,9 +1,7 @@ import React, { useState, useEffect } from "react"; import { render, Text, Box, useApp, useInput } from "ink"; import { Command } from "commander"; -import { - ServiceBusAdministrationClient, -} from "@azure/service-bus"; +import { ServiceBusAdministrationClient } from "@azure/service-bus"; import { createClients } from "../lib/client.js"; interface EntityRow { @@ -151,14 +149,22 @@ function Dashboard({ admin, intervalMs, entityFilter }: DashboardProps) { {/* Rows */} {entities.map((e) => { - const dlqColor = - e.dlq > 10 ? "red" : e.dlq > 0 ? "yellow" : "green"; - const growing = - e.prevDlq !== undefined && e.dlq > e.prevDlq; - const shrinking = - e.prevDlq !== undefined && e.dlq < e.prevDlq; - const trend = growing ? "^ UP" : shrinking ? "v DN" : ""; - const trendColor = growing ? "red" : shrinking ? "green" : undefined; + let dlqColor: string = "green"; + if (e.dlq > 10) dlqColor = "red"; + else if (e.dlq > 0) dlqColor = "yellow"; + + const growing = e.prevDlq !== undefined && e.dlq > e.prevDlq; + const shrinking = e.prevDlq !== undefined && e.dlq < e.prevDlq; + + let trend = ""; + let trendColor: string | undefined; + if (growing) { + trend = "^ UP"; + trendColor = "red"; + } else if (shrinking) { + trend = "v DN"; + trendColor = "green"; + } return ( @@ -188,9 +194,7 @@ function Dashboard({ admin, intervalMs, entityFilter }: DashboardProps) { ); })} - {entities.length === 0 && !error && ( - Loading... - )} + {entities.length === 0 && !error && Loading...} ); } @@ -201,11 +205,7 @@ export const monitorCommand = new Command("monitor") .option("--interval ", "Poll interval in seconds", "5") .option("--namespace ", "Override namespace") .action( - async (opts: { - entity?: string; - interval: string; - namespace?: string; - }) => { + async (opts: { entity?: string; interval: string; namespace?: string }) => { const { admin } = await createClients(opts.namespace); const intervalMs = Number.parseInt(opts.interval, 10) * 1000; diff --git a/src/commands/peek.ts b/src/commands/peek.ts index 3bf3d9d..e7fe25d 100644 --- a/src/commands/peek.ts +++ b/src/commands/peek.ts @@ -34,7 +34,9 @@ export const peekCommand = new Command("peek") }); try { - const messages = await receiver.peekMessages(Number.parseInt(opts.count, 10)); + const messages = await receiver.peekMessages( + Number.parseInt(opts.count, 10) + ); if (messages.length === 0) { console.log(chalk.dim("No messages found")); @@ -59,13 +61,21 @@ export const peekCommand = new Command("peek") for (const m of messages) { console.log(chalk.bold(`--- Seq: ${m.sequenceNumber} ---`)); if (m.enqueuedTimeUtc) { - console.log(chalk.dim(`Enqueued: ${m.enqueuedTimeUtc.toISOString()}`)); + console.log( + chalk.dim(`Enqueued: ${m.enqueuedTimeUtc.toISOString()}`) + ); } if (m.deadLetterReason) { console.log(chalk.red(`DLQ Reason: ${m.deadLetterReason}`)); } - if (m.applicationProperties && Object.keys(m.applicationProperties).length > 0) { - console.log(chalk.cyan("Properties:"), JSON.stringify(m.applicationProperties)); + if ( + m.applicationProperties && + Object.keys(m.applicationProperties).length > 0 + ) { + console.log( + chalk.cyan("Properties:"), + JSON.stringify(m.applicationProperties) + ); } const body = typeof m.body === "string" diff --git a/src/commands/purge.ts b/src/commands/purge.ts index ed042cd..8832ce8 100644 --- a/src/commands/purge.ts +++ b/src/commands/purge.ts @@ -67,7 +67,9 @@ export const purgeCommand = new Command("purge") // Confirmation prompt if (!opts.yes) { const ok = await confirm( - chalk.red(`Permanently delete all ${label} messages from ${entity}?`) + chalk.red( + `Permanently delete all ${label} messages from ${entity}?` + ) ); if (!ok) { console.log(chalk.dim("Aborted")); @@ -97,7 +99,9 @@ export const purgeCommand = new Command("purge") "utf-8" ); console.log( - chalk.green(`Backed up ${backupData.length} messages to ${opts.backup}`) + chalk.green( + `Backed up ${backupData.length} messages to ${opts.backup}` + ) ); } diff --git a/src/commands/replay.ts b/src/commands/replay.ts index ae639fa..9335daa 100644 --- a/src/commands/replay.ts +++ b/src/commands/replay.ts @@ -8,7 +8,10 @@ export const replayCommand = new Command("replay") .description("Replay dead-letter messages back to the source queue") .argument("", "Queue name or topic/subscription") .option("--count ", "Number of messages to replay") - .option("--filter ", 'Filter by DLQ reason (e.g., "reason=MaxDeliveryCountExceeded")') + .option( + "--filter ", + 'Filter by DLQ reason (e.g., "reason=MaxDeliveryCountExceeded")' + ) .option("--dry-run", "Show what would be replayed without doing it") .option("--to ", "Replay to a different destination") .option("--backup ", "Save messages to JSON file before replaying") @@ -43,7 +46,9 @@ export const replayCommand = new Command("replay") const sender = client.createSender(dest.queue ?? dest.topic!); try { - const maxCount = opts.count ? Number.parseInt(opts.count, 10) : undefined; + const maxCount = opts.count + ? Number.parseInt(opts.count, 10) + : undefined; const messages = await receiver.receiveMessages(maxCount ?? 100, { maxWaitTimeInMs: 5000, }); @@ -74,7 +79,9 @@ export const replayCommand = new Command("replay") "utf-8" ); console.log( - chalk.green(`Backed up ${backupData.length} messages to ${opts.backup}`) + chalk.green( + `Backed up ${backupData.length} messages to ${opts.backup}` + ) ); } @@ -92,7 +99,9 @@ export const replayCommand = new Command("replay") if (opts.dryRun) { console.log( - chalk.dim(`[dry-run] Would replay Seq: ${m.sequenceNumber} — ${m.deadLetterReason}`) + chalk.dim( + `[dry-run] Would replay Seq: ${m.sequenceNumber} — ${m.deadLetterReason}` + ) ); await receiver.abandonMessage(m); replayed++; @@ -112,9 +121,15 @@ export const replayCommand = new Command("replay") const target = opts.to ?? entity; if (opts.dryRun) { - console.log(chalk.yellow(`\nDry run: ${replayed} messages would be replayed to ${target}`)); + console.log( + chalk.yellow( + `\nDry run: ${replayed} messages would be replayed to ${target}` + ) + ); } else { - console.log(chalk.green(`Replayed ${replayed} messages to ${target}`)); + console.log( + chalk.green(`Replayed ${replayed} messages to ${target}`) + ); } if (skipped > 0) { console.log(chalk.dim(`Skipped ${skipped} (filtered out)`)); diff --git a/src/commands/search.ts b/src/commands/search.ts index 9871f45..df7ee73 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,3 +1,4 @@ +import type { ServiceBusReceivedMessage } from "@azure/service-bus"; import { Command } from "commander"; import chalk from "chalk"; import { createClients } from "../lib/client.js"; @@ -7,10 +8,7 @@ export const searchCommand = new Command("search") .description("Search messages by body content or application properties") .argument("", "Queue name or topic/subscription") .option("--body ", "Search for text in message body") - .option( - "--property ", - "Match application property (key=value)" - ) + .option("--property ", "Match application property (key=value)") .option("--dlq", "Search dead-letter queue") .option("--count ", "Max messages to scan", "100") .option("--format ", "Output format: json, table", "table") @@ -51,9 +49,7 @@ export const searchCommand = new Command("search") if (opts.property) { const eqIdx = opts.property.indexOf("="); if (eqIdx < 0) { - console.error( - chalk.red("--property must be key=value format") - ); + console.error(chalk.red("--property must be key=value format")); process.exit(1); } propKey = opts.property.slice(0, eqIdx); @@ -62,47 +58,67 @@ export const searchCommand = new Command("search") try { const scanCount = Number.parseInt(opts.count, 10); - const messages = await receiver.peekMessages(scanCount); + const batchSize = 100; + const matches: ServiceBusReceivedMessage[] = []; + let scanned = 0; + let peekOptions: { fromSequenceNumber?: never } = {}; - const matches = messages.filter((m) => { - // Body filter (case-insensitive substring match) - if (opts.body) { - const bodyStr = - typeof m.body === "string" - ? m.body - : JSON.stringify(m.body); - if ( - !bodyStr - .toLowerCase() - .includes(opts.body.toLowerCase()) - ) { - return false; + // Peek in batches to reliably scan beyond single-call limits + while (scanned < scanCount) { + const remaining = scanCount - scanned; + const batch = await receiver.peekMessages( + Math.min(batchSize, remaining), + peekOptions + ); + + if (batch.length === 0) break; + + for (const m of batch) { + let isMatch = true; + + // Body filter (case-insensitive substring match) + if (opts.body) { + const bodyStr = + typeof m.body === "string" ? m.body : JSON.stringify(m.body); + if (!bodyStr.toLowerCase().includes(opts.body.toLowerCase())) { + isMatch = false; + } + } + + // Property filter + if (isMatch && propKey && propValue) { + const props = m.applicationProperties; + if (props) { + const val = String(props[propKey] ?? ""); + if (val !== propValue) isMatch = false; + } else { + isMatch = false; + } } - } - // Property filter - if (propKey && propValue) { - const props = m.applicationProperties; - if (!props) return false; - const val = String(props[propKey] ?? ""); - if (val !== propValue) return false; + if (isMatch) matches.push(m); } - return true; - }); + scanned += batch.length; + // Next batch starts after the last message's sequence number + const lastSeq = batch[batch.length - 1].sequenceNumber; + if (lastSeq !== undefined) { + peekOptions = { + fromSequenceNumber: (BigInt(lastSeq.toString()) + 1n) as never, + }; + } + } if (matches.length === 0) { console.log( - chalk.dim( - `No matches found (scanned ${messages.length} messages)` - ) + chalk.dim(`No matches found (scanned ${scanned} messages)`) ); return; } console.log( chalk.dim( - `${matches.length} match(es) found (scanned ${messages.length} messages)\n` + `${matches.length} match(es) found (scanned ${scanned} messages)\n` ) ); diff --git a/src/commands/send.ts b/src/commands/send.ts index 054c7bb..d08fb73 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -90,7 +90,9 @@ export const sendCommand = new Command("send") const target = queue ?? topic!; if (opts.schedule) { console.log( - chalk.green(`Scheduled ${count} message(s) to ${target} for ${opts.schedule}`) + chalk.green( + `Scheduled ${count} message(s) to ${target} for ${opts.schedule}` + ) ); } else { console.log(chalk.green(`Sent ${count} message(s) to ${target}`)); diff --git a/src/commands/status.ts b/src/commands/status.ts index 2da2d50..454f3ae 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -58,7 +58,12 @@ function globToRegex(pattern: string): RegExp { function filterEntities( entities: EntityStatus[], - opts: { filter?: string; dlq?: boolean; dlqSubs?: boolean; dlqTopics?: boolean } + opts: { + filter?: string; + dlq?: boolean; + dlqSubs?: boolean; + dlqTopics?: boolean; + } ): EntityStatus[] { // Name filter (glob wildcards) — applied first if (opts.filter) { @@ -160,7 +165,10 @@ export const statusCommand = new Command("status") .description("Show queue/topic health overview") .option("--json", "Output as JSON") .option("--sort ", "Sort by field: name, active, dlq, scheduled") - .option("--filter ", "Filter entities by name (glob wildcards, e.g. \"*dev*\")") + .option( + "--filter ", + 'Filter entities by name (glob wildcards, e.g. "*dev*")' + ) .option("--dlq", "Show only entities with dead-letter messages") .option( "--dlq-subs", @@ -202,9 +210,7 @@ export const statusCommand = new Command("status") if (opts.watch !== undefined && opts.watch !== false) { const interval = - typeof opts.watch === "string" - ? Number.parseInt(opts.watch, 10) - : 5; + typeof opts.watch === "string" ? Number.parseInt(opts.watch, 10) : 5; const intervalMs = interval * 1000; while (true) { diff --git a/src/commands/topology.ts b/src/commands/topology.ts index 61499ad..72d7d10 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -2,9 +2,14 @@ import { Command } from "commander"; import chalk from "chalk"; import { createClients } from "../lib/client.js"; +interface RuleInfo { + name: string; + filter?: string; +} + interface SubInfo { name: string; - rules: string[]; + rules: RuleInfo[]; } interface TopicInfo { @@ -21,7 +26,76 @@ interface Topology { topics: TopicInfo[]; } -async function fetchTopology(namespace?: string): Promise { +interface SqlRuleFilter { + sqlExpression: string; +} + +interface CorrelationRuleFilter { + correlationId?: string; + messageId?: string; + to?: string; + replyTo?: string; + label?: string; + sessionId?: string; + contentType?: string; + properties?: Record; + applicationProperties?: Record; +} + +function isSqlFilter(f: unknown): f is SqlRuleFilter { + return typeof f === "object" && f !== null && "sqlExpression" in f; +} + +function isCorrelationFilter(f: unknown): f is CorrelationRuleFilter { + return ( + typeof f === "object" && + f !== null && + ("correlationId" in f || + "label" in f || + "contentType" in f || + "properties" in f) + ); +} + +const CORRELATION_FIELDS: (keyof CorrelationRuleFilter)[] = [ + "correlationId", + "messageId", + "to", + "replyTo", + "label", + "sessionId", + "contentType", +]; + +function formatSqlFilter(f: SqlRuleFilter): string { + return f.sqlExpression === "1=1" ? "TrueFilter" : f.sqlExpression; +} + +function formatCorrelationFilter(f: CorrelationRuleFilter): string { + const parts: string[] = CORRELATION_FIELDS.filter((k) => f[k]).map( + (k) => `${k}=${String(f[k])}` + ); + + const props = f.properties || f.applicationProperties || {}; + for (const [k, v] of Object.entries(props)) { + parts.push(`${k}=${v}`); + } + + return parts.length > 0 ? parts.join(", ") : "CorrelationFilter"; +} + +function formatFilter(rule: { filter?: unknown }): string { + const f = rule.filter; + if (!f) return ""; + if (isSqlFilter(f)) return formatSqlFilter(f); + if (isCorrelationFilter(f)) return formatCorrelationFilter(f); + return ""; +} + +async function fetchTopology( + namespace?: string, + includeRules?: boolean +): Promise { const { admin } = await createClients(namespace); const queues: QueueInfo[] = []; const topics: TopicInfo[] = []; @@ -34,9 +108,13 @@ async function fetchTopology(namespace?: string): Promise { const subs: SubInfo[] = []; for await (const s of admin.listSubscriptions(t.name)) { - const rules: string[] = []; + const rules: RuleInfo[] = []; for await (const r of admin.listRules(t.name, s.subscriptionName)) { - rules.push(r.name); + if (includeRules) { + rules.push({ name: r.name, filter: formatFilter(r) }); + } else { + rules.push({ name: r.name }); + } } subs.push({ name: s.subscriptionName, rules }); } @@ -47,43 +125,62 @@ async function fetchTopology(namespace?: string): Promise { return { queues, topics }; } -function renderTree(topo: Topology): void { - // Queues +/** Return the tree branch/continuation prefixes for an item in a list. */ +function treePrefixes( + parent: string, + index: number, + total: number +): { prefix: string; cont: string } { + const isLast = index === total - 1; + return { + prefix: isLast ? `${parent}└─` : `${parent}├─`, + cont: isLast ? `${parent} ` : `${parent}│ `, + }; +} + +function renderRulesTree( + rules: RuleInfo[], + cont: string, + showFilter: boolean +): void { + for (let ri = 0; ri < rules.length; ri++) { + const rule = rules[ri]; + const r = treePrefixes(cont, ri, rules.length); + console.log(`${r.prefix} ${chalk.dim(rule.name)}`); + if (showFilter && rule.filter) { + console.log(`${r.cont}${chalk.gray("→")} ${chalk.blue(rule.filter)}`); + } + } +} + +function renderSubscriptionsTree( + subs: SubInfo[], + cont: string, + showRules: boolean +): void { + for (let si = 0; si < subs.length; si++) { + const s = treePrefixes(cont, si, subs.length); + console.log(`${s.prefix} ${chalk.yellow(subs[si].name)}`); + renderRulesTree(subs[si].rules, s.cont, showRules); + } +} + +function renderTree(topo: Topology, showRules: boolean): void { if (topo.queues.length > 0) { console.log(chalk.bold("Queues")); for (let i = 0; i < topo.queues.length; i++) { - const isLast = i === topo.queues.length - 1; - const prefix = isLast ? " └─" : " ├─"; - console.log(`${prefix} ${chalk.cyan(topo.queues[i].name)}`); + const q = treePrefixes(" ", i, topo.queues.length); + console.log(`${q.prefix} ${chalk.cyan(topo.queues[i].name)}`); } console.log(); } - // Topics if (topo.topics.length > 0) { console.log(chalk.bold("Topics")); for (let ti = 0; ti < topo.topics.length; ti++) { - const t = topo.topics[ti]; - const tLast = ti === topo.topics.length - 1; - const tPrefix = tLast ? " └─" : " ├─"; - const tCont = tLast ? " " : " │ "; - - console.log(`${tPrefix} ${chalk.magenta(t.name)}`); - - for (let si = 0; si < t.subscriptions.length; si++) { - const s = t.subscriptions[si]; - const sLast = si === t.subscriptions.length - 1; - const sPrefix = sLast ? `${tCont}└─` : `${tCont}├─`; - const sCont = sLast ? `${tCont} ` : `${tCont}│ `; - - console.log(`${sPrefix} ${chalk.yellow(s.name)}`); - - for (let ri = 0; ri < s.rules.length; ri++) { - const rLast = ri === s.rules.length - 1; - const rPrefix = rLast ? `${sCont}└─` : `${sCont}├─`; - console.log(`${rPrefix} ${chalk.dim(s.rules[ri])}`); - } - } + const t = treePrefixes(" ", ti, topo.topics.length); + console.log(`${t.prefix} ${chalk.magenta(topo.topics[ti].name)}`); + renderSubscriptionsTree(topo.topics[ti].subscriptions, t.cont, showRules); } } @@ -92,16 +189,26 @@ function renderTree(topo: Topology): void { } } -function renderMermaid(topo: Topology): void { +function getRuleLabel(rule: RuleInfo, showFilter: boolean): string | null { + if (showFilter) { + if ( + rule.name === "$Default" && + (!rule.filter || rule.filter === "TrueFilter") + ) + return null; + return rule.filter || rule.name; + } + if (rule.name === "$Default") return null; + return rule.name; +} + +function renderMermaid(topo: Topology, showRules: boolean): void { const lines: string[] = ["graph LR"]; - // Queues for (const q of topo.queues) { - const id = sanitize(q.name); - lines.push(` ${id}[["${q.name} (queue)"]]`); + lines.push(` ${sanitize(q.name)}[["${q.name} (queue)"]]`); } - // Topics → Subscriptions → Rules for (const t of topo.topics) { const tid = sanitize(t.name); lines.push(` ${tid}(("${t.name}"))`); @@ -111,9 +218,10 @@ function renderMermaid(topo: Topology): void { lines.push(` ${tid} --> ${sid}["${s.name}"]`); for (const r of s.rules) { - if (r === "$Default") continue; - const rid = sanitize(`${t.name}_${s.name}_${r}`); - lines.push(` ${sid} -. "${r}" .-> ${rid}((filter))`); + const label = getRuleLabel(r, showRules); + if (!label) continue; + const rid = sanitize(`${t.name}_${s.name}_${r.name}`); + lines.push(` ${sid} -. "${label}" .-> ${rid}((filter))`); } } } @@ -126,24 +234,23 @@ function sanitize(name: string): string { } export const topologyCommand = new Command("topology") - .description( - "Show namespace topology (queues, topics, subscriptions, rules)" - ) + .description("Show namespace topology (queues, topics, subscriptions, rules)") .option("--format ", "Output format: tree, json, mermaid", "tree") + .option("--rules", "Show filter expressions for each subscription rule") .option("--namespace ", "Override namespace") .action( - async (opts: { format: string; namespace?: string }) => { - const topo = await fetchTopology(opts.namespace); + async (opts: { format: string; rules?: boolean; namespace?: string }) => { + const topo = await fetchTopology(opts.namespace, opts.rules); switch (opts.format) { case "json": console.log(JSON.stringify(topo, null, 2)); break; case "mermaid": - renderMermaid(topo); + renderMermaid(topo, !!opts.rules); break; default: - renderTree(topo); + renderTree(topo, !!opts.rules); break; } } diff --git a/src/commands/watch.ts b/src/commands/watch.ts index f7f6c8d..2745a80 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -3,15 +3,82 @@ import { execFile } from "node:child_process"; import chalk from "chalk"; import { createClients } from "../lib/client.js"; import { parseEntity } from "../lib/entity.js"; +import type { ServiceBusAdministrationClient } from "@azure/service-bus"; + +async function getDlqCount( + admin: ServiceBusAdministrationClient, + parsed: { queue?: string; topic?: string; subscription?: string } +): Promise { + if (parsed.queue) { + const rt = await admin.getQueueRuntimeProperties(parsed.queue); + return rt.deadLetterMessageCount; + } + const rt = await admin.getSubscriptionRuntimeProperties( + parsed.topic!, + parsed.subscription! + ); + return rt.deadLetterMessageCount; +} + +function runExecCommand( + command: string, + entity: string, + dlqCount: number, + threshold: number +): void { + execFile( + "/bin/sh", + ["-c", command], + { + env: { + ...process.env, + CRUCIBLE_ENTITY: entity, + CRUCIBLE_DLQ: String(dlqCount), + CRUCIBLE_THRESHOLD: String(threshold), + }, + }, + (err, stdout, stderr) => { + if (err) console.error(chalk.red(`exec failed: ${err.message}`)); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } + ); +} + +async function sendNotification( + entity: string, + dlqCount: number, + threshold: number +): Promise { + try { + const notifier = await import("node-notifier"); + notifier.default.notify({ + title: "Crucible DLQ Alert", + message: `${entity} has ${dlqCount} dead-letter messages (threshold: ${threshold})`, + sound: true, + }); + } catch { + console.warn( + chalk.yellow( + "Desktop notification failed — node-notifier may not be supported on this platform" + ) + ); + } +} export const watchCommand = new Command("watch") - .description("Watch entity DLQ count and trigger alerts when threshold is exceeded") + .description( + "Watch entity DLQ count and trigger alerts when threshold is exceeded" + ) .argument("", "Queue name or topic/subscription") .requiredOption( "--dlq-threshold ", "DLQ count threshold to trigger alert" ) - .option("--exec ", "Shell command to execute when threshold is crossed (use $CRUCIBLE_ENTITY, $CRUCIBLE_DLQ, $CRUCIBLE_THRESHOLD)") + .option( + "--exec ", + "Shell command to execute when threshold is crossed (use $CRUCIBLE_ENTITY, $CRUCIBLE_DLQ, $CRUCIBLE_THRESHOLD)" + ) .option("--notify", "Send desktop notification when threshold is crossed") .option("--interval ", "Poll interval in seconds", "30") .option("--namespace ", "Override namespace") @@ -27,16 +94,14 @@ export const watchCommand = new Command("watch") } ) => { if (!opts.exec && !opts.notify) { - console.error( - chalk.red("Provide --exec or --notify (or both)") - ); + console.error(chalk.red("Provide --exec or --notify (or both)")); process.exit(1); } const threshold = Number.parseInt(opts.dlqThreshold, 10); const intervalMs = Number.parseInt(opts.interval, 10) * 1000; const { admin } = await createClients(opts.namespace); - const { queue, topic, subscription } = parseEntity(entity); + const parsed = parseEntity(entity); let inAlert = false; @@ -48,19 +113,7 @@ export const watchCommand = new Command("watch") const poll = async () => { try { - let dlqCount: number; - - if (queue) { - const rt = await admin.getQueueRuntimeProperties(queue); - dlqCount = rt.deadLetterMessageCount; - } else { - const rt = await admin.getSubscriptionRuntimeProperties( - topic!, - subscription! - ); - dlqCount = rt.deadLetterMessageCount; - } - + const dlqCount = await getDlqCount(admin, parsed); const now = new Date().toLocaleTimeString(); if (dlqCount >= threshold && !inAlert) { @@ -70,44 +123,10 @@ export const watchCommand = new Command("watch") `[${now}] ALERT: ${entity} DLQ count ${dlqCount} >= threshold ${threshold}` ) ); - - // Execute command — values passed via env vars to prevent shell injection - if (opts.exec) { - execFile("/bin/sh", ["-c", opts.exec], { - env: { - ...process.env, - CRUCIBLE_ENTITY: entity, - CRUCIBLE_DLQ: String(dlqCount), - CRUCIBLE_THRESHOLD: String(threshold), - }, - }, (err, stdout, stderr) => { - if (err) { - console.error( - chalk.red(`exec failed: ${err.message}`) - ); - } - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); - }); - } - - // Desktop notification - if (opts.notify) { - try { - const notifier = await import("node-notifier"); - notifier.default.notify({ - title: "Crucible DLQ Alert", - message: `${entity} has ${dlqCount} dead-letter messages (threshold: ${threshold})`, - sound: true, - }); - } catch { - console.warn( - chalk.yellow( - "Desktop notification failed — node-notifier may not be supported on this platform" - ) - ); - } - } + if (opts.exec) + runExecCommand(opts.exec, entity, dlqCount, threshold); + if (opts.notify) + await sendNotification(entity, dlqCount, threshold); } else if (dlqCount < threshold && inAlert) { inAlert = false; console.log( @@ -128,7 +147,6 @@ export const watchCommand = new Command("watch") } }; - // Run immediately, then on interval await poll(); setInterval(poll, intervalMs); }