From 6e41d92010c62a933d3ee7df24cd74dd49c165ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:20:09 +0100 Subject: [PATCH 01/16] feat: add --rules flag to topology command to show filter expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --rules flag that displays the actual SQL or correlation filter expression beneath each subscription rule name in the topology output. Supports all three output formats: - tree: shows expression on indented sub-line with → prefix - json: includes filter property on rule objects - mermaid: uses filter expression as edge label instead of rule name $Default TrueFilter rules (1=1) are hidden in mermaid output. --- src/commands/topology.ts | 98 ++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/src/commands/topology.ts b/src/commands/topology.ts index 61499ad..f69e257 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,37 @@ interface Topology { topics: TopicInfo[]; } -async function fetchTopology(namespace?: string): Promise { +function formatFilter(rule: { filter?: unknown }): string { + const f = rule.filter as Record | undefined; + if (!f) return ""; + + // SqlFilter + if (f.sqlExpression) { + const expr = String(f.sqlExpression); + return expr === "1=1" ? "TrueFilter" : expr; + } + + // CorrelationFilter + if (f.correlationId || f.label || f.contentType || f.properties) { + const parts: string[] = []; + if (f.correlationId) parts.push(`correlationId=${f.correlationId}`); + if (f.messageId) parts.push(`messageId=${f.messageId}`); + if (f.to) parts.push(`to=${f.to}`); + if (f.replyTo) parts.push(`replyTo=${f.replyTo}`); + if (f.label) parts.push(`label=${f.label}`); + if (f.sessionId) parts.push(`sessionId=${f.sessionId}`); + if (f.contentType) parts.push(`contentType=${f.contentType}`); + const props = (f.properties || f.applicationProperties || {}) as Record; + for (const [k, v] of Object.entries(props)) { + parts.push(`${k}=${v}`); + } + return parts.length > 0 ? parts.join(", ") : "CorrelationFilter"; + } + + return ""; +} + +async function fetchTopology(namespace?: string, includeRules?: boolean): Promise { const { admin } = await createClients(namespace); const queues: QueueInfo[] = []; const topics: TopicInfo[] = []; @@ -34,9 +69,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,7 +86,7 @@ async function fetchTopology(namespace?: string): Promise { return { queues, topics }; } -function renderTree(topo: Topology): void { +function renderTree(topo: Topology, showRules: boolean): void { // Queues if (topo.queues.length > 0) { console.log(chalk.bold("Queues")); @@ -78,10 +117,23 @@ function renderTree(topo: Topology): void { 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])}`); + if (showRules) { + for (let ri = 0; ri < s.rules.length; ri++) { + const rule = s.rules[ri]; + const rLast = ri === s.rules.length - 1; + const rPrefix = rLast ? `${sCont}└─` : `${sCont}├─`; + const rCont = rLast ? `${sCont} ` : `${sCont}│ `; + console.log(`${rPrefix} ${chalk.dim(rule.name)}`); + if (rule.filter) { + console.log(`${rCont}${chalk.gray("→")} ${chalk.blue(rule.filter)}`); + } + } + } else { + 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].name)}`); + } } } } @@ -92,7 +144,7 @@ function renderTree(topo: Topology): void { } } -function renderMermaid(topo: Topology): void { +function renderMermaid(topo: Topology, showRules: boolean): void { const lines: string[] = ["graph LR"]; // Queues @@ -110,10 +162,19 @@ function renderMermaid(topo: Topology): void { const sid = sanitize(`${t.name}_${s.name}`); 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))`); + if (showRules) { + for (const r of s.rules) { + if (r.name === "$Default" && (!r.filter || r.filter === "TrueFilter")) continue; + const rid = sanitize(`${t.name}_${s.name}_${r.name}`); + const label = r.filter || r.name; + lines.push(` ${sid} -. "${label}" .-> ${rid}((filter))`); + } + } else { + for (const r of s.rules) { + if (r.name === "$Default") continue; + const rid = sanitize(`${t.name}_${s.name}_${r.name}`); + lines.push(` ${sid} -. "${r.name}" .-> ${rid}((filter))`); + } } } } @@ -130,20 +191,21 @@ export const topologyCommand = new Command("topology") "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; } } From b798668db8d098f859e0cab00a94804f46584e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:21:53 +0100 Subject: [PATCH 02/16] docs: update README with --rules flag for topology command --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 | From a3c3af0eb0920135e2ab27e0de0231edb5a663fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:27:09 +0100 Subject: [PATCH 03/16] fix: use type guards for filter objects to avoid [object Object] stringification --- src/commands/topology.ts | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/commands/topology.ts b/src/commands/topology.ts index f69e257..de89e02 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -26,18 +26,42 @@ interface Topology { topics: TopicInfo[]; } +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); +} + function formatFilter(rule: { filter?: unknown }): string { - const f = rule.filter as Record | undefined; + const f = rule.filter; if (!f) return ""; // SqlFilter - if (f.sqlExpression) { - const expr = String(f.sqlExpression); - return expr === "1=1" ? "TrueFilter" : expr; + if (isSqlFilter(f)) { + return f.sqlExpression === "1=1" ? "TrueFilter" : f.sqlExpression; } // CorrelationFilter - if (f.correlationId || f.label || f.contentType || f.properties) { + if (isCorrelationFilter(f)) { const parts: string[] = []; if (f.correlationId) parts.push(`correlationId=${f.correlationId}`); if (f.messageId) parts.push(`messageId=${f.messageId}`); @@ -46,7 +70,7 @@ function formatFilter(rule: { filter?: unknown }): string { if (f.label) parts.push(`label=${f.label}`); if (f.sessionId) parts.push(`sessionId=${f.sessionId}`); if (f.contentType) parts.push(`contentType=${f.contentType}`); - const props = (f.properties || f.applicationProperties || {}) as Record; + const props = f.properties || f.applicationProperties || {}; for (const [k, v] of Object.entries(props)) { parts.push(`${k}=${v}`); } From bc1739ecdd9d4c0370be2b7779d647c1073456c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:31:26 +0100 Subject: [PATCH 04/16] refactor: reduce cognitive complexity of formatFilter function Split into formatSqlFilter and formatCorrelationFilter helpers. Replace repeated if-checks with data-driven CORRELATION_FIELDS array. --- src/commands/topology.ts | 45 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/commands/topology.ts b/src/commands/topology.ts index de89e02..a6f573d 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -51,32 +51,33 @@ function isCorrelationFilter(f: unknown): f is CorrelationRuleFilter { ("correlationId" in f || "label" in f || "contentType" in f || "properties" in f); } -function formatFilter(rule: { filter?: unknown }): string { - const f = rule.filter; - if (!f) return ""; +const CORRELATION_FIELDS: (keyof CorrelationRuleFilter)[] = [ + "correlationId", "messageId", "to", "replyTo", + "label", "sessionId", "contentType", +]; - // SqlFilter - if (isSqlFilter(f)) { - return f.sqlExpression === "1=1" ? "TrueFilter" : f.sqlExpression; - } +function formatSqlFilter(f: SqlRuleFilter): string { + return f.sqlExpression === "1=1" ? "TrueFilter" : f.sqlExpression; +} - // CorrelationFilter - if (isCorrelationFilter(f)) { - const parts: string[] = []; - if (f.correlationId) parts.push(`correlationId=${f.correlationId}`); - if (f.messageId) parts.push(`messageId=${f.messageId}`); - if (f.to) parts.push(`to=${f.to}`); - if (f.replyTo) parts.push(`replyTo=${f.replyTo}`); - if (f.label) parts.push(`label=${f.label}`); - if (f.sessionId) parts.push(`sessionId=${f.sessionId}`); - if (f.contentType) parts.push(`contentType=${f.contentType}`); - 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 formatCorrelationFilter(f: CorrelationRuleFilter): string { + const parts: string[] = CORRELATION_FIELDS + .filter((k) => f[k]) + .map((k) => `${k}=${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 ""; } From c564888fab392d3ae5d1f105ccf6b7bfdfb5eb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:34:47 +0100 Subject: [PATCH 05/16] refactor: reduce cognitive complexity of watch poll function Extract getDlqCount, runExecCommand, and sendNotification into standalone helpers to bring the poll function under the complexity limit. --- src/commands/watch.ts | 135 ++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 65 deletions(-) diff --git a/src/commands/watch.ts b/src/commands/watch.ts index f7f6c8d..c62b917 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -3,6 +3,68 @@ 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") @@ -27,16 +89,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,78 +108,24 @@ 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) { inAlert = true; console.log( - chalk.red( - `[${now}] ALERT: ${entity} DLQ count ${dlqCount} >= threshold ${threshold}` - ) + chalk.red(`[${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( - chalk.green( - `[${now}] RESOLVED: ${entity} DLQ count ${dlqCount} < threshold ${threshold}` - ) + chalk.green(`[${now}] RESOLVED: ${entity} DLQ count ${dlqCount} < threshold ${threshold}`) ); } else { console.log( - chalk.dim( - `[${now}] ${entity} DLQ: ${dlqCount}${inAlert ? " (in alert)" : ""}` - ) + chalk.dim(`[${now}] ${entity} DLQ: ${dlqCount}${inAlert ? " (in alert)" : ""}`) ); } } catch (err: unknown) { @@ -128,7 +134,6 @@ export const watchCommand = new Command("watch") } }; - // Run immediately, then on interval await poll(); setInterval(poll, intervalMs); } From 89cba5639b5632e7eb47a19fe2896651c58ff3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:36:10 +0100 Subject: [PATCH 06/16] refactor: reduce cognitive complexity of costs command from 35 to under 15 Extract data gathering (gatherQueueCosts, gatherTopicCosts), issue detection (detectQueueIssues, detectTopicIssues), cost estimation (estimateMonthlyCost), and rendering (renderSummary, renderTable, renderOptimizations) into standalone functions. The action handler is now a simple orchestrator. --- src/commands/costs.ts | 304 ++++++++++++++++++++++++------------------ 1 file changed, 176 insertions(+), 128 deletions(-) diff --git a/src/commands/costs.ts b/src/commands/costs.ts index 4ed3215..2ad3f45 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,185 @@ 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 +221,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); } ); From 29e422c185992dfca6379e04772284aabd3f0136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:39:40 +0100 Subject: [PATCH 07/16] refactor: reduce cognitive complexity of diff functions Extract generic diffNamedEntities and diffProperties helpers to eliminate the repeated map-build/added/removed/compare pattern across diffQueues, diffTopics, diffSubscriptions, and diffRules. --- src/commands/diff.ts | 204 ++++++++++++++++++------------------------- 1 file changed, 83 insertions(+), 121 deletions(-) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index 53f12a2..453206b 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -17,116 +17,105 @@ 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 +124,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 { From be2d07aee90b9f481af8ec31603125af444c852e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:41:11 +0100 Subject: [PATCH 08/16] refactor: reduce cognitive complexity of inspect command Extract createReceiver, buildJsonOutput, renderMessage, renderApplicationProperties, and renderBody into standalone functions. --- src/commands/inspect.ts | 192 +++++++++++++++++++++------------------- 1 file changed, 100 insertions(+), 92 deletions(-) diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index e3416ef..4100131 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,8 +1,102 @@ 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 +115,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 +137,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(); From 8bdf6be320177e1cef01d24a75fc645b58ef5329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 00:45:07 +0100 Subject: [PATCH 09/16] refactor: reduce cognitive complexity of renderTree and renderMermaid Extract treePrefixes, renderRulesTree, renderSubscriptionsTree, and getRuleLabel helpers to flatten the deeply nested loop structure and eliminate the duplicated showRules if/else branches. --- src/commands/topology.ts | 112 +++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/commands/topology.ts b/src/commands/topology.ts index a6f573d..6ff4244 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -111,56 +111,58 @@ async function fetchTopology(namespace?: string, includeRules?: boolean): Promis return { queues, topics }; } +/** 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 { - // Queues 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)}`); - - if (showRules) { - for (let ri = 0; ri < s.rules.length; ri++) { - const rule = s.rules[ri]; - const rLast = ri === s.rules.length - 1; - const rPrefix = rLast ? `${sCont}└─` : `${sCont}├─`; - const rCont = rLast ? `${sCont} ` : `${sCont}│ `; - console.log(`${rPrefix} ${chalk.dim(rule.name)}`); - if (rule.filter) { - console.log(`${rCont}${chalk.gray("→")} ${chalk.blue(rule.filter)}`); - } - } - } else { - 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].name)}`); - } - } - } + 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); } } @@ -169,16 +171,22 @@ function renderTree(topo: Topology, showRules: boolean): 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}"))`); @@ -187,19 +195,11 @@ function renderMermaid(topo: Topology, showRules: boolean): void { const sid = sanitize(`${t.name}_${s.name}`); lines.push(` ${tid} --> ${sid}["${s.name}"]`); - if (showRules) { - for (const r of s.rules) { - if (r.name === "$Default" && (!r.filter || r.filter === "TrueFilter")) continue; - const rid = sanitize(`${t.name}_${s.name}_${r.name}`); - const label = r.filter || r.name; - lines.push(` ${sid} -. "${label}" .-> ${rid}((filter))`); - } - } else { - for (const r of s.rules) { - if (r.name === "$Default") continue; - const rid = sanitize(`${t.name}_${s.name}_${r.name}`); - lines.push(` ${sid} -. "${r.name}" .-> ${rid}((filter))`); - } + for (const r of s.rules) { + const label = getRuleLabel(r, showRules); + if (!label) continue; + const rid = sanitize(`${t.name}_${s.name}_${r.name}`); + lines.push(` ${sid} -. "${label}" .-> ${rid}((filter))`); } } } From 62d3c4e38e8ce6b5b3cb80bcf045bfd27d73b91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Thu, 26 Mar 2026 06:02:00 +0100 Subject: [PATCH 10/16] chore: remove unused vi and beforeEach imports from config test --- src/__tests__/config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", () => { From 6fb491d5e6b0007fb6b1cf0238020087dd4b327f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:26:58 +0200 Subject: [PATCH 11/16] feat: paginate search command to scan beyond SDK single-call limits peekMessages has a batch ceiling, so searching with --count >100 would silently return fewer results. Now peeks in 100-message batches using fromSequenceNumber to walk through the full queue. --- src/commands/search.ts | 83 +++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/src/commands/search.ts b/src/commands/search.ts index 9871f45..e37d37d 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,66 @@ 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 fromSequenceNumber: bigint | undefined; - 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), + fromSequenceNumber !== undefined + ? { fromSequenceNumber: fromSequenceNumber as never } + : undefined + ); + + 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 (propKey && propValue) { - const props = m.applicationProperties; - if (!props) return false; - const val = String(props[propKey] ?? ""); - if (val !== propValue) return false; + // Property filter + if (isMatch && propKey && propValue) { + const props = m.applicationProperties; + if (!props) { + isMatch = false; + } else { + const val = String(props[propKey] ?? ""); + if (val !== propValue) isMatch = 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; + fromSequenceNumber = + lastSeq !== undefined ? BigInt(lastSeq.toString()) + 1n : undefined; + } 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` ) ); From 8dff9f0d3e1302b7e5b088fad0b783d80f941220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:27:14 +0200 Subject: [PATCH 12/16] chore: add ESLint + Prettier and format codebase - ESLint flat config with typescript-eslint recommended rules - Prettier with double quotes, semicolons, trailing commas, 80 char width - eslint-config-prettier to avoid rule conflicts - Added lint, lint:fix, format, and format:check scripts - Formatted all source files --- .prettierignore | 2 + .prettierrc | 7 + CLAUDE.md | 7 + eslint.config.js | 20 + package-lock.json | 1124 +++++++++++++++++++++++++++++++++++- package.json | 10 +- src/commands/config.ts | 67 ++- src/commands/costs.ts | 4 +- src/commands/deadletter.ts | 16 +- src/commands/diff.ts | 32 +- src/commands/export.ts | 8 +- src/commands/import.ts | 4 +- src/commands/inspect.ts | 11 +- src/commands/login.ts | 111 ++-- src/commands/monitor.tsx | 23 +- src/commands/peek.ts | 18 +- src/commands/purge.ts | 8 +- src/commands/replay.ts | 27 +- src/commands/send.ts | 4 +- src/commands/status.ts | 16 +- src/commands/topology.ts | 46 +- src/commands/watch.ts | 27 +- 22 files changed, 1425 insertions(+), 167 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 eslint.config.js 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/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..ee84f1e 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", @@ -1946,7 +2463,178 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "engines": { - "node": ">=8" + "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": { @@ -1959,6 +2647,16 @@ "@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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -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", @@ -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..36f5547 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,17 @@ "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" } } 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 2ad3f45..900057e 100644 --- a/src/commands/costs.ts +++ b/src/commands/costs.ts @@ -67,7 +67,9 @@ async function gatherQueueCosts( for await (const queue of admin.listQueues()) { const rt = await admin.getQueueRuntimeProperties(queue.name); const totalMessages = - rt.activeMessageCount + rt.deadLetterMessageCount + rt.scheduledMessageCount; + rt.activeMessageCount + + rt.deadLetterMessageCount + + rt.scheduledMessageCount; const estimatedOpsPerDay = Math.max(totalMessages * 10, 100); entities.push({ 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 453206b..b98677e 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -86,8 +86,12 @@ function diffQueues( before: QueueSnapshot[], after: QueueSnapshot[] ): DiffEntry[] { - return diffNamedEntities("queue", before, after, (q) => q.name, (path, b, a) => - diffProperties(path, b, a, ["name"]) + return diffNamedEntities( + "queue", + before, + after, + (q) => q.name, + (path, b, a) => diffProperties(path, b, a, ["name"]) ); } @@ -95,10 +99,16 @@ 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), - ]); + 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( @@ -167,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 4100131..eeccdf4 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; import chalk from "chalk"; -import type { ServiceBusReceivedMessage, ServiceBusClient } from "@azure/service-bus"; +import type { + ServiceBusReceivedMessage, + ServiceBusClient, +} from "@azure/service-bus"; import { createClients } from "../lib/client.js"; import { parseEntity } from "../lib/entity.js"; @@ -81,7 +84,11 @@ function renderMessage(m: ServiceBusReceivedMessage): void { } function renderApplicationProperties(m: ServiceBusReceivedMessage): void { - if (!m.applicationProperties || Object.keys(m.applicationProperties).length === 0) return; + 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)) { 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..e4ae5db 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,12 +149,9 @@ 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 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; @@ -188,9 +183,7 @@ function Dashboard({ admin, intervalMs, entityFilter }: DashboardProps) { ); })} - {entities.length === 0 && !error && ( - Loading... - )} + {entities.length === 0 && !error && Loading...} ); } @@ -201,11 +194,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/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 6ff4244..6b4fa9d 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -47,13 +47,24 @@ function isSqlFilter(f: unknown): f is SqlRuleFilter { } 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); + 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", + "correlationId", + "messageId", + "to", + "replyTo", + "label", + "sessionId", + "contentType", ]; function formatSqlFilter(f: SqlRuleFilter): string { @@ -61,9 +72,9 @@ function formatSqlFilter(f: SqlRuleFilter): string { } function formatCorrelationFilter(f: CorrelationRuleFilter): string { - const parts: string[] = CORRELATION_FIELDS - .filter((k) => f[k]) - .map((k) => `${k}=${f[k]}`); + const parts: string[] = CORRELATION_FIELDS.filter((k) => f[k]).map( + (k) => `${k}=${f[k]}` + ); const props = f.properties || f.applicationProperties || {}; for (const [k, v] of Object.entries(props)) { @@ -81,7 +92,10 @@ function formatFilter(rule: { filter?: unknown }): string { return ""; } -async function fetchTopology(namespace?: string, includeRules?: boolean): Promise { +async function fetchTopology( + namespace?: string, + includeRules?: boolean +): Promise { const { admin } = await createClients(namespace); const queues: QueueInfo[] = []; const topics: TopicInfo[] = []; @@ -124,7 +138,11 @@ function treePrefixes( }; } -function renderRulesTree(rules: RuleInfo[], cont: string, showFilter: boolean): void { +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); @@ -173,7 +191,11 @@ function renderTree(topo: Topology, showRules: boolean): void { function getRuleLabel(rule: RuleInfo, showFilter: boolean): string | null { if (showFilter) { - if (rule.name === "$Default" && (!rule.filter || rule.filter === "TrueFilter")) return null; + if ( + rule.name === "$Default" && + (!rule.filter || rule.filter === "TrueFilter") + ) + return null; return rule.filter || rule.name; } if (rule.name === "$Default") return null; @@ -212,9 +234,7 @@ 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") diff --git a/src/commands/watch.ts b/src/commands/watch.ts index c62b917..2745a80 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -67,13 +67,18 @@ async function sendNotification( } 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") @@ -114,18 +119,26 @@ export const watchCommand = new Command("watch") if (dlqCount >= threshold && !inAlert) { inAlert = true; console.log( - chalk.red(`[${now}] ALERT: ${entity} DLQ count ${dlqCount} >= threshold ${threshold}`) + chalk.red( + `[${now}] ALERT: ${entity} DLQ count ${dlqCount} >= threshold ${threshold}` + ) ); - if (opts.exec) runExecCommand(opts.exec, entity, dlqCount, threshold); - if (opts.notify) await sendNotification(entity, dlqCount, threshold); + 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( - chalk.green(`[${now}] RESOLVED: ${entity} DLQ count ${dlqCount} < threshold ${threshold}`) + chalk.green( + `[${now}] RESOLVED: ${entity} DLQ count ${dlqCount} < threshold ${threshold}` + ) ); } else { console.log( - chalk.dim(`[${now}] ${entity} DLQ: ${dlqCount}${inAlert ? " (in alert)" : ""}`) + chalk.dim( + `[${now}] ${entity} DLQ: ${dlqCount}${inAlert ? " (in alert)" : ""}` + ) ); } } catch (err: unknown) { From 089687a35f1b1ba26355e1962aa83b313a69cec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:38:44 +0200 Subject: [PATCH 13/16] fix: override picomatch to 4.0.4 to resolve moderate vulnerability --- package-lock.json | 6 +++--- package.json | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee84f1e..8acef5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3817,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": { diff --git a/package.json b/package.json index 36f5547..8b672bd 100644 --- a/package.json +++ b/package.json @@ -67,5 +67,8 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.57.2", "vitest": "^3.0.0" + }, + "overrides": { + "picomatch": "4.0.4" } } From cf3367299faf1d69b4f5b083abdb1276108f491b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:41:34 +0200 Subject: [PATCH 14/16] refactor: extract nested ternaries in monitor component --- src/commands/monitor.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commands/monitor.tsx b/src/commands/monitor.tsx index e4ae5db..d3e5062 100644 --- a/src/commands/monitor.tsx +++ b/src/commands/monitor.tsx @@ -149,11 +149,22 @@ function Dashboard({ admin, intervalMs, entityFilter }: DashboardProps) { {/* Rows */} {entities.map((e) => { - const dlqColor = e.dlq > 10 ? "red" : e.dlq > 0 ? "yellow" : "green"; + 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; - const trend = growing ? "^ UP" : shrinking ? "v DN" : ""; - const trendColor = growing ? "red" : shrinking ? "green" : undefined; + + let trend = ""; + let trendColor: string | undefined; + if (growing) { + trend = "^ UP"; + trendColor = "red"; + } else if (shrinking) { + trend = "v DN"; + trendColor = "green"; + } return ( From 897817b8d29bce376641be9c14665b3524a29670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:46:25 +0200 Subject: [PATCH 15/16] refactor: remove negated condition in search pagination --- src/commands/search.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/search.ts b/src/commands/search.ts index e37d37d..e6e2f80 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -61,16 +61,14 @@ export const searchCommand = new Command("search") const batchSize = 100; const matches: ServiceBusReceivedMessage[] = []; let scanned = 0; - let fromSequenceNumber: bigint | undefined; + let peekOptions: { fromSequenceNumber?: never } = {}; // 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), - fromSequenceNumber !== undefined - ? { fromSequenceNumber: fromSequenceNumber as never } - : undefined + peekOptions ); if (batch.length === 0) break; @@ -104,8 +102,11 @@ export const searchCommand = new Command("search") scanned += batch.length; // Next batch starts after the last message's sequence number const lastSeq = batch[batch.length - 1].sequenceNumber; - fromSequenceNumber = - lastSeq !== undefined ? BigInt(lastSeq.toString()) + 1n : undefined; + if (lastSeq !== undefined) { + peekOptions = { + fromSequenceNumber: (BigInt(lastSeq.toString()) + 1n) as never, + }; + } } if (matches.length === 0) { From 3c83701faa7b48c09322650aa03ca7262181614c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20R=C3=B6diger?= Date: Sun, 29 Mar 2026 15:50:52 +0200 Subject: [PATCH 16/16] fix: flip negated condition in search, prevent object stringification in topology --- src/commands/search.ts | 6 +++--- src/commands/topology.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/search.ts b/src/commands/search.ts index e6e2f80..df7ee73 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -88,11 +88,11 @@ export const searchCommand = new Command("search") // Property filter if (isMatch && propKey && propValue) { const props = m.applicationProperties; - if (!props) { - isMatch = false; - } else { + if (props) { const val = String(props[propKey] ?? ""); if (val !== propValue) isMatch = false; + } else { + isMatch = false; } } diff --git a/src/commands/topology.ts b/src/commands/topology.ts index 6b4fa9d..72d7d10 100644 --- a/src/commands/topology.ts +++ b/src/commands/topology.ts @@ -73,7 +73,7 @@ function formatSqlFilter(f: SqlRuleFilter): string { function formatCorrelationFilter(f: CorrelationRuleFilter): string { const parts: string[] = CORRELATION_FIELDS.filter((k) => f[k]).map( - (k) => `${k}=${f[k]}` + (k) => `${k}=${String(f[k])}` ); const props = f.properties || f.applicationProperties || {};