diff --git a/extensions/ai-status-check/.gitignore b/extensions/ai-status-check/.gitignore new file mode 100644 index 00000000000..63b78c47113 --- /dev/null +++ b/extensions/ai-status-check/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.firecrawl/ +.DS_Store diff --git a/extensions/ai-status-check/.nvmrc b/extensions/ai-status-check/.nvmrc new file mode 100644 index 00000000000..60ade1ae01e --- /dev/null +++ b/extensions/ai-status-check/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/extensions/ai-status-check/.prettierignore b/extensions/ai-status-check/.prettierignore new file mode 100644 index 00000000000..93188795a3c --- /dev/null +++ b/extensions/ai-status-check/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +raycast-env.d.ts +tests/fixtures/ diff --git a/extensions/ai-status-check/.prettierrc b/extensions/ai-status-check/.prettierrc new file mode 100644 index 00000000000..fc0f5030683 --- /dev/null +++ b/extensions/ai-status-check/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "singleQuote": false +} diff --git a/extensions/ai-status-check/CHANGELOG.md b/extensions/ai-status-check/CHANGELOG.md new file mode 100644 index 00000000000..6f40dc02715 --- /dev/null +++ b/extensions/ai-status-check/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## [Initial Release] - {PR_MERGE_DATE} + +- Check the published system status of 18 AI providers from one command. +- Inspect provider components in a dedicated detail view, including source-published history and uptime where available. +- Read incident update timelines and open the corresponding official source. +- Choose which providers are displayed and fetched from extension preferences. +- Preserve last-known results while status sources refresh independently. diff --git a/extensions/ai-status-check/LICENSE b/extensions/ai-status-check/LICENSE new file mode 100644 index 00000000000..2d370408ff2 --- /dev/null +++ b/extensions/ai-status-check/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Astatine-213 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/ai-status-check/README.md b/extensions/ai-status-check/README.md new file mode 100644 index 00000000000..cdde1fbba43 --- /dev/null +++ b/extensions/ai-status-check/README.md @@ -0,0 +1,48 @@ +# AI Provider Status + +Check official service health across popular AI providers without opening or maintaining a collection of status-page URLs. AI Provider Status ships with a curated catalog of provider integrations: choose the providers you use in Raycast preferences, then inspect their published system health, components, active incidents, and recent incident updates from one native command. + +Unlike a general-purpose status-page monitor, there are no URLs to add, detect, or maintain. Each supported AI provider is integrated with its official public status source, including provider-specific handling when status systems expose different components or incident formats. + +## Features + +- Choose from 18 built-in AI providers with no status-page setup. +- See provider-published health at a glance, with matching severity icons. +- Preview each service's published component history and exact uptime when its official source provides them. +- Open a dedicated component list to inspect individual services; components without published history remain available with their current status and a clear no-history message. +- Read active incidents and their update timelines inside Raycast. +- Press Enter on a component to open its official source, or open the provider's status page from the action panel. +- Show cached last-known results immediately while a quiet background refresh runs. +- Enable only the providers you use from Raycast preferences. + +The default list includes OpenAI, Claude, Google AI Studio and Gemini, xAI, DeepSeek, and OpenRouter. The full catalog also covers Mistral AI, Cohere, Perplexity, Groq, Together AI, Fireworks AI, Cerebras, Replicate, Hugging Face, Baseten, ElevenLabs, and Stability AI. + +## Usage + +1. Run **Check AI Provider Status**. +2. Press Enter on a provider to see its components and incidents. +3. Choose **View Components**, then select a component to preview its provider-published history and uptime. Press Enter to open the component's official source. +4. Press Enter on an incident to open its local detail view. +5. Use the action panel to open an official source, refresh, or configure providers. + +History availability varies by provider and component. The extension shows the source's chart whenever it can reproduce it faithfully, displays uptime only when the source publishes or precisely measures that percentage, and does not manufacture missing history from the current operational state. + +Providers are grouped by purpose in preferences. Disabled providers are neither displayed nor fetched. + +## Privacy + +AI Provider Status makes unauthenticated requests only to the enabled providers' public, official status sources. It does not ask for provider credentials, run model prompts, collect analytics, or use an extension-specific backend. Last-successful responses are cached locally by Raycast so the list can remain useful during a temporary source failure. + +## Notes + +AI Provider Status is an independent project and is not affiliated with the providers it displays. Provider names and marks belong to their respective owners. See [Third-Party Notices](THIRD_PARTY_NOTICES.md) for bundled icon attribution. + +## Development + +```bash +nvm use +npm ci +npm run dev +``` + +Run `npm test`, `npm run typecheck`, `npm run lint`, and `npm run build` before submitting a change. `npm run check:sources` is an opt-in live check of every official status source; the regular test suite is deterministic and runs from local fixtures. diff --git a/extensions/ai-status-check/THIRD_PARTY_NOTICES.md b/extensions/ai-status-check/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..adbce372413 --- /dev/null +++ b/extensions/ai-status-check/THIRD_PARTY_NOTICES.md @@ -0,0 +1,25 @@ +# Third-Party Notices + +Provider names and marks are used only to identify the services shown by this extension and remain the property of their respective owners. + +## LobeHub Icons + +Most provider icons were rendered from marks distributed in `@lobehub/icons-static-svg` 1.94.0. + +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## Simple Icons + +The Claude mark was rendered from Simple Icons, which is distributed under the CC0 1.0 Universal public-domain dedication. Claude is a trademark of Anthropic PBC. + +## OpenAI + +The OpenAI mark was obtained from the asset used by the official OpenAI status page. OpenAI and its marks are trademarks of OpenAI, L.L.C. diff --git a/extensions/ai-status-check/assets/icon.png b/extensions/ai-status-check/assets/icon.png new file mode 100644 index 00000000000..30afe3ab762 Binary files /dev/null and b/extensions/ai-status-check/assets/icon.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/README.md b/extensions/ai-status-check/assets/provider-icons/README.md new file mode 100644 index 00000000000..db3a7321793 --- /dev/null +++ b/extensions/ai-status-check/assets/provider-icons/README.md @@ -0,0 +1,9 @@ +# Provider Icon Sources + +- `openai.png`: OpenAI mark downloaded from the asset referenced by the official OpenAI status page. +- `claude.png`: Rendered from the Claude mark distributed by Simple Icons. Claude is a trademark of Anthropic PBC. +- All other PNG files: provider marks from `@lobehub/icons-static-svg` 1.94.0, rendered locally with a shared icon treatment. + +The rendered icons use a shared circular background so monochrome marks remain readable in both Raycast themes. Development source files are not bundled with the extension. See the repository's `THIRD_PARTY_NOTICES.md` for license details. + +The provider marks belong to their respective owners and are bundled only to identify their services in the extension. diff --git a/extensions/ai-status-check/assets/provider-icons/baseten.png b/extensions/ai-status-check/assets/provider-icons/baseten.png new file mode 100644 index 00000000000..c159d22805f Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/baseten.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/cerebras.png b/extensions/ai-status-check/assets/provider-icons/cerebras.png new file mode 100644 index 00000000000..080246efb17 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/cerebras.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/claude.png b/extensions/ai-status-check/assets/provider-icons/claude.png new file mode 100644 index 00000000000..b24e30ff61c Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/claude.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/cohere.png b/extensions/ai-status-check/assets/provider-icons/cohere.png new file mode 100644 index 00000000000..5b5ea6c8319 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/cohere.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/deepseek.png b/extensions/ai-status-check/assets/provider-icons/deepseek.png new file mode 100644 index 00000000000..0ef846b79a4 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/deepseek.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/elevenlabs.png b/extensions/ai-status-check/assets/provider-icons/elevenlabs.png new file mode 100644 index 00000000000..82a8cc97d3b Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/elevenlabs.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/fireworks.png b/extensions/ai-status-check/assets/provider-icons/fireworks.png new file mode 100644 index 00000000000..a5c909e5a1b Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/fireworks.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/gemini.png b/extensions/ai-status-check/assets/provider-icons/gemini.png new file mode 100644 index 00000000000..98117b9df1b Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/gemini.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/groq.png b/extensions/ai-status-check/assets/provider-icons/groq.png new file mode 100644 index 00000000000..113018ad366 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/groq.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/huggingface.png b/extensions/ai-status-check/assets/provider-icons/huggingface.png new file mode 100644 index 00000000000..884163b6cea Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/huggingface.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/mistral.png b/extensions/ai-status-check/assets/provider-icons/mistral.png new file mode 100644 index 00000000000..4165fe4de51 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/mistral.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/openai.png b/extensions/ai-status-check/assets/provider-icons/openai.png new file mode 100644 index 00000000000..84bc2a30533 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/openai.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/openrouter.png b/extensions/ai-status-check/assets/provider-icons/openrouter.png new file mode 100644 index 00000000000..db6486dd405 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/openrouter.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/perplexity.png b/extensions/ai-status-check/assets/provider-icons/perplexity.png new file mode 100644 index 00000000000..6207915af56 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/perplexity.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/replicate.png b/extensions/ai-status-check/assets/provider-icons/replicate.png new file mode 100644 index 00000000000..f9b1bccb9c1 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/replicate.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/stability.png b/extensions/ai-status-check/assets/provider-icons/stability.png new file mode 100644 index 00000000000..8909c385528 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/stability.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/together.png b/extensions/ai-status-check/assets/provider-icons/together.png new file mode 100644 index 00000000000..84df315c41f Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/together.png differ diff --git a/extensions/ai-status-check/assets/provider-icons/xai.png b/extensions/ai-status-check/assets/provider-icons/xai.png new file mode 100644 index 00000000000..b7bf65f32e6 Binary files /dev/null and b/extensions/ai-status-check/assets/provider-icons/xai.png differ diff --git a/extensions/ai-status-check/eslint.config.js b/extensions/ai-status-check/eslint.config.js new file mode 100644 index 00000000000..568ad8f202a --- /dev/null +++ b/extensions/ai-status-check/eslint.config.js @@ -0,0 +1,4 @@ +const { defineConfig } = require("eslint/config"); +const raycastConfig = require("@raycast/eslint-config"); + +module.exports = defineConfig([{ ignores: ["dist/**", "eslint.config.js"] }, ...raycastConfig]); diff --git a/extensions/ai-status-check/metadata/01-provider-overview.png b/extensions/ai-status-check/metadata/01-provider-overview.png new file mode 100644 index 00000000000..612cdcd630d Binary files /dev/null and b/extensions/ai-status-check/metadata/01-provider-overview.png differ diff --git a/extensions/ai-status-check/metadata/02-active-incident.png b/extensions/ai-status-check/metadata/02-active-incident.png new file mode 100644 index 00000000000..b85ffaee649 Binary files /dev/null and b/extensions/ai-status-check/metadata/02-active-incident.png differ diff --git a/extensions/ai-status-check/metadata/03-incident-timeline.png b/extensions/ai-status-check/metadata/03-incident-timeline.png new file mode 100644 index 00000000000..256a544e6c3 Binary files /dev/null and b/extensions/ai-status-check/metadata/03-incident-timeline.png differ diff --git a/extensions/ai-status-check/metadata/04-component-groups.png b/extensions/ai-status-check/metadata/04-component-groups.png new file mode 100644 index 00000000000..150ceb42bc8 Binary files /dev/null and b/extensions/ai-status-check/metadata/04-component-groups.png differ diff --git a/extensions/ai-status-check/metadata/05-components.png b/extensions/ai-status-check/metadata/05-components.png new file mode 100644 index 00000000000..6febce9991c Binary files /dev/null and b/extensions/ai-status-check/metadata/05-components.png differ diff --git a/extensions/ai-status-check/package-lock.json b/extensions/ai-status-check/package-lock.json new file mode 100644 index 00000000000..c70b70768f5 --- /dev/null +++ b/extensions/ai-status-check/package-lock.json @@ -0,0 +1,2741 @@ +{ + "name": "ai-status-check", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-status-check", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@raycast/api": "^1.104.24" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@types/node": "24.13.3", + "@types/react": "19.0.10", + "eslint": "^10.8.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "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/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.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "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": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.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/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@oclif/core": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.13.3.tgz", + "integrity": "sha512-wBp2igI2KVwq2ZmpnfmGtOROo7aXZIr3OEtAS16g1wHFBqyEuswL+9K50NJWJvlpAmxd+4dVj/ycawXESU3wQQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.17", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "3.2.56", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.56.tgz", + "integrity": "sha512-P96b6RZvQatlJ6pX6sgIFOQhgX2tFVhgSfw53GT/MajecCQNFGVqagJKo2CfcPsacDpuFuPRT6zji8h1oaqtrA==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "6.2.58", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.58.tgz", + "integrity": "sha512-DAYMXZrTWRMLTbpX+rT+ibAmKrZ6IVNGn+fgAGjMoeNlqlSY94eJHocgmqChMwsdWp3H3x+jFmPJiYQt/ifr3Q==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "3.2.93", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.93.tgz", + "integrity": "sha512-KBizxn+kgTPWLUCo/vDz/oDdEA5Ll5H6x0jJhl05B56yHwgQp46ZxwXmtBsxvvgrmMqJEtFUw2Qrq+nTHWWv4g==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@oclif/core": "^4.13.3", + "ansis": "^3.17.0", + "fast-levenshtein": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@raycast/api": { + "version": "1.104.24", + "resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.104.24.tgz", + "integrity": "sha512-Jppfyi1T7wcGXZFxpzXuvNAKUPQ+BQkKC0N3rJBz1epp/z22SH9aLA23YZRk/ickGwEm6DI/7OY7Jin8dD53Sg==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4.8.4", + "@oclif/plugin-autocomplete": "^3.2.40", + "@oclif/plugin-help": "^6.2.37", + "@oclif/plugin-not-found": "^3.2.74", + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "esbuild": "^0.27.3", + "react": "19.0.0" + }, + "bin": { + "ray": "bin/run.js" + }, + "engines": { + "node": ">=22.22.2" + }, + "peerDependencies": { + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "react-devtools": "6.1.1" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react-devtools": { + "optional": true + } + } + }, + "node_modules/@raycast/api/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@raycast/api/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/@raycast/eslint-config": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-config/-/eslint-config-2.2.0.tgz", + "integrity": "sha512-uS/UDvM+3HfABgYhLYnIv0y+IVu+rGiZZIMwQsO5EGrcf2/TX/yioTzfezAQqhdszW4ViCuHbv8BddIagDKZvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/js": "^9.39.4", + "@raycast/eslint-plugin": "^2.2.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.7.0", + "typescript-eslint": "^8.62.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "prettier": ">=2", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@raycast/eslint-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-plugin/-/eslint-plugin-2.2.0.tgz", + "integrity": "sha512-ixO3PoRAEAZZcSQeRPvvDS/vs09D47tXdyXrs7hctoxxPtweXgJ6I24kjCD5GuuuQS5hGTiuL+Uy6hIg6a/a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.62.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "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": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.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.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.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.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "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/@typescript-eslint/visitor-keys/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/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "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/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "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", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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.5", + "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": "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/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/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/espree/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/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/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/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": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", + "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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/globals": { + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "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", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "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/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/optionator/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/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/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-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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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/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": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.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.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/extensions/ai-status-check/package.json b/extensions/ai-status-check/package.json new file mode 100644 index 00000000000..b4da83398d1 --- /dev/null +++ b/extensions/ai-status-check/package.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "ai-status-check", + "version": "0.1.0", + "private": true, + "title": "AI Provider Status", + "description": "Check official service health, components, and recent incidents across popular AI providers.", + "icon": "icon.png", + "author": "Astatine-213", + "platforms": [ + "macOS", + "Windows" + ], + "categories": [ + "Developer Tools", + "Productivity" + ], + "license": "MIT", + "commands": [ + { + "name": "check-ai-status", + "title": "Check AI Provider Status", + "description": "View provider health, component outages, and recent incidents from official sources", + "mode": "view" + } + ], + "preferences": [ + { + "name": "showOpenAI", + "type": "checkbox", + "title": "Model Providers", + "description": "Show OpenAI status, components, and incidents.", + "label": "OpenAI", + "default": true, + "required": false + }, + { + "name": "showClaude", + "type": "checkbox", + "title": "", + "description": "Show Claude status, components, and incidents.", + "label": "Claude", + "default": true, + "required": false + }, + { + "name": "showGemini", + "type": "checkbox", + "title": "", + "description": "Show Google Gemini API and AI Studio status.", + "label": "Google AI Studio and Gemini", + "default": true, + "required": false + }, + { + "name": "showXAI", + "type": "checkbox", + "title": "", + "description": "Show xAI, Grok, and API status.", + "label": "xAI", + "default": true, + "required": false + }, + { + "name": "showDeepSeek", + "type": "checkbox", + "title": "", + "description": "Show DeepSeek status, components, and incidents.", + "label": "DeepSeek", + "default": true, + "required": false + }, + { + "name": "showMistral", + "type": "checkbox", + "title": "", + "description": "Show Mistral AI service and incident status.", + "label": "Mistral AI", + "default": false, + "required": false + }, + { + "name": "showCohere", + "type": "checkbox", + "title": "", + "description": "Show Cohere status, components, and incidents.", + "label": "Cohere", + "default": false, + "required": false + }, + { + "name": "showPerplexity", + "type": "checkbox", + "title": "", + "description": "Show Perplexity application and API status.", + "label": "Perplexity", + "default": false, + "required": false + }, + { + "name": "showOpenRouter", + "type": "checkbox", + "title": "Routers & Inference", + "description": "Show OpenRouter service and incident status.", + "label": "OpenRouter", + "default": true, + "required": false + }, + { + "name": "showGroq", + "type": "checkbox", + "title": "", + "description": "Show GroqCloud status, components, and incidents.", + "label": "Groq", + "default": false, + "required": false + }, + { + "name": "showTogetherAI", + "type": "checkbox", + "title": "", + "description": "Show Together AI service and incident status.", + "label": "Together AI", + "default": false, + "required": false + }, + { + "name": "showFireworksAI", + "type": "checkbox", + "title": "", + "description": "Show Fireworks AI status, components, and incidents.", + "label": "Fireworks AI", + "default": false, + "required": false + }, + { + "name": "showCerebras", + "type": "checkbox", + "title": "", + "description": "Show Cerebras inference status and incidents.", + "label": "Cerebras", + "default": false, + "required": false + }, + { + "name": "showReplicate", + "type": "checkbox", + "title": "", + "description": "Show Replicate status, components, and incidents.", + "label": "Replicate", + "default": false, + "required": false + }, + { + "name": "showHuggingFace", + "type": "checkbox", + "title": "", + "description": "Show Hugging Face service and incident status.", + "label": "Hugging Face", + "default": false, + "required": false + }, + { + "name": "showBaseten", + "type": "checkbox", + "title": "", + "description": "Show Baseten inference status and incidents.", + "label": "Baseten", + "default": false, + "required": false + }, + { + "name": "showElevenLabs", + "type": "checkbox", + "title": "Specialized Generation", + "description": "Show ElevenLabs voice service status and incidents.", + "label": "ElevenLabs", + "default": false, + "required": false + }, + { + "name": "showStabilityAI", + "type": "checkbox", + "title": "", + "description": "Show Stability AI API status and incidents.", + "label": "Stability AI", + "default": false, + "required": false + } + ], + "scripts": { + "build": "ray build -e dist", + "check:sources": "npm run test:compile && node node_modules/.cache/ai-status-check-tests/tests/support/check-status-sources.js", + "check:store": "npm run test:compile && node node_modules/.cache/ai-status-check-tests/tests/support/check-store-assets.js", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "publish": "npm run check:store && npx @raycast/api@latest publish", + "test": "npm run test:compile && node node_modules/.cache/ai-status-check-tests/tests/support/run-tests.js", + "test:compile": "node -e \"require('node:fs').rmSync('node_modules/.cache/ai-status-check-tests',{recursive:true,force:true})\" && tsc -p tsconfig.test.json", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@raycast/api": "^1.104.24" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@types/node": "24.13.3", + "@types/react": "19.0.10", + "eslint": "^10.8.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.2" + }, + "packageManager": "npm@11.17.0", + "allowScripts": { + "esbuild@0.27.7": true + } +} diff --git a/extensions/ai-status-check/src/check-ai-status.tsx b/extensions/ai-status-check/src/check-ai-status.tsx new file mode 100644 index 00000000000..b351b739941 --- /dev/null +++ b/extensions/ai-status-check/src/check-ai-status.tsx @@ -0,0 +1,70 @@ +import { + Action, + ActionPanel, + Icon, + List, + Toast, + getPreferenceValues, + openExtensionPreferences, + showToast, +} from "@raycast/api"; +import { useProviderStatuses } from "./hooks/use-provider-statuses"; +import { getEnabledProviders, type ProviderPreferences } from "./providers/registry"; +import { buildProviderSections, unavailableProviderRecord } from "./providers/provider-sections"; +import { ProviderListItem } from "./components/provider-list-item"; + +export default function CheckAiStatusCommand() { + const preferences = getPreferenceValues(); + const enabledProviders = getEnabledProviders(preferences); + const { records, isRefreshing, isInitialLoading, refreshAll, refreshProvider } = + useProviderStatuses(enabledProviders); + const sections = buildProviderSections(enabledProviders, records); + + async function refreshAllWithFeedback() { + const toast = await showToast({ style: Toast.Style.Animated, title: "Refreshing AI provider status" }); + const results = await refreshAll(true); + const failures = results.filter((record) => record.refreshState === "failed").length; + + toast.style = failures > 0 ? Toast.Style.Failure : Toast.Style.Success; + toast.title = + failures > 0 ? `Refreshed with ${failures} failure${failures === 1 ? "" : "s"}` : "AI status refreshed"; + toast.message = failures > 0 ? `${results.length - failures} of ${results.length} providers updated` : undefined; + } + + if (enabledProviders.length === 0) { + return ( + + + + + } + /> + + ); + } + + return ( + + {isInitialLoading + ? null + : sections.map((section) => ( + + {section.providers.map((provider) => ( + + ))} + + ))} + + ); +} diff --git a/extensions/ai-status-check/src/components/provider-actions.tsx b/extensions/ai-status-check/src/components/provider-actions.tsx new file mode 100644 index 00000000000..de90c4f5bd9 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-actions.tsx @@ -0,0 +1,145 @@ +import { Action, ActionPanel, Icon, Keyboard, openExtensionPreferences } from "@raycast/api"; +import type { ReactNode } from "react"; +import type { ComponentStatus, Incident } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; + +interface ProviderActionsProps { + provider: ProviderDefinition; + onRefresh(): Promise; +} + +interface NavigationActionsProps extends ProviderActionsProps { + target: ReactNode; +} + +export function ProviderListActions({ + provider, + detail, + onRefreshAll, +}: { + provider: ProviderDefinition; + detail: ReactNode; + onRefreshAll(): Promise; +}) { + return ( + + + + + + + ); +} + +export function ProviderSourceActions({ provider, onRefresh }: ProviderActionsProps) { + return ( + + + + + ); +} + +export function ComponentActions({ + component, + provider, + onRefresh, +}: ProviderActionsProps & { component: ComponentStatus }) { + return ( + + + + {component.url ? : null} + + ); +} + +export function ComponentListActions({ provider, target, onRefresh }: NavigationActionsProps) { + return ( + + + + + + ); +} + +export function IncidentActions({ + incident, + provider, + target, + onRefresh, +}: NavigationActionsProps & { incident: Incident }) { + return ( + + + {incident.url ? ( + + ) : null} + + {incident.url ? ( + + ) : null} + + + ); +} + +export function IncidentDetailActions({ incident, provider }: { incident: Incident; provider: ProviderDefinition }) { + return ( + + {incident.url ? ( + + ) : null} + {incident.url ? ( + + ) : null} + + + ); +} + +function OpenProviderStatusAction({ provider }: { provider: ProviderDefinition }) { + return ( + + ); +} + +function RefreshProviderAction({ onRefresh }: { onRefresh(): Promise }) { + return ( + + ); +} diff --git a/extensions/ai-status-check/src/components/provider-components.tsx b/extensions/ai-status-check/src/components/provider-components.tsx new file mode 100644 index 00000000000..4ab7fdc0028 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-components.tsx @@ -0,0 +1,300 @@ +import { Icon, List } from "@raycast/api"; +import { useEffect, useRef, useState } from "react"; +import { highestHealth } from "../domain/derive-health"; +import { buildComponentSections } from "../domain/provider-view"; +import { componentStatusPresentation, fallbackHealthLabel } from "../domain/status-presentation"; +import type { ComponentHistory, ComponentStatus, ProviderStatusRecord } from "../domain/types"; +import { useRefreshableProviderRecord, type RefreshProvider } from "../hooks/use-refreshable-provider-record"; +import type { ProviderDefinition } from "../providers/types"; +import { buildComponentHistoryMarkdown, formatUptimePercent } from "../utils/component-history-markdown"; +import { ComponentActions, ComponentListActions, ProviderSourceActions } from "./provider-actions"; +import { statusIcon } from "./status-icon"; + +export function ProviderComponents({ + provider, + record, + refreshProvider, + onRefresh, +}: { + provider: ProviderDefinition; + record: ProviderStatusRecord; + refreshProvider: RefreshProvider; + onRefresh(): Promise; +}) { + const components = record.snapshot?.components ?? []; + + if (components.length === 0) return null; + + const health = highestHealth(components.map((component) => component.health)); + const affectedCount = components.filter( + (component) => component.health !== "operational" && component.health !== "unknown", + ).length; + + return ( + + component.name)} + accessories={[ + { text: fallbackHealthLabel(health) }, + { text: componentCollectionMetadata(components.length, affectedCount) }, + ]} + actions={ + } + onRefresh={onRefresh} + /> + } + /> + + ); +} + +function ComponentList({ + provider, + record, + refreshProvider, +}: { + provider: ProviderDefinition; + record: ProviderStatusRecord; + refreshProvider: RefreshProvider; +}) { + const { record: currentRecord, refresh } = useRefreshableProviderRecord(provider.id, record, refreshProvider); + const components = currentRecord.snapshot?.components ?? []; + const sections = buildComponentSections(components); + const firstComponent = sections.groups[0]?.components[0] ?? sections.ungrouped[0]; + const [selectedItemId, setSelectedItemId] = useState(() => + firstComponent ? componentItemId(firstComponent.id) : null, + ); + + return ( + + {components.length > 0 ? ( + <> + {sections.groups.map((group) => ( + + ))} + {sections.ungrouped.length > 0 ? ( + 0 ? "Other Components" : "Components"} + subtitle={componentCount(sections.ungrouped.length)} + components={sections.ungrouped} + provider={provider} + onRefresh={refresh} + fetchedAt={currentRecord.snapshot?.fetchedAt} + selectedItemId={selectedItemId} + /> + ) : null} + + ) : ( + } + /> + )} + + ); +} + +function ComponentSection({ + title, + subtitle, + components, + provider, + onRefresh, + fetchedAt, + selectedItemId, +}: { + title: string; + subtitle?: string; + components: readonly ComponentStatus[]; + provider: ProviderDefinition; + onRefresh(): Promise; + fetchedAt?: string; + selectedItemId?: string | null; +}) { + return ( + + {components.map((component) => ( + + ))} + + ); +} + +function ComponentItem({ + component, + provider, + onRefresh, + fetchedAt, + selected, +}: { + component: ComponentStatus; + provider: ProviderDefinition; + onRefresh(): Promise; + fetchedAt?: string; + selected: boolean; +}) { + const status = componentStatusPresentation(component); + const lazy = useLazyComponentHistory(component, provider, fetchedAt, selected); + const history = component.history ?? lazy.history; + const uptime = formatUptimePercent(history?.uptimePercent, history?.uptimeText); + return ( + + + + + {uptime ? ( + <> + + + + ) : null} + {history ? ( + <> + + + + ) : null} + {history?.monitoredSince ? ( + <> + + + + ) : null} + {fetchedAt ? ( + <> + + + + ) : null} + + } + /> + } + actions={} + /> + ); +} + +function useLazyComponentHistory( + component: ComponentStatus, + provider: ProviderDefinition, + fetchedAt: string | undefined, + selected: boolean, +): { history?: ComponentHistory; isLoading: boolean; error?: string } { + const [state, setState] = useState<{ history?: ComponentHistory; isLoading: boolean; error?: string }>({ + isLoading: false, + }); + const requestRef = useRef<{ key: string; controller: AbortController; status: "loading" | "success" } | undefined>( + undefined, + ); + + useEffect(() => { + const fetchHistory = provider.adapter.fetchComponentHistory; + if (!selected || component.history || !fetchHistory) { + if (requestRef.current?.status === "loading") { + requestRef.current.controller.abort(); + requestRef.current = undefined; + setState((current) => ({ ...current, isLoading: false })); + } + return; + } + const requestKey = `${component.id}:${fetchedAt ?? "unknown"}`; + if (requestRef.current?.key === requestKey) return; + requestRef.current?.controller.abort(); + const controller = new AbortController(); + const request = { key: requestKey, controller, status: "loading" as const }; + requestRef.current = request; + setState({ isLoading: true }); + void fetchHistory(component.id, controller.signal) + .then((history) => { + if (controller.signal.aborted || requestRef.current !== request) return; + if (history) { + requestRef.current = { ...request, status: "success" }; + } else { + requestRef.current = undefined; + } + setState({ history, isLoading: false }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted || requestRef.current !== request) return; + requestRef.current = undefined; + setState({ + isLoading: false, + error: error instanceof Error ? error.message : "Component history could not be loaded.", + }); + }); + }, [component.history, component.id, fetchedAt, provider.adapter, selected]); + useEffect(() => () => requestRef.current?.controller.abort(), []); + + return state; +} + +function componentDetailMarkdown( + history: ComponentHistory | undefined, + isLoading: boolean, + error: string | undefined, +): string { + const historyMarkdown = buildComponentHistoryMarkdown(history); + if (historyMarkdown) return historyMarkdown; + if (isLoading) return "Loading component history…"; + if (error) return "Component history could not be loaded. Current status is still available."; + return "No component history is published for this service."; +} + +function historyDescription(history: ComponentHistory): string { + return `${history.windowDays}-day ${history.basis === "incidents" ? "incident" : "availability"} history`; +} + +function componentItemId(componentId: string): string { + return `component:${componentId}`; +} + +function componentCount(count: number): string { + return `${count} component${count === 1 ? "" : "s"}`; +} + +function componentCollectionMetadata(count: number, affectedCount: number): string { + if (affectedCount > 0 && affectedCount < count) { + return `${affectedCount} of ${count} affected`; + } + return componentCount(count); +} diff --git a/extensions/ai-status-check/src/components/provider-detail.tsx b/extensions/ai-status-check/src/components/provider-detail.tsx new file mode 100644 index 00000000000..12c39800747 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-detail.tsx @@ -0,0 +1,52 @@ +import { Icon, List } from "@raycast/api"; +import { getActiveIncidents, getRecentIncidents } from "../domain/provider-view"; +import type { ProviderStatusRecord } from "../domain/types"; +import { useRefreshableProviderRecord, type RefreshProvider } from "../hooks/use-refreshable-provider-record"; +import type { ProviderDefinition } from "../providers/types"; +import { ProviderSourceActions } from "./provider-actions"; +import { ProviderComponents } from "./provider-components"; +import { ActiveIncidents, RecentIncidents } from "./provider-incidents"; +import { ProviderOverview } from "./provider-overview"; + +interface ProviderDetailProps { + provider: ProviderDefinition; + record: ProviderStatusRecord; + onRefresh: RefreshProvider; +} + +export function ProviderDetail({ provider, record, onRefresh }: ProviderDetailProps) { + const { record: currentRecord, refresh } = useRefreshableProviderRecord(provider.id, record, onRefresh); + const snapshot = currentRecord.snapshot; + const incidents = snapshot?.incidents ?? []; + const activeIncidents = getActiveIncidents(incidents); + const recentIncidents = getRecentIncidents(incidents); + + return ( + + {!snapshot ? ( + } + /> + ) : ( + <> + + + + + + )} + + ); +} diff --git a/extensions/ai-status-check/src/components/provider-incidents.tsx b/extensions/ai-status-check/src/components/provider-incidents.tsx new file mode 100644 index 00000000000..63ca348b850 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-incidents.tsx @@ -0,0 +1,86 @@ +import { Detail, Icon, List } from "@raycast/api"; +import { incidentActivityLabel, incidentStateLabel } from "../domain/status-presentation"; +import type { Incident } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; +import { buildIncidentMarkdown } from "../utils/incident-markdown"; +import { IncidentActions, IncidentDetailActions, ProviderSourceActions } from "./provider-actions"; +import { incidentIcon } from "./status-icon"; + +interface IncidentSectionProps { + incidents: readonly Incident[]; + provider: ProviderDefinition; + onRefresh(): Promise; +} + +export function ActiveIncidents({ incidents, provider, onRefresh }: IncidentSectionProps) { + if (incidents.length === 0) return null; + + return ( + + {incidents.map((incident) => ( + + ))} + + ); +} + +export function RecentIncidents({ incidents, provider, onRefresh }: IncidentSectionProps) { + return ( + + {incidents.length > 0 ? ( + incidents.map((incident) => ( + + )) + ) : ( + } + /> + )} + + ); +} + +function IncidentItem({ + incident, + provider, + onRefresh, +}: { + incident: Incident; + provider: ProviderDefinition; + onRefresh(): Promise; +}) { + const activity = incidentActivityLabel(incident); + const accessories: List.Item.Accessory[] = [{ text: incidentStateLabel(incident) }]; + if (activity) accessories.push({ text: activity }); + + return ( + } + onRefresh={onRefresh} + /> + } + /> + ); +} + +function IncidentDetail({ incident, provider }: { incident: Incident; provider: ProviderDefinition }) { + return ( + } + /> + ); +} diff --git a/extensions/ai-status-check/src/components/provider-list-item.tsx b/extensions/ai-status-check/src/components/provider-list-item.tsx new file mode 100644 index 00000000000..4fd10729018 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-list-item.tsx @@ -0,0 +1,86 @@ +import { Image, List } from "@raycast/api"; +import { + providerStatusPresentation as snapshotStatusPresentation, + type StatusPresentation, +} from "../domain/status-presentation"; +import { highestHealth } from "../domain/derive-health"; +import { getActiveIncidents } from "../domain/provider-view"; +import type { Incident, ProviderStatusRecord } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; +import { ProviderListActions } from "./provider-actions"; +import { ProviderDetail } from "./provider-detail"; +import { statusIcon } from "./status-icon"; + +interface ProviderListItemProps { + provider: ProviderDefinition; + record: ProviderStatusRecord; + onRefreshAll(): Promise; + onRefreshProvider(providerId: string): Promise; +} + +export function ProviderListItem({ provider, record, onRefreshAll, onRefreshProvider }: ProviderListItemProps) { + const snapshot = record.snapshot; + const activeIncidents = getActiveIncidents(snapshot?.incidents ?? []); + const hasOverallStatus = Boolean(snapshot && (snapshot.statusText || snapshot.health !== "unknown")); + const status = providerListStatus(record, activeIncidents); + const metadata = snapshot + ? providerMetadata(hasOverallStatus ? activeIncidents.length : 0, snapshot.components.length) + : undefined; + const accessories: List.Item.Accessory[] = [ + { + icon: statusIcon(status.health, record.freshness), + text: status.label, + tooltip: record.refreshError ?? status.label, + }, + ]; + if (metadata) accessories.push({ text: metadata, tooltip: accessoryTooltip(record) }); + + return ( + component.name) ?? [])]} + accessories={accessories} + actions={ + } + onRefreshAll={onRefreshAll} + /> + } + /> + ); +} + +function providerListStatus(record: ProviderStatusRecord, activeIncidents: readonly Incident[]): StatusPresentation { + if (!record.snapshot || record.freshness === "expired" || record.freshness === "unavailable") { + return { label: record.refreshState === "refreshing" ? "Loading" : "Unavailable", health: "unknown" }; + } + if (record.snapshot.health === "unknown" && !record.snapshot.statusText) { + if (activeIncidents.length === 0) return { label: "No Overall Status", health: "unknown" }; + return { + label: `${activeIncidents.length} active incident${activeIncidents.length === 1 ? "" : "s"}`, + health: highestHealth( + activeIncidents.map((incident) => (incident.state === "scheduled" ? "maintenance" : incident.health)), + ), + }; + } + return snapshotStatusPresentation(record.snapshot); +} + +function providerMetadata(activeIncidentCount: number, componentCount: number): string | undefined { + if (activeIncidentCount > 0) { + return `${activeIncidentCount} active incident${activeIncidentCount === 1 ? "" : "s"}`; + } + if (componentCount > 0) { + return `${componentCount} component${componentCount === 1 ? "" : "s"}`; + } + return undefined; +} + +function accessoryTooltip(record: ProviderStatusRecord): string | undefined { + if (record.refreshState === "failed") return record.refreshError; + if (record.snapshot) return `Last successful refresh: ${record.snapshot.fetchedAt}`; + return undefined; +} diff --git a/extensions/ai-status-check/src/components/provider-overview.tsx b/extensions/ai-status-check/src/components/provider-overview.tsx new file mode 100644 index 00000000000..05929ae74f9 --- /dev/null +++ b/extensions/ai-status-check/src/components/provider-overview.tsx @@ -0,0 +1,38 @@ +import { List } from "@raycast/api"; +import { providerStatusPresentation, providerUpdatedLabel } from "../domain/status-presentation"; +import type { ProviderStatusRecord } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; +import { ProviderSourceActions } from "./provider-actions"; +import { statusIcon } from "./status-icon"; + +export function ProviderOverview({ + provider, + record, + onRefresh, +}: { + provider: ProviderDefinition; + record: ProviderStatusRecord; + onRefresh(): Promise; +}) { + const snapshot = record.snapshot; + if (!snapshot || (snapshot.health === "unknown" && !snapshot.statusText)) return null; + const status = providerStatusPresentation(snapshot); + + return ( + + } + /> + + ); +} + +function activityTooltip(record: ProviderStatusRecord): string | undefined { + if (record.refreshError) return record.refreshError; + if (record.snapshot) return `Last successful refresh: ${record.snapshot.fetchedAt}`; + return undefined; +} diff --git a/extensions/ai-status-check/src/components/status-icon.ts b/extensions/ai-status-check/src/components/status-icon.ts new file mode 100644 index 00000000000..25443d28b83 --- /dev/null +++ b/extensions/ai-status-check/src/components/status-icon.ts @@ -0,0 +1,32 @@ +import { Color, Icon, type Image } from "@raycast/api"; +import type { DataFreshness, Health, Incident } from "../domain/types"; + +export function statusIcon(health: Health, freshness: DataFreshness = "fresh"): Image.ImageLike { + if (freshness === "expired" || freshness === "unavailable") { + return { source: Icon.QuestionMarkCircle, tintColor: Color.SecondaryText }; + } + + switch (health) { + case "operational": + return { source: Icon.CheckCircle, tintColor: Color.Green }; + case "degraded": + return { source: Icon.ExclamationMark, tintColor: Color.Yellow }; + case "partial_outage": + return { source: Icon.ExclamationMark, tintColor: Color.Orange }; + case "major_outage": + return { source: Icon.XMarkCircle, tintColor: Color.Red }; + case "maintenance": + return { source: Icon.Clock, tintColor: Color.Blue }; + case "unknown": + return { source: Icon.QuestionMarkCircle, tintColor: Color.SecondaryText }; + } +} + +export function incidentIcon(incident: Incident): Image.ImageLike { + if (incident.state === "resolved") return { source: Icon.CheckCircle, tintColor: Color.Green }; + if (incident.state === "scheduled") return { source: Icon.Clock, tintColor: Color.Blue }; + if (incident.health === "operational" || incident.health === "unknown" || incident.health === "maintenance") { + return { source: Icon.ExclamationMark, tintColor: Color.Yellow }; + } + return statusIcon(incident.health); +} diff --git a/extensions/ai-status-check/src/domain/derive-health.ts b/extensions/ai-status-check/src/domain/derive-health.ts new file mode 100644 index 00000000000..e33a5d3a751 --- /dev/null +++ b/extensions/ai-status-check/src/domain/derive-health.ts @@ -0,0 +1,38 @@ +import type { ComponentStatus, Health, Incident } from "./types"; + +const HEALTH_RANK: Record, number> = { + operational: 0, + maintenance: 1, + degraded: 2, + partial_outage: 3, + major_outage: 4, +}; + +export function highestHealth(values: readonly Health[]): Health { + let highest: Exclude | undefined; + + for (const value of values) { + if (value === "unknown") continue; + if (highest === undefined || HEALTH_RANK[value] > HEALTH_RANK[highest]) highest = value; + } + + return highest ?? "unknown"; +} + +export function deriveProviderHealth( + reportedHealth: Health, + components: readonly ComponentStatus[], + incidents: readonly Incident[], +): Health { + if (reportedHealth !== "unknown") return reportedHealth; + + const activeIncidentHealth = incidents + .filter((incident) => incident.state !== "resolved") + .map((incident) => (incident.state === "scheduled" ? "maintenance" : incident.health)); + + return highestHealth([...components.map((component) => component.health), ...activeIncidentHealth]); +} + +export function isIssue(health: Health): boolean { + return health === "degraded" || health === "partial_outage" || health === "major_outage"; +} diff --git a/extensions/ai-status-check/src/domain/freshness.ts b/extensions/ai-status-check/src/domain/freshness.ts new file mode 100644 index 00000000000..d37220adc68 --- /dev/null +++ b/extensions/ai-status-check/src/domain/freshness.ts @@ -0,0 +1,16 @@ +import type { DataFreshness, ProviderSnapshot } from "./types"; + +export const FRESH_TTL_MS = 60_000; +export const STALE_TTL_MS = 24 * 60 * 60 * 1_000; + +export function getDataFreshness(snapshot: ProviderSnapshot | undefined, now = Date.now()): DataFreshness { + if (!snapshot) return "unavailable"; + + const fetchedAt = Date.parse(snapshot.fetchedAt); + if (!Number.isFinite(fetchedAt)) return "expired"; + + const age = Math.max(0, now - fetchedAt); + if (age <= FRESH_TTL_MS) return "fresh"; + if (age <= STALE_TTL_MS) return "stale"; + return "expired"; +} diff --git a/extensions/ai-status-check/src/domain/provider-view.ts b/extensions/ai-status-check/src/domain/provider-view.ts new file mode 100644 index 00000000000..caa34883099 --- /dev/null +++ b/extensions/ai-status-check/src/domain/provider-view.ts @@ -0,0 +1,61 @@ +import { incidentActivityTime } from "../providers/utils/incidents"; +import { highestHealth } from "./derive-health"; +import type { ComponentStatus, Health, Incident } from "./types"; + +const RECENT_INCIDENT_WINDOW_MS = 30 * 24 * 60 * 60 * 1_000; +const RECENT_INCIDENT_LIMIT = 10; + +export interface ComponentGroup { + name: string; + health: Health; + affectedCount: number; + components: ComponentStatus[]; +} + +interface ComponentSections { + ungrouped: ComponentStatus[]; + groups: ComponentGroup[]; +} + +export function buildComponentSections(components: readonly ComponentStatus[]): ComponentSections { + const ungrouped: ComponentStatus[] = []; + const groups = new Map(); + + for (const component of components) { + if (!component.group) { + ungrouped.push(component); + continue; + } + + const group = groups.get(component.group) ?? []; + group.push(component); + groups.set(component.group, group); + } + + return { + ungrouped, + groups: [...groups].map(([name, groupedComponents]) => ({ + name, + health: highestHealth(groupedComponents.map((component) => component.health)), + affectedCount: groupedComponents.filter( + (component) => component.health !== "operational" && component.health !== "unknown", + ).length, + components: groupedComponents, + })), + }; +} + +export function getActiveIncidents(incidents: readonly Incident[]): Incident[] { + return incidents.filter((incident) => incident.state !== "resolved"); +} + +export function getRecentIncidents(incidents: readonly Incident[], now = Date.now()): Incident[] { + const cutoff = now - RECENT_INCIDENT_WINDOW_MS; + return incidents + .filter((incident) => incident.state === "resolved") + .filter((incident) => { + const activityAt = incidentActivityTime(incident); + return activityAt === 0 || activityAt >= cutoff; + }) + .slice(0, RECENT_INCIDENT_LIMIT); +} diff --git a/extensions/ai-status-check/src/domain/status-presentation.ts b/extensions/ai-status-check/src/domain/status-presentation.ts new file mode 100644 index 00000000000..6d95c4ed60e --- /dev/null +++ b/extensions/ai-status-check/src/domain/status-presentation.ts @@ -0,0 +1,145 @@ +import type { ComponentStatus, Health, Incident, IncidentState, IncidentUpdate, ProviderSnapshot } from "./types"; +import { formatRelativeTime } from "../utils/dates"; +import { normalizeStatusToken } from "../utils/status-token"; + +export interface StatusPresentation { + label: string; + health: Health; +} + +const KNOWN_HEALTH: Readonly>> = { + operational: "operational", + up: "operational", + available: "operational", + good: "operational", + all_system_operational: "operational", + all_systems_operational: "operational", + fully_operational: "operational", + no_incidents_declared: "operational", + degraded: "degraded", + degraded_performance: "degraded", + partial_outage: "partial_outage", + major_outage: "major_outage", + maintenance: "maintenance", + under_maintenance: "maintenance", + maintenance_scheduled: "maintenance", +}; + +const KNOWN_INCIDENT_STATES: Readonly>> = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + completed: "Resolved", + closed: "Resolved", + scheduled: "Scheduled", + maintenance_scheduled: "Scheduled", +}; + +const KNOWN_IMPACTS: Readonly>> = { + none: "None", + minor: "Minor", + major: "Major", + critical: "Critical", +}; + +export function fallbackHealthLabel(health: Health): string { + switch (health) { + case "operational": + return "Operational"; + case "degraded": + return "Degraded Performance"; + case "partial_outage": + return "Partial Outage"; + case "major_outage": + return "Major Outage"; + case "maintenance": + return "Maintenance"; + case "unknown": + return "Unknown"; + } +} + +function fallbackIncidentStateLabel(state: IncidentState): string { + switch (state) { + case "investigating": + return "Investigating"; + case "identified": + return "Identified"; + case "monitoring": + return "Monitoring"; + case "resolved": + return "Resolved"; + case "scheduled": + return "Scheduled"; + case "unknown": + return "Unknown"; + } +} + +export function incidentImpactLabel(incident: Incident): string | undefined { + return knownSourceLabel(incident.impactText, KNOWN_IMPACTS); +} + +export function providerStatusPresentation(snapshot: ProviderSnapshot): StatusPresentation { + return healthPresentation(snapshot.statusText, snapshot.health, true); +} + +export function componentStatusPresentation(component: ComponentStatus): StatusPresentation { + return healthPresentation(component.statusText, component.health, false); +} + +export function incidentStateLabel(incident: Pick): string { + return knownSourceLabel(incident.stateText, KNOWN_INCIDENT_STATES) ?? fallbackIncidentStateLabel(incident.state); +} + +export function incidentUpdateStateLabel(update: Pick): string { + return knownSourceLabel(update.stateText, KNOWN_INCIDENT_STATES) ?? fallbackIncidentStateLabel(update.state); +} + +export function incidentActivityLabel(incident: Incident, now = Date.now()): string | undefined { + const updatedAt = formatRelativeTime(incident.updatedAt, now); + const startedAt = formatRelativeTime(incident.startedAt, now); + return updatedAt ? `updated ${updatedAt}` : startedAt ? `started ${startedAt}` : undefined; +} + +export function providerUpdatedLabel( + snapshot: Pick, + now = Date.now(), +): string | undefined { + const refreshedAt = formatRelativeTime(snapshot.fetchedAt, now); + return refreshedAt ? `updated ${refreshedAt}` : undefined; +} + +function healthPresentation( + sourceText: string | undefined, + fallbackHealth: Health, + overall: boolean, +): StatusPresentation { + const source = sourceValue(sourceText); + const knownHealth = source ? KNOWN_HEALTH[normalizeStatusToken(source)] : undefined; + const health = knownHealth ?? fallbackHealth; + return { + label: knownHealth ? healthLabel(knownHealth, overall) : (source ?? healthLabel(fallbackHealth, overall)), + health, + }; +} + +function healthLabel(health: Health, overall: boolean): string { + if (overall && health === "operational") return "All Systems Operational"; + return fallbackHealthLabel(health); +} + +function knownSourceLabel( + value: string | undefined, + labels: Readonly>>, +): string | undefined { + const source = sourceValue(value); + if (!source) return undefined; + return labels[normalizeStatusToken(source)] ?? source; +} + +function sourceValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} diff --git a/extensions/ai-status-check/src/domain/types.ts b/extensions/ai-status-check/src/domain/types.ts new file mode 100644 index 00000000000..80ad8de64ac --- /dev/null +++ b/extensions/ai-status-check/src/domain/types.ts @@ -0,0 +1,88 @@ +export type Health = "operational" | "degraded" | "partial_outage" | "major_outage" | "maintenance" | "unknown"; + +export type DataFreshness = "fresh" | "stale" | "expired" | "unavailable"; + +export type RefreshState = "idle" | "refreshing" | "failed"; + +export type IncidentState = "investigating" | "identified" | "monitoring" | "resolved" | "scheduled" | "unknown"; + +export type ProviderCategory = "model-providers" | "routers-and-inference" | "specialized"; + +export type ComponentHistoryLevel = + | "operational" + | "degraded" + | "partial_outage" + | "major_outage" + | "maintenance" + | "informational" + | "not_monitored" + | "unknown"; + +export interface ComponentHistoryDay { + date: string; + level: ComponentHistoryLevel; +} + +export interface ComponentHistory { + /** Number of calendar days represented by the provider's published history. */ + windowDays: number; + /** Whether the source publishes availability measurements or an incident calendar. */ + basis: "availability" | "incidents"; + days: ComponentHistoryDay[]; + /** Present only when the provider publishes or precisely measures this value. */ + uptimePercent?: number; + /** Source-faithful percentage text, including provider-specific precision. */ + uptimeText?: string; + /** First date for which the provider says monitoring data is available. */ + monitoredSince?: string; +} + +export interface IncidentUpdate { + id: string; + state: IncidentState; + stateText?: string; + body: string; + createdAt: string; +} + +export interface Incident { + id: string; + title: string; + health: Health; + state: IncidentState; + stateText?: string; + impactText?: string; + startedAt?: string; + updatedAt?: string; + resolvedAt?: string; + affectedComponentIds: string[]; + updates: IncidentUpdate[]; + url?: string; +} + +export interface ComponentStatus { + id: string; + name: string; + group?: string; + health: Health; + statusText?: string; + url?: string; + history?: ComponentHistory; +} + +export interface ProviderSnapshot { + providerId: string; + health: Health; + statusText?: string; + components: ComponentStatus[]; + incidents: Incident[]; + fetchedAt: string; +} + +export interface ProviderStatusRecord { + providerId: string; + snapshot?: ProviderSnapshot; + freshness: DataFreshness; + refreshState: RefreshState; + refreshError?: string; +} diff --git a/extensions/ai-status-check/src/hooks/use-provider-statuses.ts b/extensions/ai-status-check/src/hooks/use-provider-statuses.ts new file mode 100644 index 00000000000..4c1e6209196 --- /dev/null +++ b/extensions/ai-status-check/src/hooks/use-provider-statuses.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getDataFreshness } from "../domain/freshness"; +import type { ProviderStatusRecord } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; +import { recordFromCache, refreshProviderStatuses, refreshProviderStatus } from "../services/fetch-provider-statuses"; +import { ProviderRequestOrder } from "../services/provider-request-order"; +import { RaycastStatusCache, type StatusCache } from "../services/status-cache"; + +type StatusRecords = Record; + +interface ProviderStatuses { + records: StatusRecords; + isRefreshing: boolean; + isInitialLoading: boolean; + refreshAll(force?: boolean): Promise; + refreshProvider(providerId: string): Promise; +} + +export function useProviderStatuses(providers: readonly ProviderDefinition[]): ProviderStatuses { + const stableProviders = useStableProviders(providers); + const [cache] = useState(() => new RaycastStatusCache()); + const providerById = useMemo( + () => new Map(stableProviders.map((provider) => [provider.id, provider])), + [stableProviders], + ); + const [records, setRecords] = useState(() => recordsFromCache(stableProviders, cache)); + const [isRefreshing, setIsRefreshing] = useState(() => providersNeedRefresh(stableProviders, cache)); + const abortController = useRef(undefined); + const generation = useRef(0); + const requestOrder = useRef(new ProviderRequestOrder()); + + const refreshAll = useCallback( + async (force = true) => { + abortController.current?.abort(); + const controller = new AbortController(); + abortController.current = controller; + const currentGeneration = ++generation.current; + const requestTokens = new Map( + stableProviders.map((provider) => [provider.id, requestOrder.current.begin(provider.id)]), + ); + setIsRefreshing(force || providersNeedRefresh(stableProviders, cache)); + + try { + const results = await refreshProviderStatuses(stableProviders, { + cache, + force, + isCurrent: (providerId) => requestOrder.current.isCurrent(providerId, requestTokens.get(providerId)), + signal: controller.signal, + }); + if (generation.current === currentGeneration) { + const currentResults = results.filter((result) => + requestOrder.current.isCurrent(result.providerId, requestTokens.get(result.providerId)), + ); + setRecords((current) => mergeResults(current, currentResults)); + } + return results; + } finally { + if (generation.current === currentGeneration) setIsRefreshing(false); + } + }, + [cache, stableProviders], + ); + + const refreshProvider = useCallback( + async (providerId: string) => { + const provider = providerById.get(providerId); + if (!provider) throw new Error(`Unknown provider: ${providerId}`); + const requestToken = requestOrder.current.begin(providerId); + + setRecords((current) => ({ + ...current, + [providerId]: { + ...(current[providerId] ?? recordFromCache(providerId, cache)), + refreshState: "refreshing", + refreshError: undefined, + }, + })); + + const result = await refreshProviderStatus(provider, { + cache, + force: true, + isCurrent: () => requestOrder.current.isCurrent(providerId, requestToken), + }); + if (!requestOrder.current.isCurrent(providerId, requestToken)) return undefined; + setRecords((current) => ({ ...current, [providerId]: result })); + return result; + }, + [cache, providerById], + ); + + useEffect(() => { + setRecords(recordsFromCache(stableProviders, cache)); + void refreshAll(false); + + return () => abortController.current?.abort(); + }, [cache, stableProviders, refreshAll]); + + return { + records, + isRefreshing, + isInitialLoading: isRefreshing && !hasDisplayableSnapshot(records), + refreshAll, + refreshProvider, + }; +} + +function useStableProviders(providers: readonly ProviderDefinition[]): readonly ProviderDefinition[] { + const stableProviders = useRef(providers); + const changed = + stableProviders.current.length !== providers.length || + stableProviders.current.some((provider, index) => provider !== providers[index]); + + if (changed) stableProviders.current = providers; + return stableProviders.current; +} + +function recordsFromCache(providers: readonly ProviderDefinition[], cache: StatusCache): StatusRecords { + return Object.fromEntries(providers.map((provider) => [provider.id, recordFromCache(provider.id, cache)])); +} + +function providersNeedRefresh(providers: readonly ProviderDefinition[], cache: StatusCache): boolean { + return providers.some((provider) => getDataFreshness(cache.getSnapshot(provider.id)) !== "fresh"); +} + +function hasDisplayableSnapshot(records: StatusRecords): boolean { + return Object.values(records).some( + (record) => record.snapshot && record.freshness !== "expired" && record.freshness !== "unavailable", + ); +} + +function mergeResults(current: StatusRecords, results: readonly ProviderStatusRecord[]): StatusRecords { + const next = { ...current }; + for (const result of results) next[result.providerId] = result; + return next; +} diff --git a/extensions/ai-status-check/src/hooks/use-refreshable-provider-record.ts b/extensions/ai-status-check/src/hooks/use-refreshable-provider-record.ts new file mode 100644 index 00000000000..e2508affda3 --- /dev/null +++ b/extensions/ai-status-check/src/hooks/use-refreshable-provider-record.ts @@ -0,0 +1,21 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ProviderStatusRecord } from "../domain/types"; + +export type RefreshProvider = (providerId: string) => Promise; + +export function useRefreshableProviderRecord( + providerId: string, + record: ProviderStatusRecord, + refreshProvider: RefreshProvider, +) { + const [currentRecord, setCurrentRecord] = useState(record); + useEffect(() => setCurrentRecord(record), [record]); + + const refresh = useCallback(async () => { + setCurrentRecord((current) => ({ ...current, refreshState: "refreshing", refreshError: undefined })); + const result = await refreshProvider(providerId); + if (result) setCurrentRecord(result); + }, [providerId, refreshProvider]); + + return { record: currentRecord, refresh }; +} diff --git a/extensions/ai-status-check/src/providers/README.md b/extensions/ai-status-check/src/providers/README.md new file mode 100644 index 00000000000..342a8a61b2a --- /dev/null +++ b/extensions/ai-status-check/src/providers/README.md @@ -0,0 +1,311 @@ +# Provider Architecture + +This document describes how provider data is represented, fetched, normalized, cached, and extended in code. Product behavior and source acceptance criteria are maintained separately from these implementation details. + +## Layer boundaries + +- `catalog/` has exactly one file per provider. A catalog file declares identity, preferences, aliases, official URLs, icon, category, and selects an adapter. Catalog files never import payload-parsing details. +- `adapters/` owns each complete status-source integration: network orchestration, deterministic payload parsing, and conversion into `ProviderSnapshot`. Every adapter exports an `*AdapterConfig` interface and a `create*Adapter` factory; parsing functions stay in the same module so their ownership is explicit and their fixture tests remain deterministic. +- `factories/provider.ts` connects catalog metadata to any adapter, normalizes the status-page URL once, and passes through adapter-specific options. +- `utils/` contains source-independent mechanics such as HTTP, RSS tokenization, HTML-to-text cleanup, runtime-value guards, incident ordering, and status normalization. +- `domain/` defines the application vocabulary shared by providers, services, and UI. Domain types are not utilities. + +Provider names belong in `catalog/`; source-platform names belong in `adapters/`. A custom provider source may have its own bound adapter, such as `adapters/xai.ts`, while still reusing internal request orchestration from `adapters/page-and-feed.ts`. All catalog files use the same framework-neutral `createProvider` helper. + +React components never contain provider-specific parsing or source logic. + +## Contracts + +All adapters return the same normalized domain model: + +```ts +type Health = "operational" | "degraded" | "partial_outage" | "major_outage" | "maintenance" | "unknown"; + +type IncidentState = "investigating" | "identified" | "monitoring" | "resolved" | "scheduled" | "unknown"; + +interface ProviderSnapshot { + providerId: string; + health: Health; + statusText?: string; + components: ComponentStatus[]; + incidents: Incident[]; + fetchedAt: string; +} + +interface ComponentStatus { + id: string; + name: string; + group?: string; + health: Health; + statusText?: string; + url?: string; + history?: ComponentHistory; +} + +interface ComponentHistory { + windowDays: number; + basis: "availability" | "incidents"; + days: ComponentHistoryDay[]; + uptimePercent?: number; + uptimeText?: string; + monitoredSince?: string; +} + +interface Incident { + id: string; + title: string; + health: Health; + state: IncidentState; + stateText?: string; + impactText?: string; + startedAt?: string; + updatedAt?: string; + resolvedAt?: string; + affectedComponentIds: string[]; + updates: IncidentUpdate[]; + url?: string; +} +``` + +The provider boundary is deliberately small: + +```ts +interface ProviderAdapter { + fetch(signal: AbortSignal): Promise; + fetchComponentHistory?(componentId: string, signal: AbortSignal): Promise; +} + +interface ProviderDefinition { + id: string; + name: string; + aliases: string[]; + category: ProviderCategory; + preferenceKey: string; + icon: string; + statusPageUrl: string; + adapter: ProviderAdapter; +} +``` + +External payloads enter adapter parsing functions as `unknown` or source text. Those deterministic functions validate the fields they understand, retain readable source wording, and map only the small internal enums required for icons, ordering, and aggregation. UI code consumes normalized records rather than source payloads. + +Component history follows the same ownership rule. Adapters populate `ComponentStatus.history` during their normal source request when history is available in a shared response. A provider may instead implement `fetchComponentHistory` when the official site publishes history only on a component-specific page; the UI calls that path lazily for the selected component. Absence is represented by `undefined`, not by an all-green synthetic chart. + +## Data flow + +```text +official status source + -> source-family adapter + -> fetch source payloads + -> parse and validate them + -> combine normalized results + -> attach optional source-published component history + -> ProviderSnapshot + -> last-successful cache + -> ProviderStatusRecord + -> shared provider presentation +``` + +`ProviderSnapshot.health` is the adapter's behavioral classification. `statusText`, component `statusText`, incident `stateText`, and `impactText` preserve readable provider language. Presentation derives canonical labels for a small allowlist at render time, so changing a UI alias does not invalidate cached snapshots. + +`ProviderStatusRecord` keeps data freshness and refresh state separate from provider health: + +```ts +type DataFreshness = "fresh" | "stale" | "expired" | "unavailable"; +type RefreshState = "idle" | "refreshing" | "failed"; + +interface ProviderStatusRecord { + providerId: string; + snapshot?: ProviderSnapshot; + freshness: DataFreshness; + refreshState: RefreshState; + refreshError?: string; +} +``` + +## Catalog and framework configuration + +The catalog is organized by provider identity, not source vendor. The architecture test enforces exactly one exported provider definition per registered provider file. + +A conventional Statuspage provider needs metadata plus its shared adapter: + +```ts +export const claudeProvider = createProvider( + { + id: "claude", + name: "Claude", + aliases: ["Anthropic", "Claude API", "Claude Code"], + category: "model-providers", + preferenceKey: "showClaude", + icon: "provider-icons/claude.png", + statusPageUrl: "https://status.claude.com/", + }, + createStatuspageAdapter, +); +``` + +`createProvider` supplies the provider ID and normalized status-page URL to the adapter. Required framework options are type-checked, so adding a Flashcat provider requires a `pageId`, while conventional Statuspage and Incident.io providers need no additional options. + +Framework adapters own their conventional routes. Statuspage derives its summary, incident, and maintenance API routes. Incident.io derives its same-origin `proxy/` and `proxy//incidents` routes. Those are the same endpoints used by the rendered pages. + +Special cases remain owned by adapter modules rather than catalog branches. Statuspage and Incident.io accept narrow endpoint or filtering options. Mistral, OpenRouter, and xAI expose bound adapter factories that select their page parser internally while reusing the page-and-feed request flow. For example: + +```ts +export function createXaiAdapter(config: XaiAdapterConfig): ProviderAdapter { + return createPageAndFeedAdapter({ + ...config, + pageUrl: "https://status.x.ai/index.txt", + feedUrl: "https://status.x.ai/feed.xml", + parsePage: parseXaiStatusPage, + }); +} +``` + +Keep shared fetching, merging, sorting, health handling, and snapshot construction in reusable adapter functions, while each public adapter module owns the source-specific parser it selects. + +The provider-facing catalog name remains stable if its status vendor changes. + +## Implemented source families + +| Providers | Implementation | Data used | +| ----------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI, Cohere, Groq, Replicate, ElevenLabs, Fireworks AI, Stability AI | Incident.io adapter | Same-origin proxy summary, incident history, component impacts, exact uptime where enabled, and source monitoring dates | +| Claude, Cerebras, Baseten | Statuspage-compatible adapter | Published summary, components, incidents, scheduled maintenance, and rendered `uptimeData` | +| Together AI, Hugging Face | Better Stack adapter | `index.json` sections, resources, reports, updates, status history, and availability | +| Perplexity | Instatus adapter | Official `/v3/summary.json`, `/v3/components.json`, `/history.rss`, and rendered component uptime data | +| DeepSeek | Flashcat adapter | Components, active changes, incident history, component impacts, and component uptimes from the published page ID | +| Mistral AI, OpenRouter | Bound provider adapters | Provider-specific rendered-page parsing, component history and exact published uptime, plus shared incident/update history from RSS | +| xAI | xAI adapter | All current services from the page's Next.js RSC response, lazy component-page incident history, and shared incident/update history from RSS | +| Google AI Studio and Gemini | Gemini adapter | The public status RPC, its rendered-page component labels, and 90-day incident-derived component history | + +The catalog always retains the canonical official status page for user verification, even when the structured endpoint is hosted by the provider's status vendor. + +## Component history + +History is optional at the normalized boundary because some official pages publish only current operational state, and some publish history for only a subset of components. Rendering follows three rules: + +1. Reproduce the provider's published availability or incident calendar when the source exposes enough data. +2. Show `uptimePercent` only when the source publishes it or exposes exact duration data from which it can be calculated. Preserve provider-displayed precision in `uptimeText`; an incident-day chart alone is not treated as an uptime measurement. +3. Preserve monitoring gaps as `not_monitored`; do not render pre-monitoring dates as successful uptime. + +The shared renderer does not inspect provider IDs. It renders any normalized `ComponentHistory`, so partial support is handled uniformly: a component with history gets a chart, while a component without it gets current status and a clear no-history message. + +The Gemini RPC uses undocumented numeric enums. The parsing functions in `adapters/gemini.ts` map only values verified against the official page's rendered lifecycle labels and severity classes, preserve those labels as source text, and leave any new value unknown rather than applying a numeric range fallback. + +## Component groups + +The normalized model supports one optional group name per component. The UI builds its one-level hierarchy from this field. + +For Incident.io pages, `adapters/incidentio.ts` reads the source-published `structure` directly from the proxy JSON. Group entries become the shared model's one-level component groups; standalone entries remain ungrouped. Components absent from `affected_components` are operational, matching Incident.io's published current-status model. If a proxy response omits `structure`, the adapter keeps its published component catalog flat. + +Other adapters populate `group` only when their official source publishes group information. Catalog files do not invent groups. + +## Health and source wording + +Adapters retain two separate values: + +- A normalized `Health` or `IncidentState` for icons, ordering, grouping, and fallback aggregation. +- The source's readable wording for display. + +Unknown source strings map to `unknown` for behavioral classification rather than silently becoming operational. Their readable text remains available unchanged. + +`domain/status-presentation.ts` recognizes a deliberately small alias allowlist. Known operational aliases render consistently; unfamiliar wording is trimmed and preserved. Final presentation is computed at render time rather than stored in the cache. + +`deriveProviderHealth` follows these rules: + +1. A usable provider-published overall health is authoritative. +2. When no overall health is available, use the highest known active-incident or component severity. +3. Ignore `unknown` while known signals exist. +4. Treat scheduled incidents as maintenance. +5. Do not infer incident impact from lifecycle state. + +Severity ordering is: + +```text +major_outage > partial_outage > degraded > maintenance > operational +``` + +Scoped platform providers intentionally discard the platform-wide overall state and derive health only from their selected components and incidents. + +## Fetching and cache implementation + +`services/fetch-provider-statuses.ts` owns request policy: + +- Read cached snapshots before scheduling requests. +- Skip automatic requests for snapshots no more than 60 seconds old. +- Retain snapshots for up to 24 hours while revalidating. +- Run at most six provider requests concurrently. +- Apply an eight-second timeout per provider. +- Isolate failures per provider. +- Preserve the previous successful snapshot on failure. +- Let forced provider or global refresh bypass the freshness shortcut. + +`services/status-cache.ts` stores versioned, per-provider JSON snapshots through Raycast `Cache`. It performs lightweight runtime validation before returning cached values. + +`hooks/use-provider-statuses.ts` coordinates the current provider set, aborts superseded bulk refresh generations, commits bulk refresh results together, and exposes a single list-level loading state. Per-provider request tokens cover bulk, individual, and duplicate refreshes, so a late older response can update neither React state nor the persistent cache. + +## Adding a provider + +1. Confirm that the official source meets the product acceptance requirements. +2. Add one `catalog/.ts` file. +3. Reuse a source-family adapter through `createProvider` whenever possible. +4. Add the provider to `registry.ts` in its display order. +5. Add its manifest checkbox preference in matching category order. +6. Add a bundled icon under `assets/provider-icons/`. +7. Add or extend deterministic adapter fixture tests, including malformed payloads. +8. Run tests, type-checking, Raycast lint/build, and the opt-in live source check. + +Add a new adapter only when no existing source family fits. Adding a provider must not require UI, cache, grouping, action, or section-order changes. + +## Source layout + +```text +src/ + domain/ + derive-health.ts + freshness.ts + provider-view.ts + status-presentation.ts + types.ts + providers/ + README.md + registry.ts + provider-sections.ts + types.ts + catalog/ + .ts + adapters/ + betterstack.ts + flashcat.ts + gemini.ts + incidentio.ts + instatus.ts + mistral.ts + openrouter.ts + page-and-feed.ts + statuspage.ts + xai.ts + factories/ + provider.ts + utils/ + http.ts + incidents.ts + rss.ts + runtime-values.ts + status-normalization.ts + services/ + fetch-provider-statuses.ts + status-cache.ts + hooks/ + use-provider-statuses.ts +``` + +## Project conventions and verification + +- Use npm and retain `package-lock.json` for Raycast Store consistency. +- Keep provider icons local and require every `ProviderDefinition` to reference one. +- Prefer platform `fetch`, `Intl`, and narrow runtime guards over additional runtime dependencies. +- Keep deterministic tests network-independent. +- Cover source parsing, malformed input, health mapping, incident histories, grouping, caching, registry order, preferences, and icons. +- Use `npm run check:sources` as an opt-in compatibility check against current official sources. +- Run `npm test`, `npm run typecheck`, `npm run lint`, and `npm run build` before release. diff --git a/extensions/ai-status-check/src/providers/adapters/betterstack.ts b/extensions/ai-status-check/src/providers/adapters/betterstack.ts new file mode 100644 index 00000000000..9304a4dcfa3 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/betterstack.ts @@ -0,0 +1,193 @@ +import { deriveProviderHealth, highestHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { withoutTrailingSlash } from "../../utils/url"; +import { componentHistory, finitePercent, historyLevelFromHealth } from "../utils/component-history"; +import { fetchJson, type FetchJson } from "../utils/http"; +import { + optionalRecord, + optionalRecordArray, + optionalString, + requireRecord, + type JsonRecord, +} from "../utils/runtime-values"; +import { mapFlexibleHealth, mapFlexibleIncidentState } from "../utils/status-normalization"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; + +export interface BetterStackAdapterConfig extends ProviderAdapterConfig { + indexUrl?: string; + fetchJson?: FetchJson; +} + +export function createBetterStackAdapter(config: BetterStackAdapterConfig): ProviderAdapter { + const request = config.fetchJson ?? fetchJson; + const now = config.now ?? (() => new Date()); + + return { + async fetch(signal) { + const statusPageUrl = withoutTrailingSlash(config.statusPageUrl); + const parsed = parseBetterStack(await request(config.indexUrl ?? `${statusPageUrl}/index.json`, signal)); + return { + providerId: config.providerId, + health: deriveProviderHealth(parsed.reportedHealth, parsed.components, parsed.incidents), + statusText: parsed.statusText, + components: parsed.components, + incidents: parsed.incidents.map((incident) => ({ + ...incident, + url: `${statusPageUrl}/incident/${encodeURIComponent(incident.id)}`, + })), + fetchedAt: now().toISOString(), + }; + }, + }; +} + +export interface ParsedBetterStackStatus { + reportedHealth: Health; + statusText?: string; + components: ComponentStatus[]; + incidents: Incident[]; +} + +export function parseBetterStack(payload: unknown): ParsedBetterStackStatus { + const root = requireRecord(payload, "Better Stack status page"); + const data = requireRecord(root.data, "Better Stack status page data"); + const pageAttributes = requireRecord(data.attributes, "Better Stack status page attributes"); + const included = optionalRecordArray(root.included); + const sections = new Map( + included + .filter((item) => item.type === "status_page_section") + .map((item) => [optionalString(item.id), optionalString(optionalRecord(item.attributes)?.name)] as const) + .filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])), + ); + const components = included + .filter((item) => item.type === "status_page_resource") + .map((item) => parseResource(item, sections)) + .filter((component): component is ComponentStatus => component !== undefined); + const updates = new Map( + included + .filter((item) => item.type === "status_update") + .map((item) => [optionalString(item.id), item] as const) + .filter((entry): entry is readonly [string, JsonRecord] => Boolean(entry[0])), + ); + const incidents = included + .filter((item) => item.type === "status_report") + .map((item) => parseReport(item, updates)) + .filter((incident): incident is Incident => incident !== undefined) + .sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); + + const statusText = optionalString(pageAttributes.aggregate_state); + return { + reportedHealth: mapFlexibleHealth(statusText), + statusText, + components, + incidents, + }; +} + +function parseResource(resource: JsonRecord, sections: ReadonlyMap): ComponentStatus | undefined { + const id = optionalString(resource.id); + const attributes = optionalRecord(resource.attributes); + const name = optionalString(attributes?.public_name); + const statusText = optionalString(attributes?.status); + if (!id || !name || !statusText) return undefined; + + const sectionId = attributes?.status_page_section_id; + const historyDays = optionalRecordArray(attributes?.status_history).flatMap((day) => { + const date = optionalString(day.day)?.slice(0, 10); + const sourceStatus = optionalString(day.status); + if (!date || !sourceStatus) return []; + const level = + sourceStatus === "not_monitored" + ? ("not_monitored" as const) + : sourceStatus === "downtime" + ? ("major_outage" as const) + : sourceStatus === "maintenance" + ? ("maintenance" as const) + : historyLevelFromHealth(mapFlexibleHealth(sourceStatus)); + return [{ date, level }]; + }); + const measuredAvailability = + typeof attributes?.availability === "number" ? finitePercent(attributes.availability * 100) : undefined; + const availability = measuredAvailability === undefined ? undefined : Number(measuredAvailability.toFixed(3)); + const monitoredSince = historyDays.find((day) => day.level !== "not_monitored")?.date; + const history = componentHistory("availability", historyDays, { + uptimePercent: availability, + uptimeText: availability === undefined ? undefined : availability === 100 ? "100%" : `${availability.toFixed(3)}%`, + monitoredSince, + }); + return { + id, + name, + health: mapFlexibleHealth(statusText), + statusText, + group: typeof sectionId === "number" || typeof sectionId === "string" ? sections.get(String(sectionId)) : undefined, + ...(history ? { history } : {}), + }; +} + +function parseReport(report: JsonRecord, updatesById: ReadonlyMap): Incident | undefined { + const id = optionalString(report.id); + const attributes = optionalRecord(report.attributes); + const title = optionalString(attributes?.title); + if (!id || !attributes || !title) return undefined; + + const relationshipUpdates = optionalRecordArray( + optionalRecord(optionalRecord(report.relationships)?.status_updates)?.data, + ); + const updates = relationshipUpdates + .map((relationship) => optionalString(relationship.id)) + .map((updateId) => (updateId ? updatesById.get(updateId) : undefined)) + .map(parseUpdate) + .filter((update): update is IncidentUpdate => update !== undefined) + .sort((left, right) => parseTimestamp(left.createdAt) - parseTimestamp(right.createdAt)); + const lastUpdate = updates.at(-1); + const aggregateState = optionalString(attributes.aggregate_state); + const reportType = optionalString(attributes.report_type); + const state = + aggregateState === "resolved" + ? "resolved" + : reportType === "maintenance" + ? "scheduled" + : (lastUpdate?.state ?? mapFlexibleIncidentState(aggregateState)); + const affected = optionalRecordArray(attributes.affected_resources); + + return { + id, + title, + state, + stateText: aggregateState ?? lastUpdate?.stateText ?? (reportType === "maintenance" ? reportType : undefined), + health: state === "scheduled" ? "maintenance" : highestAffectedHealth(affected), + startedAt: optionalString(attributes.starts_at), + resolvedAt: state === "resolved" ? (optionalString(attributes.ends_at) ?? lastUpdate?.createdAt) : undefined, + updatedAt: lastUpdate?.createdAt, + affectedComponentIds: affected + .map((item) => optionalString(item.status_page_resource_id)) + .filter((componentId): componentId is string => Boolean(componentId)), + updates, + }; +} + +function parseUpdate(update: JsonRecord | undefined): IncidentUpdate | undefined { + if (!update) return undefined; + const id = optionalString(update.id); + const attributes = optionalRecord(update.attributes); + const body = optionalString(attributes?.message); + const createdAt = optionalString(attributes?.published_at); + if (!id || !body || !createdAt) return undefined; + const updateHealth = highestAffectedHealth(optionalRecordArray(attributes?.affected_resources)); + const stateText = optionalString(attributes?.status); + + return { + id, + body, + createdAt, + state: updateHealth === "operational" ? "resolved" : mapFlexibleIncidentState(stateText), + stateText, + }; +} + +function highestAffectedHealth(affected: readonly JsonRecord[]): Health { + const health = highestHealth(affected.map((item) => mapFlexibleHealth(optionalString(item.status)))); + return health === "unknown" ? "degraded" : health; +} diff --git a/extensions/ai-status-check/src/providers/adapters/flashcat.ts b/extensions/ai-status-check/src/providers/adapters/flashcat.ts new file mode 100644 index 00000000000..9f6e38265f0 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/flashcat.ts @@ -0,0 +1,269 @@ +import { deriveProviderHealth, highestHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentState, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { withoutTrailingSlash } from "../../utils/url"; +import { + applyHistoryRange, + componentHistory, + finitePercent, + historyLevelFromHealth, + historyWindow, + markBeforeMonitoredSince, +} from "../utils/component-history"; +import { fetchJson, type FetchJson } from "../utils/http"; +import { fetchOptionalEnrichment } from "../utils/optional-enrichment"; +import { + optionalRecord, + optionalRecordArray, + optionalString, + requireRecord, + type JsonRecord, +} from "../utils/runtime-values"; +import { mapFlexibleHealth, mapFlexibleIncidentState } from "../utils/status-normalization"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; + +export interface FlashcatAdapterConfig extends ProviderAdapterConfig { + pageId: string; + apiBaseUrl?: string; + fetchJson?: FetchJson; +} + +export function createFlashcatAdapter(config: FlashcatAdapterConfig): ProviderAdapter { + const request = config.fetchJson ?? fetchJson; + const now = config.now ?? (() => new Date()); + + return { + async fetch(signal) { + const fetchedAt = now(); + const endAt = Math.floor(fetchedAt.getTime() / 1000); + const startAt = endAt - 90 * 86_400; + const apiBaseUrl = config.apiBaseUrl ?? "https://statuspage.flashcat.cloud/api/status-page"; + const sourceBase = `${apiBaseUrl.replace(/\/+$/, "")}/${encodeURIComponent(config.pageId)}`; + const [currentPayload, historyPayload, structurePayload] = await Promise.all([ + request(`${sourceBase}/summary/active`, signal), + request(`${sourceBase}/change/list?start_at_seconds=${startAt}&end_at_seconds=${endAt}`, signal), + fetchOptionalEnrichment(signal, (historySignal) => + request( + `${sourceBase}/summary/structure?start_at_from_seconds=${startAt}&start_at_to_seconds=${endAt}`, + historySignal, + ), + ), + ]); + const parsed = parseFlashcatStatus( + currentPayload, + historyPayload, + config.statusPageUrl, + structurePayload, + fetchedAt, + ); + const health = deriveProviderHealth(parsed.reportedHealth, parsed.components, parsed.incidents); + + return { + providerId: config.providerId, + health, + components: parsed.components, + incidents: parsed.incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +export function parseFlashcatStatus( + currentPayload: unknown, + historyPayload: unknown, + statusPageUrl: string, + structurePayload?: unknown, + now = new Date(), +): { reportedHealth: Health; components: ComponentStatus[]; incidents: Incident[] } { + const currentRoot = requireRecord(currentPayload, "Flashcat current response"); + const current = requireRecord(currentRoot.data, "Flashcat current status"); + const historyRoot = requireRecord(historyPayload, "Flashcat history response"); + const history = requireRecord(historyRoot.data, "Flashcat incident history"); + const page = requireRecord(current.page, "Flashcat status page"); + const sections = new Map( + optionalRecordArray(page.sections) + .map((section) => [optionalString(section.section_id), optionalString(section.name)] as const) + .filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])), + ); + const activeChanges = optionalRecordArray(current.active_changes); + const currentStatuses = activeComponentStatuses(activeChanges); + const histories = parseFlashcatComponentHistories(structurePayload, now); + const components = optionalRecordArray(page.components) + .map((component) => parseComponent(component, sections, currentStatuses)) + .filter((component): component is ComponentStatus => component !== undefined) + .map((component) => { + const history = histories.get(component.id); + return history ? { ...component, history } : component; + }); + if (components.length === 0) throw new Error("Flashcat status page contained no components"); + + const incidents = [ + ...new Map( + [...optionalRecordArray(history.items), ...activeChanges] + .map((change) => parseChange(change, statusPageUrl)) + .filter((incident): incident is Incident => incident !== undefined) + .map((incident) => [incident.id, incident]), + ).values(), + ].sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); + + return { + reportedHealth: activeChanges.length === 0 ? "operational" : "unknown", + components, + incidents, + }; +} + +export function parseFlashcatComponentHistories(payload: unknown, now = new Date()) { + const root = optionalRecord(payload); + const data = optionalRecord(root?.data); + if (!data) return new Map(); + const impacts = optionalRecordArray(data.component_impacts); + const result = new Map(); + + for (const uptime of optionalRecordArray(data.component_uptimes)) { + const componentId = optionalString(uptime.component_id); + if (!componentId) continue; + const days = historyWindow(90, now); + for (const impact of impacts) { + if (optionalString(impact.component_id) !== componentId) continue; + const startAt = secondsIso(impact.start_at_seconds); + const endAt = secondsIso(impact.end_at_seconds) ?? now; + const status = optionalString(impact.status); + if (!startAt || !status) continue; + applyHistoryRange(days, startAt, endAt, historyLevelFromHealth(mapFlexibleHealth(status))); + } + const monitoredSince = secondsIso(uptime.available_since_seconds); + markBeforeMonitoredSince(days, monitoredSince); + const uptimePercent = finitePercent(uptime.uptime); + const history = componentHistory("availability", days, { + monitoredSince, + uptimePercent, + uptimeText: uptimePercent === undefined ? undefined : `${uptimePercent.toFixed(2)}%`, + }); + if (history) result.set(componentId, history); + } + return result; +} + +interface ActiveComponentStatus { + health: Health; + statusText: string; +} + +function parseComponent( + component: JsonRecord, + sections: ReadonlyMap, + currentStatuses: ReadonlyMap, +): ComponentStatus | undefined { + const id = optionalString(component.component_id); + const name = optionalString(component.name); + if (!id || !name) return undefined; + const currentStatus = currentStatuses.get(id); + const sectionId = optionalString(component.section_id); + return { + id, + name, + group: sectionId ? sections.get(sectionId) : undefined, + health: currentStatus?.health ?? "operational", + statusText: currentStatus?.statusText, + }; +} + +function activeComponentStatuses(changes: readonly JsonRecord[]): Map { + const result = new Map(); + for (const change of changes) { + for (const affected of optionalRecordArray(change.affected_components)) { + setHigher(result, optionalString(affected.component_id), optionalString(affected.status)); + } + for (const update of optionalRecordArray(change.updates)) { + for (const componentChange of optionalRecordArray(update.component_changes)) { + setHigher(result, optionalString(componentChange.component_id), optionalString(componentChange.status)); + } + } + } + return result; +} + +function parseChange(change: JsonRecord, statusPageUrl: string): Incident | undefined { + const idValue = change.change_id; + const id = typeof idValue === "number" || typeof idValue === "string" ? String(idValue) : undefined; + const title = optionalString(change.title); + if (!id || !title) return undefined; + const updates = optionalRecordArray(change.updates) + .map((update, index) => parseUpdate(id, update, index)) + .filter((update): update is IncidentUpdate => update !== undefined); + const type = optionalString(change.type); + const stateText = optionalString(change.status); + const closedAt = secondsIso(change.close_at_seconds); + const state = changeState(stateText, type, closedAt, updates.at(-1)?.state); + const affected = optionalRecordArray(change.affected_components); + const allHealth = [ + ...affected.map((component) => mapFlexibleHealth(optionalString(component.status))), + ...optionalRecordArray(change.updates).flatMap((update) => + optionalRecordArray(update.component_changes).map((component) => + mapFlexibleHealth(optionalString(component.status)), + ), + ), + ]; + const peakHealth = highestHealth(allHealth); + + return { + id, + title, + state, + stateText: stateText ?? updates.at(-1)?.stateText, + health: + type === "maintenance" + ? "maintenance" + : peakHealth === "unknown" || peakHealth === "operational" + ? "degraded" + : peakHealth, + startedAt: secondsIso(change.start_at_seconds) ?? updates[0]?.createdAt, + updatedAt: updates.at(-1)?.createdAt, + resolvedAt: state === "resolved" ? (closedAt ?? updates.at(-1)?.createdAt) : undefined, + affectedComponentIds: affected + .map((component) => optionalString(component.component_id)) + .filter((componentId): componentId is string => Boolean(componentId)), + updates, + url: `${withoutTrailingSlash(statusPageUrl)}/incidents/${encodeURIComponent(id)}`, + }; +} + +function parseUpdate(changeId: string, update: JsonRecord, index: number): IncidentUpdate | undefined { + const body = optionalString(update.description); + const createdAt = secondsIso(update.at_seconds); + if (!body || !createdAt) return undefined; + const updateId = optionalString(update.update_id) ?? `${changeId}-${index}`; + const stateText = optionalString(update.status); + return { id: updateId, body, createdAt, state: mapFlexibleIncidentState(stateText), stateText }; +} + +function changeState( + statusText: string | undefined, + type: string | undefined, + closedAt: string | undefined, + latestState: IncidentState | undefined, +): IncidentState { + const mapped = mapFlexibleIncidentState(statusText); + if (mapped !== "unknown") return mapped; + if (closedAt) return "resolved"; + if (type === "maintenance") return "scheduled"; + return latestState === "unknown" || latestState === undefined ? "investigating" : latestState; +} + +function secondsIso(value: unknown): string | undefined { + const seconds = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; + return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : undefined; +} + +function setHigher( + target: Map, + id: string | undefined, + statusText: string | undefined, +): void { + const health = mapFlexibleHealth(statusText); + if (!id || !statusText || health === "unknown" || health === "operational") return; + const current = target.get(id); + if (!current || highestHealth([current.health, health]) === health) target.set(id, { health, statusText }); +} diff --git a/extensions/ai-status-check/src/providers/adapters/gemini.ts b/extensions/ai-status-check/src/providers/adapters/gemini.ts new file mode 100644 index 00000000000..97485a3ede6 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/gemini.ts @@ -0,0 +1,206 @@ +import { deriveProviderHealth, highestHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentState, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { applyHistoryRange, componentHistory, historyLevelFromHealth, historyWindow } from "../utils/component-history"; +import { fetchJson, fetchText, type FetchJson, type FetchText } from "../utils/http"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; + +const COMPONENTS = [ + { id: "1", name: "API" }, + { id: "2", name: "Multimodal Live API" }, + { id: "3", name: "Google AI Studio" }, +] as const; + +export interface GeminiAdapterConfig extends ProviderAdapterConfig { + rpcPath?: string; + requestInit?: RequestInit; + fetchText?: FetchText; + fetchJson?: FetchJson; +} + +export function createGeminiAdapter(config: GeminiAdapterConfig): ProviderAdapter { + const requestPage = config.fetchText ?? fetchText; + const requestRpc = config.fetchJson ?? fetchJson; + const now = config.now ?? (() => new Date()); + + return { + async fetch(signal) { + const page = await requestPage(config.statusPageUrl, signal); + const { apiKey, rpcBase } = parseGeminiBootConfig(page); + const payload = await requestRpc( + `${rpcBase}${config.rpcPath ?? "/$rpc/google.internal.alkali.applications.makersuite.v1.MakerSuiteService/ListIncidentsHistory"}`, + signal, + { + ...config.requestInit, + method: "POST", + headers: { + "Content-Type": "application/json+protobuf", + "X-Goog-Api-Key": apiKey, + "X-User-Agent": "grpc-web-javascript/0.1", + Origin: "https://aistudio.google.com", + Referer: "https://aistudio.google.com/", + ...config.requestInit?.headers, + }, + body: config.requestInit?.body ?? "[]", + }, + ); + const incidents = parseGeminiIncidents(payload, config.statusPageUrl); + const fetchedAt = now(); + const components = geminiComponents(incidents, fetchedAt); + const reportedHealth: Health = incidents.some((incident) => incident.state !== "resolved") + ? "unknown" + : "operational"; + + return { + providerId: config.providerId, + health: deriveProviderHealth(reportedHealth, components, incidents), + components, + incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +export function parseGeminiBootConfig(html: string): { apiKey: string; rpcBase: string } { + const apiKey = /"WIu0Nc":"([^"]+)"/.exec(html)?.[1]; + const rpcBase = /"CoJqbf":"([^"]+)"/.exec(html)?.[1]; + if (!apiKey || !rpcBase) throw new Error("Gemini status page boot configuration was missing"); + return { apiKey, rpcBase }; +} + +export function parseGeminiIncidents(payload: unknown, statusPageUrl: string): Incident[] { + const records = findIncidentRecords(payload); + if (!records) throw new Error("Gemini status response was malformed"); + + return records + .map((record) => parseIncident(record, statusPageUrl)) + .filter((incident): incident is Incident => incident !== undefined) + .sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); +} + +export function geminiComponents(incidents: readonly Incident[], now = new Date()): ComponentStatus[] { + return COMPONENTS.map((component) => { + const active = incidents.filter( + (incident) => incident.state !== "resolved" && incident.affectedComponentIds.includes(component.id), + ); + const days = historyWindow(90, now); + for (const incident of incidents) { + if (!incident.affectedComponentIds.includes(component.id) || !incident.startedAt) continue; + applyHistoryRange( + days, + incident.startedAt, + incident.state === "resolved" ? (incident.resolvedAt ?? incident.updatedAt ?? now) : now, + historyLevelFromHealth(incident.health), + ); + } + return { + ...component, + health: highestHealth(["operational", ...active.map((incident) => incident.health)]), + history: componentHistory("incidents", days), + }; + }); +} + +function parseIncident(record: readonly unknown[], statusPageUrl: string): Incident | undefined { + const id = typeof record[0] === "string" ? record[0] : undefined; + const title = typeof record[1] === "string" ? record[1] : undefined; + const updateRecords = Array.isArray(record[3]) ? record[3] : []; + if (!id || !title) return undefined; + + const updates = updateRecords + .map((value, index) => (Array.isArray(value) ? parseUpdate(id, value, index) : undefined)) + .filter((update): update is IncidentUpdate => update !== undefined); + const state = updates.at(-1)?.state ?? "unknown"; + const affectedCodes = Array.isArray(record[5]) ? record[5] : []; + const affectedComponentIds = affectedCodes + .map((value) => (typeof value === "number" || typeof value === "string" ? String(value) : undefined)) + .filter((value): value is string => Boolean(value)); + const severity = geminiSeverity(typeof record[2] === "number" ? record[2] : undefined); + + return { + id, + title, + state, + stateText: updates.at(-1)?.stateText, + health: severity.health, + startedAt: updates[0]?.createdAt, + updatedAt: updates.at(-1)?.createdAt, + resolvedAt: state === "resolved" ? updates.at(-1)?.createdAt : undefined, + affectedComponentIds, + updates, + url: statusPageUrl, + }; +} + +function parseUpdate(incidentId: string, record: readonly unknown[], index: number): IncidentUpdate | undefined { + const stateCode = typeof record[0] === "number" ? record[0] : undefined; + const unixContainer = Array.isArray(record[2]) ? record[2] : []; + const unixValue = unixContainer[0]; + const body = typeof record[3] === "string" ? record[3] : undefined; + if (stateCode === undefined || !body) return undefined; + const createdAt = + unixDate(unixValue) ?? (typeof record[1] === "string" ? new Date(`${record[1]}Z`).toISOString() : undefined); + if (!createdAt) return undefined; + + const status = geminiUpdateStatus(stateCode); + return { + id: `${incidentId}-${index}`, + body, + createdAt, + state: status.state, + stateText: status.label, + }; +} + +// The public RPC is undocumented. These explicit values are verified against the +// labels and severity classes rendered by https://aistudio.google.com/status. +// Unrecognized values deliberately stay unknown instead of inheriting a range fallback. +function geminiUpdateStatus(value: number): { state: IncidentState; label?: string } { + switch (value) { + case 1: + return { state: "investigating", label: "Detected" }; + case 2: + return { state: "identified", label: "Identified" }; + case 3: + return { state: "monitoring", label: "Mitigated" }; + case 4: + return { state: "resolved", label: "Resolved" }; + case 5: + return { state: "monitoring", label: "Update" }; + default: + return { state: "unknown" }; + } +} + +function geminiSeverity(value: number | undefined): { health: Health } { + switch (value) { + case 1: + return { health: "degraded" }; + case 2: + return { health: "major_outage" }; + default: + return { health: "unknown" }; + } +} + +function findIncidentRecords(value: unknown): readonly (readonly unknown[])[] | undefined { + if (!Array.isArray(value)) return undefined; + if ( + value.length > 0 && + value.every((item) => Array.isArray(item) && typeof item[0] === "string" && typeof item[1] === "string") + ) { + return value as readonly (readonly unknown[])[]; + } + for (const item of value) { + const found = findIncidentRecords(item); + if (found) return found; + } + return undefined; +} + +function unixDate(value: unknown): string | undefined { + if (typeof value !== "string" && typeof value !== "number") return undefined; + const seconds = Number(value); + return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : undefined; +} diff --git a/extensions/ai-status-check/src/providers/adapters/incidentio.ts b/extensions/ai-status-check/src/providers/adapters/incidentio.ts new file mode 100644 index 00000000000..8d87cfef16b --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/incidentio.ts @@ -0,0 +1,404 @@ +import { highestHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { withTrailingSlash } from "../../utils/url"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; +import { fetchJson, type FetchJson } from "../utils/http"; +import { mergeIncidents } from "../utils/incidents"; +import { fetchOptionalEnrichment } from "../utils/optional-enrichment"; +import { + applyHistoryRange, + componentHistory, + finitePercent, + historyLevelFromHealth, + historyWindow, + markBeforeMonitoredSince, + publishedPercentText, +} from "../utils/component-history"; +import { + optionalRecord, + optionalRecordArray, + optionalString, + requireRecord, + type JsonRecord, +} from "../utils/runtime-values"; +import { mapFlexibleHealth } from "../utils/status-normalization"; + +export interface IncidentIoAdapterConfig extends ProviderAdapterConfig { + /** Override only when a page's Incident.io proxy route differs from the standard host-derived route. */ + proxyUrl?: string; + /** Override only when incident history is exposed at a non-standard route. */ + incidentsUrl?: string; + /** Override only when component history is exposed at a non-standard route. */ + componentImpactsUrl?: string; + fetchJson?: FetchJson; +} + +export function createIncidentIoAdapter(config: IncidentIoAdapterConfig): ProviderAdapter { + const request = config.fetchJson ?? fetchJson; + const now = config.now ?? (() => new Date()); + const statusPageUrl = new URL(config.statusPageUrl); + const proxyUrl = config.proxyUrl ?? new URL(`proxy/${statusPageUrl.host}`, statusPageUrl).toString(); + const incidentsUrl = config.incidentsUrl ?? `${proxyUrl}/incidents`; + + return { + async fetch(signal) { + const [proxyPayload, incidentsPayload] = await Promise.all([ + request(proxyUrl, signal), + request(incidentsUrl, signal), + ]); + const summary = parseIncidentIoSummary(proxyPayload); + const fetchedAt = now(); + const impactsPayload = await fetchOptionalEnrichment(signal, (historySignal) => + request( + config.componentImpactsUrl ?? componentImpactsUrl(proxyUrl, fetchedAt, summary.historyWindowDays), + historySignal, + ), + ); + const incidents = mergeIncidents( + parseIncidentIoIncidents(incidentsPayload, config.statusPageUrl), + summary.incidents, + ); + + return { + providerId: config.providerId, + health: summary.reportedHealth, + statusText: summary.statusText, + components: attachComponentHistory(summary, impactsPayload, fetchedAt), + incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +export interface ParsedIncidentIoSummary { + reportedHealth: Health; + statusText?: string; + components: ComponentStatus[]; + incidents: Incident[]; + historyWindowDays: number; + displayUptimeMode?: string; + historyVisibility: ReadonlyMap; +} + +export function parseIncidentIoSummary(payload: unknown): ParsedIncidentIoSummary { + const root = requireRecord(payload, "Incident.io proxy"); + const summary = requireRecord(root.summary, "Incident.io proxy summary"); + const sourceComponents = optionalRecordArray(summary.components); + if (sourceComponents.length === 0) throw new Error("Incident.io proxy summary contained no components"); + + const affectedStatuses = parseAffectedStatuses(summary.affected_components); + const historyVisibility = parseHistoryVisibility(summary.structure); + const components = parseStructure(summary.structure, sourceComponents, affectedStatuses); + const incidents = [ + ...parseIncidentIoNoticeList(summary.ongoing_incidents, "incident", undefined), + ...parseIncidentIoNoticeList(summary.scheduled_maintenances, "maintenance", undefined), + ]; + const issueHealth = highestHealth( + incidents + .filter((incident) => incident.state !== "resolved" && incident.state !== "scheduled") + .map((incident) => incident.health), + ); + const hasIssue = affectedStatuses.size > 0 || issueHealth !== "unknown"; + + return { + reportedHealth: hasIssue + ? highestHealth([reportedHealth(affectedStatuses), issueHealth === "operational" ? "degraded" : issueHealth]) + : "operational", + statusText: hasIssue ? "We're currently experiencing issues" : undefined, + components, + incidents, + historyWindowDays: positiveInteger(summary.history_window_days) ?? 90, + displayUptimeMode: optionalString(summary.display_uptime_mode), + historyVisibility, + }; +} + +function componentImpactsUrl(proxyUrl: string, now: Date, windowDays: number): string { + const end = new Date(now); + end.setUTCHours(23, 59, 59, 999); + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - windowDays + 1); + start.setUTCHours(0, 0, 0, 0); + const parameters = new URLSearchParams({ start_at: start.toISOString(), end_at: end.toISOString() }); + return `${proxyUrl}/component_impacts?${parameters.toString()}`; +} + +function attachComponentHistory(summary: ParsedIncidentIoSummary, payload: unknown, now: Date): ComponentStatus[] { + const root = optionalRecord(payload); + if (!root || !Array.isArray(root.component_impacts) || !Array.isArray(root.component_uptimes)) { + return summary.components; + } + + const impacts = optionalRecordArray(root.component_impacts); + const uptimes = new Map( + optionalRecordArray(root.component_uptimes) + .map((entry) => [optionalString(entry.component_id), entry] as const) + .filter((entry): entry is readonly [string, JsonRecord] => Boolean(entry[0])), + ); + + return summary.components.map((component) => { + const visibility = summary.historyVisibility.get(component.id); + if (!visibility?.display) return component; + + const days = historyWindow(summary.historyWindowDays, now); + for (const impact of impacts) { + if (optionalString(impact.component_id) !== component.id) continue; + const startAt = optionalString(impact.start_at); + const endAt = optionalString(impact.end_at) ?? now; + const status = optionalString(impact.status); + if (!startAt || !status) continue; + applyHistoryRange(days, startAt, endAt, historyLevelFromHealth(mapFlexibleHealth(status))); + } + + const uptime = uptimes.get(component.id); + const monitoredSince = optionalString(uptime?.data_available_since) ?? visibility.monitoredSince; + markBeforeMonitoredSince(days, monitoredSince); + const showPercentage = summary.displayUptimeMode === "chart_and_percentage"; + const uptimePercent = showPercentage ? finitePercent(uptime?.uptime) : undefined; + const uptimeText = + uptimePercent === undefined ? undefined : uptimePercent === 100 ? "100%" : publishedPercentText(uptime?.uptime); + const history = componentHistory("availability", days, { + monitoredSince, + uptimePercent, + uptimeText, + }); + return history ? { ...component, history } : component; + }); +} + +function parseHistoryVisibility(value: unknown): ReadonlyMap { + const result = new Map(); + const structure = optionalRecord(value); + for (const item of optionalRecordArray(structure?.items)) { + const group = optionalRecord(item.group); + const candidates = group ? optionalRecordArray(group.components) : [optionalRecord(item.component)].filter(Boolean); + for (const component of candidates) { + const id = component ? componentId(component) : undefined; + if (!id || !component) continue; + result.set(id, { + display: component.display_uptime === true, + monitoredSince: optionalString(component.data_available_since), + }); + } + } + return result; +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +export function parseIncidentIoIncidents(payload: unknown, statusPageUrl: string): Incident[] { + const root = requireRecord(payload, "Incident.io incident history"); + return parseIncidentIoNoticeList(root.incidents, "incident", statusPageUrl).sort( + (left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt), + ); +} + +function parseAffectedStatuses(value: unknown): Map { + const statuses = new Map(); + + for (const component of optionalRecordArray(value)) { + const id = optionalString(component.component_id) ?? optionalString(component.id); + const status = optionalString(component.component_status) ?? optionalString(component.status); + if (id && status) statuses.set(id, status); + } + + return statuses; +} + +function parseStructure( + value: unknown, + sourceComponents: readonly JsonRecord[], + affectedStatuses: ReadonlyMap, +): ComponentStatus[] { + const structure = optionalRecord(value); + if (!structure) { + return sourceComponents + .map((component) => parseComponent(component, affectedStatuses)) + .filter((component): component is ComponentStatus => component !== undefined); + } + + const items = optionalRecordArray(structure.items); + const components: ComponentStatus[] = []; + + for (const item of items) { + const group = optionalRecord(item.group); + if (group) { + const groupName = optionalString(group.name); + for (const component of optionalRecordArray(group.components)) { + if (group.hidden === true || !groupName) continue; + + const parsed = parseComponent(component, affectedStatuses, groupName); + if (parsed) components.push(parsed); + } + continue; + } + + const component = optionalRecord(item.component); + if (!component) continue; + + const parsed = parseComponent(component, affectedStatuses); + if (parsed) components.push(parsed); + } + + return components; +} + +function parseComponent( + component: JsonRecord, + affectedStatuses: ReadonlyMap, + group?: string, +): ComponentStatus | undefined { + if (component.hidden === true) return undefined; + + const id = componentId(component); + const name = optionalString(component.name); + if (!id || !name) return undefined; + + const statusText = affectedStatuses.get(id); + return { + id, + name, + group, + health: statusText ? mapFlexibleHealth(statusText) : "operational", + statusText, + }; +} + +function componentId(component: JsonRecord): string | undefined { + return optionalString(component.component_id) ?? optionalString(component.id); +} + +function reportedHealth(affectedStatuses: ReadonlyMap): Health { + if (affectedStatuses.size === 0) return "operational"; + return highestHealth([...affectedStatuses.values()].map(mapFlexibleHealth)); +} + +function parseIncidentIoNoticeList( + value: unknown, + fallbackType: "incident" | "maintenance", + statusPageUrl: string | undefined, +): Incident[] { + return optionalRecordArray(value) + .map((notice) => parseIncidentIoNotice(notice, fallbackType, statusPageUrl)) + .filter((incident): incident is Incident => incident !== undefined); +} + +function parseIncidentIoNotice( + notice: JsonRecord, + fallbackType: "incident" | "maintenance", + statusPageUrl: string | undefined, +): Incident | undefined { + const id = optionalString(notice.id); + const title = optionalString(notice.name); + if (!id || !title) return undefined; + + const type = optionalString(notice.type) ?? fallbackType; + const stateText = optionalString(notice.status); + const updates = optionalRecordArray(notice.updates) + .map(parseIncidentIoUpdate) + .filter((update): update is IncidentUpdate => update !== undefined); + const latestUpdate = updates.at(-1); + const sourceState = mapIncidentIoState(stateText ?? latestUpdate?.stateText); + const state = type === "maintenance" && sourceState !== "resolved" ? "scheduled" : sourceState; + const affectedComponentIds = new Set(); + const currentHealthValues: Health[] = []; + const peakHealthValues: Health[] = []; + + for (const component of optionalRecordArray(notice.affected_components)) { + const componentId = optionalString(component.component_id) ?? optionalString(component.id); + if (componentId) affectedComponentIds.add(componentId); + const currentStatus = optionalString(component.current_status); + const peakStatus = optionalString(component.status); + if (currentStatus) currentHealthValues.push(mapFlexibleHealth(currentStatus)); + if (peakStatus) peakHealthValues.push(mapFlexibleHealth(peakStatus)); + } + + const sourceUpdates = optionalRecordArray(notice.updates); + for (const update of sourceUpdates) { + for (const component of optionalRecordArray(update.component_statuses)) { + const componentId = optionalString(component.component_id) ?? optionalString(component.id); + if (componentId) affectedComponentIds.add(componentId); + const status = optionalString(component.status); + if (status) peakHealthValues.push(mapFlexibleHealth(status)); + } + } + + if (currentHealthValues.length === 0) { + for (const component of optionalRecordArray(sourceUpdates.at(-1)?.component_statuses)) { + const status = optionalString(component.status); + if (status) currentHealthValues.push(mapFlexibleHealth(status)); + } + } + + for (const summary of optionalRecordArray(notice.status_summaries)) { + const status = optionalString(summary.worst_component_status); + if (status) peakHealthValues.push(mapFlexibleHealth(status)); + } + + let health = + type === "maintenance" + ? "maintenance" + : highestHealth(state === "resolved" ? peakHealthValues : currentHealthValues); + if (state !== "resolved" && state !== "scheduled" && (health === "operational" || health === "unknown")) { + health = "degraded"; + } + + const startedAt = optionalString(notice.published_at) ?? updates[0]?.createdAt; + const updatedAt = latestUpdate?.createdAt ?? startedAt; + + return { + id, + title, + health, + state, + stateText, + startedAt, + updatedAt, + resolvedAt: state === "resolved" ? updatedAt : undefined, + affectedComponentIds: [...affectedComponentIds], + updates, + url: statusPageUrl + ? new URL(`incidents/${encodeURIComponent(id)}`, withTrailingSlash(statusPageUrl)).toString() + : undefined, + }; +} + +function parseIncidentIoUpdate(update: JsonRecord): IncidentUpdate | undefined { + const id = optionalString(update.id); + const body = optionalString(update.message_string) ?? optionalString(optionalRecord(update.message)?.markdown); + const createdAt = optionalString(update.published_at); + if (!id || !body || !createdAt) return undefined; + + const stateText = optionalString(update.to_status); + return { + id, + body: body.trim(), + createdAt, + state: mapIncidentIoState(stateText), + stateText, + }; +} + +function mapIncidentIoState(value: string | undefined): Incident["state"] { + switch (value) { + case "investigating": + return "investigating"; + case "identified": + return "identified"; + case "monitoring": + return "monitoring"; + case "resolved": + case "completed": + return "resolved"; + case "scheduled": + case "maintenance_scheduled": + case "in_progress": + return "scheduled"; + default: + return "unknown"; + } +} diff --git a/extensions/ai-status-check/src/providers/adapters/instatus.ts b/extensions/ai-status-check/src/providers/adapters/instatus.ts new file mode 100644 index 00000000000..2874d9613a2 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/instatus.ts @@ -0,0 +1,252 @@ +import { deriveProviderHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { withoutTrailingSlash } from "../../utils/url"; +import { + applyHistoryRange, + componentHistory, + finitePercent, + historyLevelFromHealth, + historyWindow, + publishedPercentText, +} from "../utils/component-history"; +import { fetchJson, fetchText, type FetchJson, type FetchText } from "../utils/http"; +import { fetchOptionalEnrichment } from "../utils/optional-enrichment"; +import { parseRssItems, stripHtml } from "../utils/rss"; +import { optionalRecord, optionalRecordArray, optionalString, requireRecord } from "../utils/runtime-values"; +import { mapFlexibleHealth, mapFlexibleIncidentState } from "../utils/status-normalization"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; + +export interface InstatusAdapterConfig extends ProviderAdapterConfig { + summaryUrl?: string; + componentsUrl?: string; + historyUrl?: string; + fetchJson?: FetchJson; + fetchText?: FetchText; +} + +export function createInstatusAdapter(config: InstatusAdapterConfig): ProviderAdapter { + const request = config.fetchJson ?? fetchJson; + const requestText = config.fetchText ?? fetchText; + const now = config.now ?? (() => new Date()); + const baseUrl = withoutTrailingSlash(config.statusPageUrl); + + return { + async fetch(signal) { + const [summaryPayload, componentsPayload, historyFeed, statusPageHtml] = await Promise.all([ + request(config.summaryUrl ?? `${baseUrl}/v3/summary.json`, signal), + request(config.componentsUrl ?? `${baseUrl}/v3/components.json`, signal), + requestText(config.historyUrl ?? `${baseUrl}/history.rss`, signal), + fetchOptionalEnrichment(signal, (historySignal) => requestText(config.statusPageUrl, historySignal)), + ]); + const fetchedAt = now(); + const parsed = parseInstatus(summaryPayload, componentsPayload); + const histories = parseInstatusComponentHistories(statusPageHtml ?? "", fetchedAt); + const incidents = parseInstatusHistory(historyFeed, parsed.components); + + return { + providerId: config.providerId, + health: deriveProviderHealth(parsed.reportedHealth, parsed.components, incidents), + statusText: parsed.statusText, + components: parsed.components.map((component) => { + const history = histories.get(component.id); + return history ? { ...component, history } : component; + }), + incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +export function parseInstatusComponentHistories(html: string, now = new Date()) { + const payload = decodeNextFlightPayload(html); + const marker = '"componentsUptime":'; + const markerIndex = payload.indexOf(marker); + if (markerIndex === -1) return new Map(); + const openIndex = payload.indexOf("{", markerIndex + marker.length); + const objectText = openIndex === -1 ? undefined : extractBalancedObject(payload, openIndex); + if (!objectText) return new Map(); + + let parsed: unknown; + try { + parsed = JSON.parse(objectText); + } catch { + return new Map(); + } + const root = optionalRecord(parsed); + if (!root) return new Map(); + + const result = new Map(); + for (const [componentId, value] of Object.entries(root)) { + const uptime = optionalRecord(value); + if (!uptime) continue; + const days = historyWindow(90, now); + for (const outage of optionalRecordArray(uptime.outages)) { + const startAt = optionalString(outage.from); + const endAt = optionalString(outage.to) ?? now; + const status = optionalString(outage.status); + if (!startAt || !status || status === "OPERATIONAL") continue; + applyHistoryRange(days, startAt, endAt, instatusHistoryLevel(status)); + } + const history = componentHistory("availability", days, { + uptimePercent: finitePercent(uptime.uptime), + uptimeText: publishedPercentText(uptime.uptime), + }); + if (history) result.set(componentId, history); + } + return result; +} + +function instatusHistoryLevel(status: string) { + switch (status.toUpperCase()) { + case "MAJOROUTAGE": + return "major_outage" as const; + case "PARTIALOUTAGE": + case "MINOROUTAGE": + return "partial_outage" as const; + case "UNDERMAINTENANCE": + return "maintenance" as const; + case "DEGRADEDPERFORMANCE": + return "degraded" as const; + default: + return historyLevelFromHealth(mapFlexibleHealth(status)); + } +} + +function decodeNextFlightPayload(html: string): string { + let payload = ""; + for (const match of html.matchAll(/self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)/g)) { + try { + payload += JSON.parse(`"${match[1]}"`) as string; + } catch { + // Ignore a malformed chunk while preserving any valid component data. + } + } + return payload; +} + +function extractBalancedObject(text: string, openIndex: number): string | undefined { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = openIndex; index < text.length; index += 1) { + const character = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (character === "{") depth += 1; + if (character === "}" && --depth === 0) return text.slice(openIndex, index + 1); + } + return undefined; +} + +export interface ParsedInstatusSummary { + reportedHealth: Health; + statusText: string; + components: ComponentStatus[]; +} + +export function parseInstatus(summaryPayload: unknown, componentsPayload: unknown): ParsedInstatusSummary { + const summary = requireRecord(summaryPayload, "Instatus summary"); + const page = requireRecord(summary.page, "Instatus summary page"); + const statusText = optionalString(page.status); + if (!statusText) throw new Error("Instatus summary did not contain a status"); + + const componentsRoot = requireRecord(componentsPayload, "Instatus components"); + const components = optionalRecordArray(componentsRoot.components) + .map((component) => { + const id = optionalString(component.id); + const name = optionalString(component.name); + const status = optionalString(component.status); + if (!id || !name || !status) return undefined; + return { + id, + name, + health: mapFlexibleHealth(status), + statusText: status, + group: + optionalString(optionalRecord(component.group)?.name) ?? + optionalString(component.group) ?? + optionalString(optionalRecord(component.parent)?.name), + } satisfies ComponentStatus; + }) + .filter((component): component is ComponentStatus => component !== undefined); + + return { reportedHealth: mapFlexibleHealth(statusText), statusText, components }; +} + +export function parseInstatusHistory(xml: string, components: readonly ComponentStatus[]): Incident[] { + const componentIds = new Map(components.map((component) => [component.name.toLowerCase(), component.id])); + + return parseRssItems(xml) + .map((item): Incident | undefined => { + const id = (item.guid ?? item.link)?.split("/").filter(Boolean).at(-1); + if (!id || !item.publishedAt) return undefined; + + const description = stripHtml(item.description ?? ""); + const type = /^Type:\s*([^\n]+)/im.exec(description)?.[1]?.trim(); + const affectedNames = + /Affected Components:\s*(.*?)(?=\s+[A-Z][a-z]{2}\s+\d{1,2},\s+\d{2}:\d{2}:\d{2}\s+GMT|$)/is + .exec(description)?.[1] + ?.split(",") + .map((name) => name.trim()) + .filter(Boolean) ?? []; + const updates = parseInstatusUpdates(id, description, item.publishedAt).sort( + (left, right) => parseTimestamp(left.createdAt) - parseTimestamp(right.createdAt), + ); + const latest = updates.at(-1); + const state = latest?.state ?? (type?.toLowerCase() === "maintenance" ? "scheduled" : "unknown"); + const isMaintenance = type?.toLowerCase() === "maintenance"; + + return { + id, + title: item.title, + health: isMaintenance ? "maintenance" : "degraded", + state, + stateText: latest?.stateText, + startedAt: updates[0]?.createdAt ?? item.publishedAt, + updatedAt: latest?.createdAt ?? item.publishedAt, + resolvedAt: state === "resolved" ? latest?.createdAt : undefined, + affectedComponentIds: affectedNames + .map((name) => componentIds.get(name.toLowerCase())) + .filter((componentId): componentId is string => componentId !== undefined), + updates, + url: item.link, + }; + }) + .filter((incident): incident is Incident => incident !== undefined) + .sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); +} + +function parseInstatusUpdates(incidentId: string, description: string, publishedAt: string): IncidentUpdate[] { + const year = new Date(publishedAt).getUTCFullYear(); + const pattern = + /\b([A-Z][a-z]{2})\s+(\d{1,2}),\s+(\d{2}:\d{2}:\d{2})\s+GMT([+-]\d+(?::\d+)?)\s+-\s+([^-]+?)\s+-\s+([\s\S]*?)(?=\s+[A-Z][a-z]{2}\s+\d{1,2},\s+\d{2}:\d{2}:\d{2}\s+GMT[+-]\d+(?::\d+)?\s+-|$)/g; + const updates: IncidentUpdate[] = []; + + for (const [index, match] of [...description.matchAll(pattern)].entries()) { + const [, month, day, time, offset, stateText, body] = match; + const parsedTime = Date.parse(`${month} ${day}, ${year} ${time} GMT${offset}`); + if (!stateText || !body || !Number.isFinite(parsedTime)) continue; + updates.push({ + id: `${incidentId}-${index}`, + state: mapFlexibleIncidentState(stateText.trim()), + stateText: stateText.trim(), + body: body.trim().replace(/\s+/g, " "), + createdAt: new Date(parsedTime).toISOString(), + }); + } + + return updates; +} diff --git a/extensions/ai-status-check/src/providers/adapters/mistral.ts b/extensions/ai-status-check/src/providers/adapters/mistral.ts new file mode 100644 index 00000000000..1d601286219 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/mistral.ts @@ -0,0 +1,157 @@ +import type { ComponentHistoryLevel, ComponentStatus, Health } from "../../domain/types"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; +import { componentHistory, finitePercent, historyWindow, publishedPercentText } from "../utils/component-history"; +import { stripHtml } from "../utils/rss"; +import { optionalRecord, optionalRecordArray, optionalString } from "../utils/runtime-values"; +import { mapFlexibleHealth, statusComponentId } from "../utils/status-normalization"; +import { createPageAndFeedAdapter, type PageAndFeedAdapterConfig, type ParsedStatusPage } from "./page-and-feed"; + +export interface MistralAdapterConfig extends ProviderAdapterConfig { + fetchText?: PageAndFeedAdapterConfig["fetchText"]; +} + +export function createMistralAdapter(config: MistralAdapterConfig): ProviderAdapter { + return createPageAndFeedAdapter({ + ...config, + feedUrl: "https://status.mistral.ai/feed.rss", + parsePage: parseMistralStatusPage, + }); +} + +export function parseMistralStatusPage(html: string, now = new Date()): ParsedStatusPage { + const components: ComponentStatus[] = []; + const histories = parseMistralComponentHistories(html, now); + const cards = [...html.matchAll(/<[^>]+\baria-label="Card ([^"]+)"[^>]*>/g)]; + + for (const [index, card] of cards.entries()) { + if (card.index === undefined) continue; + const group = stripHtml(card[1] ?? ""); + const nextCardIndex = cards[index + 1]?.index ?? html.length; + components.push(...parseMistralServices(html.slice(card.index, nextCardIndex), group || undefined)); + } + + const groupedIds = new Set(components.map((component) => component.id)); + for (const component of parseMistralServices(html)) { + if (!groupedIds.has(component.id)) components.push(component); + } + if (components.length === 0) throw new Error("Mistral status page contained no services"); + + const summary = /]*>([\s\S]*?)<\/h2>/i.exec(html)?.[1]; + const statusText = summary ? stripHtml(summary) : undefined; + return { + reportedHealth: mapFlexibleHealth(statusText), + statusText, + components: uniqueComponents(components).map((component) => { + const history = histories.get(component.id); + return history ? { ...component, history } : component; + }), + }; +} + +export function parseMistralComponentHistories(html: string, now = new Date()) { + const match = /]*id="__NUXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html); + if (!match?.[1]) return new Map(); + let values: unknown[]; + try { + values = JSON.parse(match[1]) as unknown[]; + } catch { + return new Map(); + } + const root = optionalRecord(hydrateNuxtData(values, 0)); + const data = optionalRecord(root?.data); + const uptimeEntry = data ? Object.entries(data).find(([key]) => key.startsWith("uptime-"))?.[1] : undefined; + const cards = optionalRecordArray(optionalRecord(uptimeEntry)?.uptime); + const result = new Map(); + + for (const card of cards) { + for (const service of optionalRecordArray(card.services)) { + const name = optionalString(service.name); + if (!name) continue; + const sourceDays = optionalRecordArray(service.days); + if (sourceDays.length === 0) continue; + const days = historyWindow(90, now, "not_monitored"); + const byDate = new Map(days.map((day) => [day.date, day])); + for (const sourceDay of sourceDays) { + const date = optionalString(sourceDay.date)?.slice(0, 10); + const target = date ? byDate.get(date) : undefined; + if (!target) continue; + target.level = mistralDayLevel(optionalRecordArray(sourceDay.events)); + } + const monitoredSince = sourceDays + .map((day) => optionalString(day.date)?.slice(0, 10)) + .filter((date): date is string => Boolean(date)) + .sort()[0]; + const history = componentHistory("availability", days, { + monitoredSince, + uptimePercent: finitePercent(service.uptime), + uptimeText: publishedPercentText(service.uptime), + }); + if (history) result.set(statusComponentId(name), history); + } + } + return result; +} + +function mistralDayLevel(events: readonly Record[]): ComponentHistoryLevel { + if (events.length === 0) return "operational"; + const rank: Readonly> = { MINOR: 1, MEDIUM: 2, MAJOR: 3 }; + const severities = events + .map((event) => optionalString(event.severity)) + .filter((severity): severity is string => Boolean(severity)); + const worst = severities + .filter((severity) => rank[severity] !== undefined) + .sort((left, right) => rank[right]! - rank[left]!)[0]; + if (worst === "MAJOR") return "major_outage"; + if (worst === "MEDIUM") return "partial_outage"; + if (worst === "MINOR") return "degraded"; + return "unknown"; +} + +const NUXT_REFERENCE_TAGS = new Set(["ShallowReactive", "Reactive", "Ref", "ShallowRef"]); + +function hydrateNuxtData(values: unknown[], index: unknown, visiting = new Set()): unknown { + if (typeof index !== "number") return index; + if (index < 0 || visiting.has(index)) return undefined; + const value = values[index]; + visiting.add(index); + try { + if (Array.isArray(value)) { + if (value.length === 2 && typeof value[0] === "string") { + if (NUXT_REFERENCE_TAGS.has(value[0])) return hydrateNuxtData(values, value[1], visiting); + if (value[0] === "Date") return value[1]; + } + return value.map((item) => hydrateNuxtData(values, item, visiting)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, hydrateNuxtData(values, item, visiting)]), + ); + } + return value; + } finally { + visiting.delete(index); + } +} + +function parseMistralServices(html: string, group?: string): ComponentStatus[] { + const components: ComponentStatus[] = []; + for (const match of html.matchAll(/aria-label="Service ([^"]+)"/g)) { + const name = stripHtml(match[1] ?? ""); + if (!name || match.index === undefined) continue; + const tail = html.slice(match.index, match.index + 700); + const color = /status-circle[^>]*\bbg-(green|yellow|amber|orange|red)-\d+/i.exec(tail)?.[1]; + components.push({ id: statusComponentId(name), name, group, health: colorHealth(color) }); + } + return components; +} + +function colorHealth(color: string | undefined): Health { + if (color === "green") return "operational"; + if (color === "yellow" || color === "amber" || color === "orange") return "degraded"; + if (color === "red") return "major_outage"; + return "unknown"; +} + +function uniqueComponents(components: readonly ComponentStatus[]): ComponentStatus[] { + return [...new Map(components.map((component) => [component.id, component])).values()]; +} diff --git a/extensions/ai-status-check/src/providers/adapters/openrouter.ts b/extensions/ai-status-check/src/providers/adapters/openrouter.ts new file mode 100644 index 00000000000..ec62b597129 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/openrouter.ts @@ -0,0 +1,83 @@ +import type { ComponentHistoryLevel, ComponentStatus } from "../../domain/types"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; +import { componentHistory, finitePercent, historyWindow, publishedPercentText } from "../utils/component-history"; +import { stripHtml } from "../utils/rss"; +import { mapFlexibleHealth, statusComponentId } from "../utils/status-normalization"; +import { createPageAndFeedAdapter, type PageAndFeedAdapterConfig, type ParsedStatusPage } from "./page-and-feed"; + +export interface OpenRouterAdapterConfig extends ProviderAdapterConfig { + fetchText?: PageAndFeedAdapterConfig["fetchText"]; +} + +export function createOpenRouterAdapter(config: OpenRouterAdapterConfig): ProviderAdapter { + return createPageAndFeedAdapter({ + ...config, + feedUrl: "https://status.openrouter.ai/incidents.rss", + parsePage: parseOpenRouterStatusPage, + }); +} + +export function parseOpenRouterStatusPage(html: string, now = new Date()): ParsedStatusPage { + const components: ComponentStatus[] = []; + const pattern = /

([\s\S]*?)<\/p>/gi; + const matches = [...html.matchAll(pattern)]; + for (const [index, match] of matches.entries()) { + const name = stripHtml(match[1] ?? ""); + const statusStart = (match.index ?? 0) + match[0].length; + const nextComponentStart = matches[index + 1]?.index ?? html.length; + const tail = html.slice(statusStart, nextComponentStart); + const statusText = stripHtml(/]*>([\s\S]*?)<\/span>/i.exec(tail)?.[1] ?? ""); + if (!name || !statusText) continue; + const barColors = [...tail.matchAll(/class="[^"]*\bh-8\b[^"]*\bbg-([a-z]+)-500\b[^"]*"/gi)] + .map((bar) => bar[1]) + .filter((color): color is string => Boolean(color)); + const days = barColors.length > 0 ? historyWindow(barColors.length, now, "unknown") : []; + for (const [barIndex, color] of barColors.entries()) { + const day = days[barIndex]; + if (day) day.level = openRouterBarLevel(color); + } + const uptimeText = /class="[^"]*underline[^"]*">\s*([\d.]+)(?:)?% uptime/i.exec(tail)?.[1]; + const history = componentHistory("availability", days, { + uptimePercent: finitePercent(uptimeText), + uptimeText: publishedPercentText(uptimeText), + }); + components.push({ + id: statusComponentId(name), + name, + health: mapFlexibleHealth(statusText), + statusText, + ...(history ? { history } : {}), + }); + } + if (components.length === 0) throw new Error("Status page contained no components"); + + const summary = /

([\s\S]*?)<\/p>/i.exec(html)?.[1]; + const statusText = summary ? stripHtml(summary) : undefined; + return { + reportedHealth: mapFlexibleHealth(statusText), + statusText, + components: uniqueComponents(components), + }; +} + +function openRouterBarLevel(color: string): ComponentHistoryLevel { + switch (color.toLowerCase()) { + case "green": + return "operational"; + case "yellow": + case "amber": + return "degraded"; + case "orange": + return "partial_outage"; + case "red": + return "major_outage"; + case "blue": + return "maintenance"; + default: + return "unknown"; + } +} + +function uniqueComponents(components: readonly ComponentStatus[]): ComponentStatus[] { + return [...new Map(components.map((component) => [component.id, component])).values()]; +} diff --git a/extensions/ai-status-check/src/providers/adapters/page-and-feed.ts b/extensions/ai-status-check/src/providers/adapters/page-and-feed.ts new file mode 100644 index 00000000000..2fb22b73b52 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/page-and-feed.ts @@ -0,0 +1,184 @@ +import { deriveProviderHealth } from "../../domain/derive-health"; +import type { ComponentStatus, Health, Incident, IncidentUpdate } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { normalizeStatusToken } from "../../utils/status-token"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; +import { fetchText, type FetchText } from "../utils/http"; +import { parseRssItems, stripHtml, type RssItem } from "../utils/rss"; +import { mapFlexibleIncidentState, statusComponentId } from "../utils/status-normalization"; + +export interface ParsedStatusPage { + reportedHealth: Health; + statusText?: string; + components: ComponentStatus[]; +} + +export interface PageAndFeedAdapterConfig extends ProviderAdapterConfig { + pageUrl?: string; + feedUrl: string; + parsePage: (html: string, now?: Date) => ParsedStatusPage; + parseFeed?: (xml: string) => Incident[]; + fetchText?: FetchText; +} + +export function createPageAndFeedAdapter(config: PageAndFeedAdapterConfig): ProviderAdapter { + const request = config.fetchText ?? fetchText; + const now = config.now ?? (() => new Date()); + + return { + async fetch(signal) { + const fetchedAt = now(); + const [page, feed] = await Promise.all([ + request(config.pageUrl ?? config.statusPageUrl, signal).then((html) => config.parsePage(html, fetchedAt)), + request(config.feedUrl, signal), + ]); + const incidents = (config.parseFeed ?? parseIncidentRss)(feed); + const components = page.components; + if (components.length === 0) throw new Error("Status adapter contained no components"); + const pageHealth = page.reportedHealth; + const reportedHealth = + pageHealth === "unknown" && incidents.every((incident) => incident.state === "resolved") + ? "operational" + : pageHealth; + + return { + providerId: config.providerId, + health: deriveProviderHealth(reportedHealth, components, incidents), + statusText: page.statusText, + components, + incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +export function parseIncidentRss(xml: string): Incident[] { + const grouped = new Map(); + for (const item of parseRssItems(xml)) { + const id = rssIncidentId(item); + const existing = grouped.get(id) ?? []; + existing.push(item); + grouped.set(id, existing); + } + + return [...grouped.entries()] + .map(([id, items]) => parseIncidentGroup(id, items)) + .sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); +} + +function parseIncidentGroup(id: string, items: RssItem[]): Incident { + const sorted = [...items].sort((left, right) => parseTimestamp(left.publishedAt) - parseTimestamp(right.publishedAt)); + const latest = sorted.at(-1) ?? items[0]; + const updates = sorted + .flatMap((item, index) => { + const embedded = parseEmbeddedRssUpdates(id, item); + return embedded.length > 0 ? embedded : [parseRssUpdate(id, item, index)]; + }) + .filter((update): update is IncidentUpdate => update !== undefined); + updates.sort((left, right) => parseTimestamp(left.createdAt) - parseTimestamp(right.createdAt)); + const publishedStatus = rssItemStatus(latest); + const latestUpdate = updates.at(-1); + const state = publishedStatus?.state !== "unknown" ? publishedStatus.state : (latestUpdate?.state ?? "unknown"); + const stateText = publishedStatus?.state !== "unknown" ? publishedStatus.stateText : latestUpdate?.stateText; + const affectedNames = new Set(); + for (const item of items) { + const bracketed = /^\[([^\]]+)\]/.exec(item.title)?.[1]; + if (bracketed) affectedNames.add(bracketed); + for (const match of (item.description ?? "").matchAll(/

  • ([\s\S]*?)<\/li>/gi)) { + const name = stripHtml(match[1] ?? ""); + if (name) affectedNames.add(name); + } + } + const title = latest.title.replace(/^\[[^\]]+\]\s*/, ""); + const health = incidentHealth(`${title} ${latest.description ?? ""} ${latest.categories.join(" ")}`, state); + + return { + id, + title, + state, + stateText, + health, + startedAt: updates[0]?.createdAt ?? sorted[0]?.publishedAt, + updatedAt: updates.at(-1)?.createdAt ?? latest.publishedAt, + resolvedAt: state === "resolved" ? (updates.at(-1)?.createdAt ?? latest.publishedAt) : undefined, + affectedComponentIds: [...affectedNames].map(statusComponentId), + updates, + url: latest.link, + }; +} + +function parseEmbeddedRssUpdates(incidentId: string, item: RssItem): IncidentUpdate[] { + const description = item.description ?? ""; + if (!/Updates:/i.test(description)) return []; + + return [...description.matchAll(/
    ([\s\S]*?)<\/div>/gi)] + .map((match, index): IncidentUpdate | undefined => { + const block = match[1] ?? ""; + const createdAtText = /([\s\S]*?)<\/strong>/i.exec(block)?.[1]; + const stateText = /

    ([\s\S]*?)<\/h3>/i.exec(block)?.[1]; + const bodies = [...block.matchAll(/

    ([\s\S]*?)<\/p>/gi)]; + const body = stripHtml(bodies.at(-1)?.[1] ?? ""); + const parsedTime = Date.parse(stripHtml(createdAtText ?? "")); + if (!Number.isFinite(parsedTime) || !stateText || !body) return undefined; + + const cleanState = stripHtml(stateText); + return { + id: `${incidentId}-embedded-${index}`, + body, + createdAt: new Date(parsedTime).toISOString(), + state: mapFlexibleIncidentState(cleanState), + stateText: cleanState, + }; + }) + .filter((update): update is IncidentUpdate => update !== undefined); +} + +function parseRssUpdate(incidentId: string, item: RssItem, index: number): IncidentUpdate | undefined { + if (!item.publishedAt) return undefined; + + const description = item.description ?? ""; + const status = rssItemStatus(item); + const body = stripHtml(description) + .replace(/^Status:\s*[^\n]+\n?/i, "") + .replace(/^Severity:\s*[^\n]+\n?/i, "") + .replace(/^Resolved:\s*[^\n]+\n?/i, "") + .replace(/Affected services[\s\S]*$/i, "") + .trim(); + return { + id: `${incidentId}-${index}`, + body: body || item.title, + createdAt: item.publishedAt, + state: status.state, + stateText: status.stateText, + }; +} + +function rssItemStatus(item: RssItem): { state: Incident["state"]; stateText?: string } { + const description = item.description ?? ""; + const sourceState = + /Status:\s*([^<\n]+)/i.exec(description)?.[1]?.trim() ?? + /([^<]+)<\/strong>/i.exec(description)?.[1]?.trim(); + const candidates = [sourceState, ...item.categories.toReversed()].filter((value): value is string => Boolean(value)); + + for (const stateText of candidates) { + const state = mapFlexibleIncidentState(stateText); + if (state !== "unknown") return { state, stateText }; + } + + return { state: "unknown", stateText: candidates[0] }; +} + +function rssIncidentId(item: RssItem): string { + const value = item.guid ?? item.link ?? `${item.title}-${item.publishedAt ?? ""}`; + return value.split("/").filter(Boolean).at(-1) ?? value; +} + +function incidentHealth(value: string, state: Incident["state"]): Health { + if (state === "scheduled") return "maintenance"; + const normalized = normalizeStatusToken(stripHtml(value)); + if (/major_outage|full_outage|unavailable|(^|_)outage($|_)/.test(normalized)) return "major_outage"; + if (/partial_outage/.test(normalized)) return "partial_outage"; + if (/maintenance/.test(normalized)) return "maintenance"; + return "degraded"; +} diff --git a/extensions/ai-status-check/src/providers/adapters/statuspage.ts b/extensions/ai-status-check/src/providers/adapters/statuspage.ts new file mode 100644 index 00000000000..733f5aafa92 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/statuspage.ts @@ -0,0 +1,341 @@ +import { deriveProviderHealth } from "../../domain/derive-health"; +import type { + ComponentHistoryDay, + ComponentStatus, + Health, + Incident, + IncidentState, + IncidentUpdate, +} from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; +import { normalizeStatusToken } from "../../utils/status-token"; +import { withTrailingSlash } from "../../utils/url"; +import { componentHistory, finitePercent, publishedPercentText } from "../utils/component-history"; +import { fetchJson, fetchText, type FetchJson, type FetchText } from "../utils/http"; +import { sortIncidentsByActivity } from "../utils/incidents"; +import { fetchOptionalEnrichment } from "../utils/optional-enrichment"; +import { optionalRecordArray, optionalString, requireRecord, type JsonRecord } from "../utils/runtime-values"; +import { mapFlexibleHealth } from "../utils/status-normalization"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; + +export interface StatuspageAdapterConfig extends ProviderAdapterConfig { + endpoints?: Partial; + componentFilter?: (component: ComponentStatus) => boolean; + incidentFilter?: (incident: Incident) => boolean; + fetchJson?: FetchJson; + fetchText?: FetchText; +} + +export interface StatuspageEndpoints { + summary: string; + incidents: string; + maintenances: string; +} + +export function createStatuspageAdapter(config: StatuspageAdapterConfig): ProviderAdapter { + const fetchJsonResponse = config.fetchJson ?? fetchJson; + const fetchTextResponse = config.fetchText ?? fetchText; + const now = config.now ?? (() => new Date()); + const endpoints = statuspageEndpoints(config.statusPageUrl, config.endpoints); + + return { + async fetch(signal) { + const [summaryPayload, incidentsPayload, maintenancesPayload, statusPageHtml] = await Promise.all([ + fetchJsonResponse(endpoints.summary, signal), + fetchJsonResponse(endpoints.incidents, signal), + fetchJsonResponse(endpoints.maintenances, signal), + fetchOptionalEnrichment(signal, (historySignal) => fetchTextResponse(config.statusPageUrl, historySignal)), + ]); + const fetchedAt = now(); + + const summary = parseSummary(summaryPayload); + const uptimeData = parseStatuspageUptimeHtml(statusPageHtml ?? ""); + const incidents = sortIncidentsByActivity([ + ...parseIncidents(incidentsPayload, config.statusPageUrl), + ...parseScheduledMaintenances(maintenancesPayload, config.statusPageUrl), + ]).filter((incident) => config.incidentFilter?.(incident) ?? true); + const components = summary.components + .filter((component) => config.componentFilter?.(component) ?? true) + .map((component) => { + const history = statuspageComponentHistory(uptimeData[component.id]); + return history ? { ...component, history } : component; + }); + const isScopedProvider = Boolean(config.componentFilter || config.incidentFilter); + const health = deriveProviderHealth(isScopedProvider ? "unknown" : summary.reportedHealth, components, incidents); + + return { + providerId: config.providerId, + health, + statusText: isScopedProvider ? undefined : summary.statusText, + components, + incidents, + fetchedAt: fetchedAt.toISOString(), + }; + }, + }; +} + +interface StatuspageUptimeDay { + date?: unknown; + outages?: unknown; +} + +interface StatuspageUptimeComponent { + component?: { startDate?: unknown }; + days?: StatuspageUptimeDay[]; + uptimeText?: unknown; +} + +type StatuspageUptimeData = Record; + +/** Parse the official day-level availability data embedded by Statuspage. */ +export function parseStatuspageUptimeHtml(html: string): StatuspageUptimeData { + const marker = "window.uptimeData = "; + const markerIndex = html.indexOf(marker); + if (markerIndex === -1) return {}; + const openIndex = html.indexOf("{", markerIndex + marker.length); + if (openIndex === -1) return {}; + const objectText = extractBalancedObject(html, openIndex); + if (!objectText) return {}; + try { + const parsed: unknown = JSON.parse(objectText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const uptimeData = parsed as StatuspageUptimeData; + for (const match of html.matchAll( + /]*>[\s\S]*?]*>\s*([\d.]+)\s*<\/var>/gi, + )) { + const componentId = match[1]; + const uptimeText = match[2]; + const component = componentId ? uptimeData[componentId] : undefined; + if (component && uptimeText) component.uptimeText = uptimeText; + } + return uptimeData; + } catch { + return {}; + } +} + +function statuspageComponentHistory(value: StatuspageUptimeComponent | undefined) { + if (!Array.isArray(value?.days) || value.days.length === 0) return undefined; + const monitoredSince = sourceDate(value.component?.startDate); + const days = value.days.flatMap((day) => { + const date = typeof day.date === "string" ? day.date.slice(0, 10) : undefined; + if (!date) return []; + if (monitoredSince && date < monitoredSince) return [{ date, level: "not_monitored" as const }]; + + const outages = day.outages && typeof day.outages === "object" ? (day.outages as Record) : {}; + const major = nonNegativeNumber(outages.m); + const partial = nonNegativeNumber(outages.p); + const weightedDayDowntime = major + partial * 0.3; + return [ + { + date, + level: statuspageDayLevel(weightedDayDowntime), + }, + ]; + }); + if (days.length === 0) return undefined; + const publishedUptime = finitePercent(value.uptimeText); + return componentHistory("availability", days, { + uptimePercent: publishedUptime, + uptimeText: publishedPercentText(value.uptimeText), + monitoredSince, + }); +} + +function statuspageDayLevel(weightedDowntimeSeconds: number) { + if (weightedDowntimeSeconds <= 0) return "operational" as const; + if (weightedDowntimeSeconds <= 20 * 60) return "degraded" as const; + if (weightedDowntimeSeconds <= 40 * 60) return "partial_outage" as const; + return "major_outage" as const; +} + +function sourceDate(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const date = value.slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined; +} + +function nonNegativeNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; +} + +function extractBalancedObject(text: string, openIndex: number): string | undefined { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = openIndex; index < text.length; index += 1) { + const character = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (character === "{") depth += 1; + if (character === "}" && --depth === 0) return text.slice(openIndex, index + 1); + } + return undefined; +} + +export function statuspageEndpoints( + statusPageUrl: string, + overrides: Partial = {}, +): StatuspageEndpoints { + return { + summary: overrides.summary ?? new URL("api/v2/summary.json", statusPageUrl).toString(), + incidents: overrides.incidents ?? new URL("api/v2/incidents.json", statusPageUrl).toString(), + maintenances: overrides.maintenances ?? new URL("api/v2/scheduled-maintenances.json", statusPageUrl).toString(), + }; +} + +export interface ParsedStatuspageSummary { + reportedHealth: Health; + statusText?: string; + components: ComponentStatus[]; +} + +export function parseSummary(payload: unknown): ParsedStatuspageSummary { + const root = requireRecord(payload, "status summary"); + const status = requireRecord(root.status, "status summary status"); + const indicator = optionalString(status.indicator); + const description = optionalString(status.description); + + if (!indicator && !description) throw new Error("Status summary did not contain an overall status"); + + const components = optionalRecordArray(root.components) + .map(parseComponent) + .filter((component): component is ComponentStatus => component !== undefined); + + return { + reportedHealth: mapFlexibleHealth(indicator ?? description), + statusText: description, + components, + }; +} + +export function parseIncidents(payload: unknown, statusPageUrl: string): Incident[] { + const root = requireRecord(payload, "incident history"); + return parseIncidentList(root.incidents, statusPageUrl); +} + +export function parseScheduledMaintenances(payload: unknown, statusPageUrl: string): Incident[] { + const root = requireRecord(payload, "scheduled maintenance history"); + return parseIncidentList(root.scheduled_maintenances, statusPageUrl); +} + +function parseIncidentList(value: unknown, statusPageUrl: string): Incident[] { + return optionalRecordArray(value) + .map((incident) => parseIncident(incident, statusPageUrl)) + .filter((incident): incident is Incident => incident !== undefined) + .sort((left, right) => parseTimestamp(right.startedAt) - parseTimestamp(left.startedAt)); +} + +function parseComponent(component: JsonRecord): ComponentStatus | undefined { + const id = optionalString(component.id); + const name = optionalString(component.name); + const statusText = optionalString(component.status); + if (!id || !name || !statusText) return undefined; + + return { + id, + name, + health: mapFlexibleHealth(statusText), + statusText, + group: optionalString(component.group_name), + }; +} + +function parseIncident(incident: JsonRecord, statusPageUrl: string): Incident | undefined { + const id = optionalString(incident.id); + const title = optionalString(incident.name); + if (!id || !title) return undefined; + + const stateText = optionalString(incident.status); + const state = mapIncidentState(stateText); + const impactText = optionalString(incident.impact); + const updates = optionalRecordArray(incident.incident_updates) + .map(parseIncidentUpdate) + .filter((update): update is IncidentUpdate => update !== undefined); + const affectedComponentIds = new Set(); + + for (const component of optionalRecordArray(incident.components)) { + const componentId = optionalString(component.id); + if (componentId) affectedComponentIds.add(componentId); + } + + for (const update of optionalRecordArray(incident.incident_updates)) { + for (const component of optionalRecordArray(update.affected_components)) { + const componentId = optionalString(component.code) ?? optionalString(component.id); + if (componentId) affectedComponentIds.add(componentId); + } + } + + const shortlink = optionalString(incident.shortlink); + + return { + id, + title, + state, + health: state === "scheduled" ? "maintenance" : mapIncidentImpact(impactText), + stateText, + impactText, + startedAt: + optionalString(incident.started_at) ?? + optionalString(incident.scheduled_for) ?? + optionalString(incident.created_at), + updatedAt: optionalString(incident.updated_at), + resolvedAt: optionalString(incident.resolved_at), + affectedComponentIds: [...affectedComponentIds], + updates, + url: shortlink ?? new URL(`incidents/${encodeURIComponent(id)}`, withTrailingSlash(statusPageUrl)).toString(), + }; +} + +function parseIncidentUpdate(update: JsonRecord): IncidentUpdate | undefined { + const id = optionalString(update.id); + const body = optionalString(update.body); + const createdAt = + optionalString(update.display_at) ?? optionalString(update.created_at) ?? optionalString(update.updated_at); + if (!id || !body || !createdAt) return undefined; + + const stateText = optionalString(update.status); + return { + id, + body, + createdAt, + state: mapIncidentState(stateText), + stateText, + }; +} + +function mapIncidentImpact(value: string | undefined): Health { + if (!value) return "unknown"; + return mapFlexibleHealth(value); +} + +function mapIncidentState(value: string | undefined): IncidentState { + switch (normalizeStatusToken(value ?? "")) { + case "investigating": + return "investigating"; + case "identified": + return "identified"; + case "monitoring": + return "monitoring"; + case "resolved": + case "completed": + return "resolved"; + case "scheduled": + case "in_progress": + return "scheduled"; + default: + return "unknown"; + } +} diff --git a/extensions/ai-status-check/src/providers/adapters/xai.ts b/extensions/ai-status-check/src/providers/adapters/xai.ts new file mode 100644 index 00000000000..c823e6d4816 --- /dev/null +++ b/extensions/ai-status-check/src/providers/adapters/xai.ts @@ -0,0 +1,220 @@ +import type { ComponentHistory, ComponentHistoryLevel, ComponentStatus } from "../../domain/types"; +import { normalizeStatusToken } from "../../utils/status-token"; +import type { ProviderAdapter, ProviderAdapterConfig } from "../types"; +import { componentHistory, historyWindow } from "../utils/component-history"; +import { fetchText } from "../utils/http"; +import { mapFlexibleHealth } from "../utils/status-normalization"; +import { createPageAndFeedAdapter, type PageAndFeedAdapterConfig, type ParsedStatusPage } from "./page-and-feed"; + +export interface XaiAdapterConfig extends ProviderAdapterConfig { + fetchText?: PageAndFeedAdapterConfig["fetchText"]; +} + +export function createXaiAdapter(config: XaiAdapterConfig): ProviderAdapter { + const adapter = createPageAndFeedAdapter({ + ...config, + pageUrl: "https://status.x.ai/index.txt", + feedUrl: "https://status.x.ai/feed.xml", + parsePage: parseXaiStatusPage, + }); + const request = config.fetchText ?? fetchText; + return { + ...adapter, + async fetchComponentHistory(componentId, signal) { + const detailUrl = new URL(`${encodeURIComponent(componentId)}.txt`, config.statusPageUrl).toString(); + return parseXaiComponentHistory(await request(detailUrl, signal), config.now?.() ?? new Date()); + }, + }; +} + +export function parseXaiStatusPage(rsc: string): ParsedStatusPage { + if (!rsc.includes('"children":"Live service data"')) { + throw new Error("xAI status page payload was malformed"); + } + + const references = parseReferences(rsc); + const components: ComponentStatus[] = []; + const servicePattern = + /\["\$","\$L[\da-f]+","([^"]+)",\{"href":"\/\1"[\s\S]*?"className":"heading-2","children":"([^"]+)"([\s\S]*?)(?=\["\$","\$L[\da-f]+","[^"\]]+",\{"href":"\/|\n[\da-f]+:|$)/gi; + + for (const match of rsc.matchAll(servicePattern)) { + const id = match[1]; + const name = match[2]; + const tail = match[3] ?? ""; + if (!id || !name) continue; + + const inlineStatus = [...tail.matchAll(/"children":"([^"]+)"/g)].at(-1)?.[1]; + const referenceId = /"\$L([\da-f]+)"/.exec(tail)?.[1]; + const referencedStatus = referenceId + ? /"children":"([^"]+)"/.exec(references.get(referenceId) ?? "")?.[1] + : undefined; + const statusText = inlineStatus ?? referencedStatus; + if (!statusText) continue; + + components.push({ + id, + name, + health: mapFlexibleHealth(statusText), + statusText, + url: `https://status.x.ai/${encodeURIComponent(id)}`, + }); + } + + if (components.length === 0) throw new Error("xAI status page contained no components"); + + const noIncidents = rsc.includes('"children":"No incidents declared"'); + return { + reportedHealth: noIncidents ? "operational" : "unknown", + statusText: noIncidents ? "No incidents declared" : undefined, + components, + }; +} + +interface XaiProductIncident { + status?: unknown; + startTime?: unknown; + endTime?: unknown; + severity?: unknown; + updates?: Array<{ createTime?: unknown; severity?: unknown }>; +} + +export function parseXaiComponentHistory(rsc: string, now = new Date()): ComponentHistory { + const product = parseProduct(rsc); + const incidents = Array.isArray(product.incidents) ? (product.incidents as XaiProductIncident[]) : []; + const days = historyWindow(30, now); + + for (const day of days) { + const start = new Date(`${day.date}T00:00:00.000Z`); + const end = new Date(`${day.date}T23:59:59.999Z`); + const levels = incidents + .filter((incident) => incidentOverlapsDay(incident, start, end, now)) + .map((incident) => xaiIncidentLevelOnDay(incident, start, end)) + .filter((level): level is ComponentHistoryLevel => level !== undefined); + day.level = worstXaiLevel(levels); + } + + const history = componentHistory("incidents", days); + if (!history) throw new Error("xAI component page contained no history"); + return history; +} + +function parseProduct(rsc: string): Record { + const marker = '"product":'; + const markerIndex = rsc.indexOf(marker); + const openIndex = markerIndex === -1 ? -1 : rsc.indexOf("{", markerIndex + marker.length); + const objectText = openIndex === -1 ? undefined : extractBalancedObject(rsc, openIndex); + if (!objectText) throw new Error("xAI component page payload was malformed"); + try { + const value: unknown = JSON.parse(objectText); + if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; + } catch { + // Fall through to the source-specific error below. + } + throw new Error("xAI component page payload was malformed"); +} + +function incidentOverlapsDay(incident: XaiProductIncident, start: Date, end: Date, now: Date): boolean { + const incidentStart = parsedDate(incident.startTime); + const incidentEnd = parsedDate(incident.endTime) ?? (incident.status === "active" ? now : undefined); + return Boolean(incidentStart && incidentEnd && incidentStart <= end && incidentEnd >= start); +} + +function xaiIncidentLevelOnDay( + incident: XaiProductIncident, + start: Date, + end: Date, +): ComponentHistoryLevel | undefined { + const updates = Array.isArray(incident.updates) ? incident.updates : []; + const sameDay = updates.filter((update) => { + const time = parsedDate(update.createTime); + return time && time >= start && time <= end; + }); + if (sameDay.length > 0) return worstXaiLevel(sameDay.map((update) => xaiSeverityLevel(update.severity))); + + const before = updates + .map((update) => ({ update, time: parsedDate(update.createTime) })) + .filter((entry): entry is { update: (typeof updates)[number]; time: Date } => + Boolean(entry.time && entry.time <= end), + ) + .sort((left, right) => right.time.getTime() - left.time.getTime())[0]; + if (before) return xaiSeverityLevel(before.update.severity); + + const after = updates + .map((update) => ({ update, time: parsedDate(update.createTime) })) + .filter((entry): entry is { update: (typeof updates)[number]; time: Date } => + Boolean(entry.time && entry.time > end), + ) + .sort((left, right) => left.time.getTime() - right.time.getTime())[0]; + return xaiSeverityLevel(after?.update.severity ?? incident.severity); +} + +function xaiSeverityLevel(value: unknown): ComponentHistoryLevel { + switch (value) { + case "outage": + return "major_outage"; + case "disruption": + return "degraded"; + case "info": + return "informational"; + case "available": + return "operational"; + default: + return "unknown"; + } +} + +function worstXaiLevel(levels: readonly ComponentHistoryLevel[]): ComponentHistoryLevel { + const severity: Readonly>> = { + operational: 0, + unknown: 1, + informational: 2, + degraded: 3, + major_outage: 4, + }; + return levels.reduce( + (worst, level) => ((severity[level] ?? 0) > (severity[worst] ?? 0) ? level : worst), + "operational", + ); +} + +function parsedDate(value: unknown): Date | undefined { + if (typeof value !== "string") return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function extractBalancedObject(text: string, openIndex: number): string | undefined { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = openIndex; index < text.length; index += 1) { + const character = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (character === "{") depth += 1; + if (character === "}" && --depth === 0) return text.slice(openIndex, index + 1); + } + return undefined; +} + +function parseReferences(rsc: string): ReadonlyMap { + const references = new Map(); + + for (const line of rsc.split("\n")) { + const match = /^([\da-f]+):(.*)$/i.exec(line); + if (match?.[1] && match[2]) references.set(normalizeStatusToken(match[1]), match[2]); + } + + return references; +} diff --git a/extensions/ai-status-check/src/providers/catalog/baseten.ts b/extensions/ai-status-check/src/providers/catalog/baseten.ts new file mode 100644 index 00000000000..ca46dac04f3 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/baseten.ts @@ -0,0 +1,15 @@ +import { createStatuspageAdapter } from "../adapters/statuspage"; +import { createProvider } from "../factories/provider"; + +export const basetenProvider = createProvider( + { + id: "baseten", + name: "Baseten", + aliases: ["Baseten Inference", "Model APIs"], + category: "routers-and-inference", + preferenceKey: "showBaseten", + icon: "provider-icons/baseten.png", + statusPageUrl: "https://status.baseten.co/", + }, + createStatuspageAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/cerebras.ts b/extensions/ai-status-check/src/providers/catalog/cerebras.ts new file mode 100644 index 00000000000..fd9d7855af4 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/cerebras.ts @@ -0,0 +1,15 @@ +import { createStatuspageAdapter } from "../adapters/statuspage"; +import { createProvider } from "../factories/provider"; + +export const cerebrasProvider = createProvider( + { + id: "cerebras", + name: "Cerebras", + aliases: ["Cerebras Inference", "Cerebras API"], + category: "routers-and-inference", + preferenceKey: "showCerebras", + icon: "provider-icons/cerebras.png", + statusPageUrl: "https://status.cerebras.ai/", + }, + createStatuspageAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/claude.ts b/extensions/ai-status-check/src/providers/catalog/claude.ts new file mode 100644 index 00000000000..be18c0d25a1 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/claude.ts @@ -0,0 +1,15 @@ +import { createStatuspageAdapter } from "../adapters/statuspage"; +import { createProvider } from "../factories/provider"; + +export const claudeProvider = createProvider( + { + id: "claude", + name: "Claude", + aliases: ["Anthropic", "Claude API", "Claude Code", "Claude Cowork"], + category: "model-providers", + preferenceKey: "showClaude", + icon: "provider-icons/claude.png", + statusPageUrl: "https://status.claude.com/", + }, + createStatuspageAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/cohere.ts b/extensions/ai-status-check/src/providers/catalog/cohere.ts new file mode 100644 index 00000000000..c58fa5294e2 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/cohere.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const cohereProvider = createProvider( + { + id: "cohere", + name: "Cohere", + aliases: ["Command", "Embed", "Rerank"], + category: "model-providers", + preferenceKey: "showCohere", + icon: "provider-icons/cohere.png", + statusPageUrl: "https://status.cohere.com/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/deepseek.ts b/extensions/ai-status-check/src/providers/catalog/deepseek.ts new file mode 100644 index 00000000000..46be0a4dc7e --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/deepseek.ts @@ -0,0 +1,18 @@ +import { createFlashcatAdapter } from "../adapters/flashcat"; +import { createProvider } from "../factories/provider"; + +export const deepseekProvider = createProvider( + { + id: "deepseek", + name: "DeepSeek", + aliases: ["DeepSeek API", "DeepSeek Chat"], + category: "model-providers", + preferenceKey: "showDeepSeek", + icon: "provider-icons/deepseek.png", + statusPageUrl: "https://status.deepseek.com/", + }, + createFlashcatAdapter, + { + pageId: "6410630422455", + }, +); diff --git a/extensions/ai-status-check/src/providers/catalog/elevenlabs.ts b/extensions/ai-status-check/src/providers/catalog/elevenlabs.ts new file mode 100644 index 00000000000..5d3081a79ac --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/elevenlabs.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const elevenLabsProvider = createProvider( + { + id: "elevenlabs", + name: "ElevenLabs", + aliases: ["Eleven Labs", "Text to Speech", "Speech to Text"], + category: "specialized", + preferenceKey: "showElevenLabs", + icon: "provider-icons/elevenlabs.png", + statusPageUrl: "https://status.elevenlabs.io/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/fireworks.ts b/extensions/ai-status-check/src/providers/catalog/fireworks.ts new file mode 100644 index 00000000000..21d262c15bf --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/fireworks.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const fireworksProvider = createProvider( + { + id: "fireworks-ai", + name: "Fireworks AI", + aliases: ["Fireworks", "Fireworks API"], + category: "routers-and-inference", + preferenceKey: "showFireworksAI", + icon: "provider-icons/fireworks.png", + statusPageUrl: "https://status.fireworks.ai/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/gemini.ts b/extensions/ai-status-check/src/providers/catalog/gemini.ts new file mode 100644 index 00000000000..443c9dd7ec4 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/gemini.ts @@ -0,0 +1,15 @@ +import { createGeminiAdapter } from "../adapters/gemini"; +import { createProvider } from "../factories/provider"; + +export const geminiProvider = createProvider( + { + id: "gemini-api", + name: "Google AI Studio and Gemini", + aliases: ["Gemini", "Google AI Studio", "Google AI", "Multimodal Live API"], + category: "model-providers", + preferenceKey: "showGemini", + icon: "provider-icons/gemini.png", + statusPageUrl: "https://aistudio.google.com/status", + }, + createGeminiAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/groq.ts b/extensions/ai-status-check/src/providers/catalog/groq.ts new file mode 100644 index 00000000000..c50aced96f7 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/groq.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const groqProvider = createProvider( + { + id: "groq", + name: "Groq", + aliases: ["GroqCloud", "Groq API"], + category: "routers-and-inference", + preferenceKey: "showGroq", + icon: "provider-icons/groq.png", + statusPageUrl: "https://groqstatus.com/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/hugging-face.ts b/extensions/ai-status-check/src/providers/catalog/hugging-face.ts new file mode 100644 index 00000000000..fc5f8cd9c03 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/hugging-face.ts @@ -0,0 +1,15 @@ +import { createBetterStackAdapter } from "../adapters/betterstack"; +import { createProvider } from "../factories/provider"; + +export const huggingFaceProvider = createProvider( + { + id: "hugging-face", + name: "Hugging Face", + aliases: ["HuggingFace", "HF", "Inference Endpoints", "Spaces"], + category: "routers-and-inference", + preferenceKey: "showHuggingFace", + icon: "provider-icons/huggingface.png", + statusPageUrl: "https://status.huggingface.co/", + }, + createBetterStackAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/mistral.ts b/extensions/ai-status-check/src/providers/catalog/mistral.ts new file mode 100644 index 00000000000..9fd9a8c3a10 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/mistral.ts @@ -0,0 +1,15 @@ +import { createMistralAdapter } from "../adapters/mistral"; +import { createProvider } from "../factories/provider"; + +export const mistralProvider = createProvider( + { + id: "mistral-ai", + name: "Mistral AI", + aliases: ["Mistral", "Le Chat", "La Plateforme", "Codestral"], + category: "model-providers", + preferenceKey: "showMistral", + icon: "provider-icons/mistral.png", + statusPageUrl: "https://status.mistral.ai/", + }, + createMistralAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/openai.ts b/extensions/ai-status-check/src/providers/catalog/openai.ts new file mode 100644 index 00000000000..79dfaa785ee --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/openai.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const openaiProvider = createProvider( + { + id: "openai", + name: "OpenAI", + aliases: ["ChatGPT", "Codex", "GPT", "Sora"], + category: "model-providers", + preferenceKey: "showOpenAI", + icon: "provider-icons/openai.png", + statusPageUrl: "https://status.openai.com/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/openrouter.ts b/extensions/ai-status-check/src/providers/catalog/openrouter.ts new file mode 100644 index 00000000000..22951d751dc --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/openrouter.ts @@ -0,0 +1,15 @@ +import { createOpenRouterAdapter } from "../adapters/openrouter"; +import { createProvider } from "../factories/provider"; + +export const openRouterProvider = createProvider( + { + id: "openrouter", + name: "OpenRouter", + aliases: ["OpenRouter API", "Model Router"], + category: "routers-and-inference", + preferenceKey: "showOpenRouter", + icon: "provider-icons/openrouter.png", + statusPageUrl: "https://status.openrouter.ai/", + }, + createOpenRouterAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/perplexity.ts b/extensions/ai-status-check/src/providers/catalog/perplexity.ts new file mode 100644 index 00000000000..b7495288840 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/perplexity.ts @@ -0,0 +1,15 @@ +import { createInstatusAdapter } from "../adapters/instatus"; +import { createProvider } from "../factories/provider"; + +export const perplexityProvider = createProvider( + { + id: "perplexity", + name: "Perplexity", + aliases: ["Perplexity AI", "Perplexity API", "Sonar"], + category: "model-providers", + preferenceKey: "showPerplexity", + icon: "provider-icons/perplexity.png", + statusPageUrl: "https://status.perplexity.com/", + }, + createInstatusAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/replicate.ts b/extensions/ai-status-check/src/providers/catalog/replicate.ts new file mode 100644 index 00000000000..828e67e2595 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/replicate.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const replicateProvider = createProvider( + { + id: "replicate", + name: "Replicate", + aliases: ["Replicate API", "r8.im"], + category: "routers-and-inference", + preferenceKey: "showReplicate", + icon: "provider-icons/replicate.png", + statusPageUrl: "https://www.replicatestatus.com/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/stability.ts b/extensions/ai-status-check/src/providers/catalog/stability.ts new file mode 100644 index 00000000000..92fe15a2d5b --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/stability.ts @@ -0,0 +1,15 @@ +import { createIncidentIoAdapter } from "../adapters/incidentio"; +import { createProvider } from "../factories/provider"; + +export const stabilityProvider = createProvider( + { + id: "stability-ai", + name: "Stability AI", + aliases: ["Stable Diffusion", "Stability API"], + category: "specialized", + preferenceKey: "showStabilityAI", + icon: "provider-icons/stability.png", + statusPageUrl: "https://status.stability.ai/", + }, + createIncidentIoAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/together.ts b/extensions/ai-status-check/src/providers/catalog/together.ts new file mode 100644 index 00000000000..99c8546af8f --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/together.ts @@ -0,0 +1,15 @@ +import { createBetterStackAdapter } from "../adapters/betterstack"; +import { createProvider } from "../factories/provider"; + +export const togetherProvider = createProvider( + { + id: "together-ai", + name: "Together AI", + aliases: ["Together", "Together API", "Together Inference"], + category: "routers-and-inference", + preferenceKey: "showTogetherAI", + icon: "provider-icons/together.png", + statusPageUrl: "https://status.together.ai/", + }, + createBetterStackAdapter, +); diff --git a/extensions/ai-status-check/src/providers/catalog/xai.ts b/extensions/ai-status-check/src/providers/catalog/xai.ts new file mode 100644 index 00000000000..6203210b3a7 --- /dev/null +++ b/extensions/ai-status-check/src/providers/catalog/xai.ts @@ -0,0 +1,15 @@ +import { createXaiAdapter } from "../adapters/xai"; +import { createProvider } from "../factories/provider"; + +export const xaiProvider = createProvider( + { + id: "xai", + name: "xAI", + aliases: ["Grok", "xAI API", "Grok API", "Grok Web"], + category: "model-providers", + preferenceKey: "showXAI", + icon: "provider-icons/xai.png", + statusPageUrl: "https://status.x.ai/", + }, + createXaiAdapter, +); diff --git a/extensions/ai-status-check/src/providers/categories.ts b/extensions/ai-status-check/src/providers/categories.ts new file mode 100644 index 00000000000..36fa2f6ab11 --- /dev/null +++ b/extensions/ai-status-check/src/providers/categories.ts @@ -0,0 +1,13 @@ +import type { ProviderCategory } from "../domain/types"; + +export const PROVIDER_CATEGORY_TITLES: Record = { + "model-providers": "Model Providers", + "routers-and-inference": "Routers & Inference", + specialized: "Specialized Generation", +}; + +export const PROVIDER_CATEGORY_ORDER: readonly ProviderCategory[] = [ + "model-providers", + "routers-and-inference", + "specialized", +]; diff --git a/extensions/ai-status-check/src/providers/factories/provider.ts b/extensions/ai-status-check/src/providers/factories/provider.ts new file mode 100644 index 00000000000..d64487b4d81 --- /dev/null +++ b/extensions/ai-status-check/src/providers/factories/provider.ts @@ -0,0 +1,40 @@ +import { withTrailingSlash } from "../../utils/url"; +import type { ProviderAdapterConfig, ProviderAdapterFactory, ProviderDefinition } from "../types"; + +type RuntimeOption = "providerId" | "statusPageUrl" | "now" | "fetchJson" | "fetchText"; +type AdapterOptions = Omit; +type RequiredKeys = { + [Key in keyof Value]-?: Record extends Pick ? never : Key; +}[keyof Value]; +type AdapterOptionsArgument = + RequiredKeys> extends never + ? [options?: AdapterOptions] + : [options: AdapterOptions]; + +export type ProviderMetadata = Omit, "adapter">; + +/** + * Connects catalog metadata to an adapter without repeating provider IDs or + * normalized status-page URLs in every provider module. + * + * Adapter-specific options remain available for framework variations while + * request and clock injection stay on adapters for isolated tests. + */ +export function createProvider( + metadata: ProviderMetadata, + createAdapter: ProviderAdapterFactory, + ...[options]: AdapterOptionsArgument +): ProviderDefinition { + const statusPageUrl = withTrailingSlash(metadata.statusPageUrl); + const adapterConfig = { + ...options, + providerId: metadata.id, + statusPageUrl, + } as Config; + + return { + ...metadata, + statusPageUrl, + adapter: createAdapter(adapterConfig), + }; +} diff --git a/extensions/ai-status-check/src/providers/provider-sections.ts b/extensions/ai-status-check/src/providers/provider-sections.ts new file mode 100644 index 00000000000..a744de4f287 --- /dev/null +++ b/extensions/ai-status-check/src/providers/provider-sections.ts @@ -0,0 +1,67 @@ +import { highestHealth, isIssue } from "../domain/derive-health"; +import type { ProviderCategory, ProviderStatusRecord } from "../domain/types"; +import { PROVIDER_CATEGORY_ORDER, PROVIDER_CATEGORY_TITLES } from "./categories"; +import type { ProviderDefinition } from "./types"; + +interface ProviderSection { + id: string; + title: string; + providers: readonly ProviderDefinition[]; +} + +export function buildProviderSections( + providers: readonly ProviderDefinition[], + records: Record, +): ProviderSection[] { + const issues: ProviderDefinition[] = []; + const maintenance: ProviderDefinition[] = []; + const unavailable: ProviderDefinition[] = []; + const categories = new Map(); + + for (const provider of providers) { + const record = records[provider.id] ?? unavailableProviderRecord(provider.id); + if (!record.snapshot || record.freshness === "expired" || record.freshness === "unavailable") { + unavailable.push(provider); + continue; + } + + const activeIncidentHealth = highestHealth( + record.snapshot.incidents + .filter((incident) => incident.state !== "resolved") + .map((incident) => (incident.state === "scheduled" ? "maintenance" : incident.health)), + ); + const sortableHealth = record.snapshot.health === "unknown" ? activeIncidentHealth : record.snapshot.health; + + if (isIssue(sortableHealth)) { + issues.push(provider); + } else if (sortableHealth === "maintenance") { + maintenance.push(provider); + } else { + const category = categories.get(provider.category) ?? []; + category.push(provider); + categories.set(provider.category, category); + } + } + + const sections: ProviderSection[] = []; + if (issues.length > 0) sections.push({ id: "issues", title: "Issues", providers: issues }); + if (maintenance.length > 0) sections.push({ id: "maintenance", title: "Maintenance", providers: maintenance }); + + for (const category of PROVIDER_CATEGORY_ORDER) { + const categoryProviders = categories.get(category); + if (categoryProviders?.length) { + sections.push({ id: category, title: PROVIDER_CATEGORY_TITLES[category], providers: categoryProviders }); + } + } + + if (unavailable.length > 0) sections.push({ id: "unavailable", title: "Unavailable", providers: unavailable }); + return sections; +} + +export function unavailableProviderRecord(providerId: string): ProviderStatusRecord { + return { + providerId, + freshness: "unavailable", + refreshState: "idle", + }; +} diff --git a/extensions/ai-status-check/src/providers/registry.ts b/extensions/ai-status-check/src/providers/registry.ts new file mode 100644 index 00000000000..b4c0ca4dadc --- /dev/null +++ b/extensions/ai-status-check/src/providers/registry.ts @@ -0,0 +1,66 @@ +import type { ProviderDefinition } from "./types"; +import { basetenProvider } from "./catalog/baseten"; +import { cerebrasProvider } from "./catalog/cerebras"; +import { claudeProvider } from "./catalog/claude"; +import { cohereProvider } from "./catalog/cohere"; +import { deepseekProvider } from "./catalog/deepseek"; +import { elevenLabsProvider } from "./catalog/elevenlabs"; +import { fireworksProvider } from "./catalog/fireworks"; +import { geminiProvider } from "./catalog/gemini"; +import { groqProvider } from "./catalog/groq"; +import { huggingFaceProvider } from "./catalog/hugging-face"; +import { mistralProvider } from "./catalog/mistral"; +import { openaiProvider } from "./catalog/openai"; +import { openRouterProvider } from "./catalog/openrouter"; +import { perplexityProvider } from "./catalog/perplexity"; +import { replicateProvider } from "./catalog/replicate"; +import { stabilityProvider } from "./catalog/stability"; +import { togetherProvider } from "./catalog/together"; +import { xaiProvider } from "./catalog/xai"; + +export const PROVIDERS = [ + openaiProvider, + claudeProvider, + geminiProvider, + xaiProvider, + deepseekProvider, + mistralProvider, + cohereProvider, + perplexityProvider, + openRouterProvider, + groqProvider, + togetherProvider, + fireworksProvider, + cerebrasProvider, + replicateProvider, + huggingFaceProvider, + basetenProvider, + elevenLabsProvider, + stabilityProvider, +] as const satisfies readonly ProviderDefinition[]; + +export type ProviderPreferenceKey = (typeof PROVIDERS)[number]["preferenceKey"]; +export type ProviderPreferences = Partial>; + +export const DEFAULT_ENABLED_PROVIDER_IDS = [ + "openai", + "claude", + "gemini-api", + "xai", + "deepseek", + "openrouter", +] as const; + +const DEFAULT_ENABLED_PROVIDERS = new Set(DEFAULT_ENABLED_PROVIDER_IDS); + +export function getEnabledProviders( + preferences: ProviderPreferences, +): readonly ProviderDefinition[] { + return PROVIDERS.filter( + (provider) => preferences[provider.preferenceKey] ?? DEFAULT_ENABLED_PROVIDERS.has(provider.id), + ); +} + +export function isProviderEnabledByDefault(providerId: string): boolean { + return DEFAULT_ENABLED_PROVIDERS.has(providerId); +} diff --git a/extensions/ai-status-check/src/providers/types.ts b/extensions/ai-status-check/src/providers/types.ts new file mode 100644 index 00000000000..d8b5fcf46e4 --- /dev/null +++ b/extensions/ai-status-check/src/providers/types.ts @@ -0,0 +1,26 @@ +import type { ComponentHistory, ProviderCategory, ProviderSnapshot } from "../domain/types"; + +export interface ProviderAdapter { + fetch(signal: AbortSignal): Promise; + /** Optional lazy path for providers that expose history only on per-component pages. */ + fetchComponentHistory?(componentId: string, signal: AbortSignal): Promise; +} + +export interface ProviderAdapterConfig { + providerId: string; + statusPageUrl: string; + now?: () => Date; +} + +export type ProviderAdapterFactory = (config: Config) => ProviderAdapter; + +export interface ProviderDefinition { + id: string; + name: string; + aliases: string[]; + category: ProviderCategory; + preferenceKey: PreferenceKey; + icon: string; + statusPageUrl: string; + adapter: ProviderAdapter; +} diff --git a/extensions/ai-status-check/src/providers/utils/component-history.ts b/extensions/ai-status-check/src/providers/utils/component-history.ts new file mode 100644 index 00000000000..239e4af83ce --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/component-history.ts @@ -0,0 +1,115 @@ +import type { ComponentHistory, ComponentHistoryDay, ComponentHistoryLevel, Health } from "../../domain/types"; + +const LEVEL_SEVERITY: Readonly> = { + not_monitored: -1, + operational: 0, + unknown: 1, + informational: 2, + maintenance: 3, + degraded: 4, + partial_outage: 5, + major_outage: 6, +}; + +export function historyLevelFromHealth(health: Health): ComponentHistoryLevel { + switch (health) { + case "operational": + return "operational"; + case "degraded": + return "degraded"; + case "partial_outage": + return "partial_outage"; + case "major_outage": + return "major_outage"; + case "maintenance": + return "maintenance"; + case "unknown": + return "unknown"; + } +} + +export function historyWindow( + windowDays: number, + now: Date, + defaultLevel: ComponentHistoryLevel = "operational", +): ComponentHistoryDay[] { + const end = startOfUtcDay(now); + return Array.from({ length: windowDays }, (_, index) => { + const date = new Date(end); + date.setUTCDate(end.getUTCDate() - (windowDays - 1 - index)); + return { date: dateKey(date), level: defaultLevel }; + }); +} + +export function applyHistoryRange( + days: ComponentHistoryDay[], + startAt: string | Date, + endAt: string | Date, + level: ComponentHistoryLevel, +): void { + const start = parsedDate(startAt); + const end = parsedDate(endAt); + if (!start || !end || end < start) return; + + const first = dateKey(start); + const last = dateKey(end); + for (const day of days) { + if (day.date < first || day.date > last) continue; + if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[day.level]) day.level = level; + } +} + +export function markBeforeMonitoredSince(days: ComponentHistoryDay[], monitoredSince: string | undefined): void { + const parsed = monitoredSince ? parsedDate(monitoredSince) : undefined; + if (!parsed) return; + const firstMonitoredDate = dateKey(parsed); + for (const day of days) { + if (day.date < firstMonitoredDate) day.level = "not_monitored"; + } +} + +export function componentHistory( + basis: ComponentHistory["basis"], + days: ComponentHistoryDay[], + options: Pick = {}, +): ComponentHistory | undefined { + if (days.length === 0) return undefined; + const uptimePercent = finitePercent(options.uptimePercent); + const uptimeText = uptimePercent === undefined ? undefined : publishedPercentText(options.uptimeText); + const monitoredSinceDate = options.monitoredSince ? parsedDate(options.monitoredSince) : undefined; + const monitoredSince = monitoredSinceDate ? dateKey(monitoredSinceDate) : undefined; + return { + basis, + windowDays: days.length, + days, + ...(uptimePercent === undefined ? {} : { uptimePercent }), + ...(uptimeText ? { uptimeText } : {}), + ...(monitoredSince ? { monitoredSince } : {}), + }; +} + +export function finitePercent(value: unknown): number | undefined { + const number = typeof value === "number" ? value : typeof value === "string" ? Number.parseFloat(value) : Number.NaN; + return Number.isFinite(number) && number >= 0 && number <= 100 ? number : undefined; +} + +export function publishedPercentText(value: unknown): string | undefined { + if (typeof value !== "string" && typeof value !== "number") return undefined; + const text = String(value).trim().replace(/\s*%$/, ""); + return finitePercent(text) === undefined ? undefined : `${text}%`; +} + +export function dateKey(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function startOfUtcDay(value: Date): Date { + const date = new Date(value); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +function parsedDate(value: string | Date): Date | undefined { + const date = value instanceof Date ? new Date(value) : new Date(value); + return Number.isFinite(date.getTime()) ? startOfUtcDay(date) : undefined; +} diff --git a/extensions/ai-status-check/src/providers/utils/http.ts b/extensions/ai-status-check/src/providers/utils/http.ts new file mode 100644 index 00000000000..61fb9d9a28b --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/http.ts @@ -0,0 +1,24 @@ +export type FetchJson = (url: string, signal: AbortSignal, init?: RequestInit) => Promise; +export type FetchText = (url: string, signal: AbortSignal, init?: RequestInit) => Promise; + +export const fetchJson: FetchJson = async (url, signal, init = {}) => { + const response = await fetch(url, { + ...init, + headers: { Accept: "application/json", ...init.headers }, + signal, + }); + + if (!response.ok) throw new Error(`Status source returned HTTP ${response.status}`); + return response.json() as Promise; +}; + +export const fetchText: FetchText = async (url, signal, init = {}) => { + const response = await fetch(url, { + ...init, + headers: { Accept: "text/html", ...init.headers }, + signal, + }); + + if (!response.ok) throw new Error(`Status source returned HTTP ${response.status}`); + return response.text(); +}; diff --git a/extensions/ai-status-check/src/providers/utils/incidents.ts b/extensions/ai-status-check/src/providers/utils/incidents.ts new file mode 100644 index 00000000000..44e748911de --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/incidents.ts @@ -0,0 +1,16 @@ +import type { Incident } from "../../domain/types"; +import { parseTimestamp } from "../../utils/dates"; + +export function incidentActivityTime(incident: Incident): number { + return parseTimestamp(incident.resolvedAt ?? incident.updatedAt ?? incident.startedAt); +} + +export function sortIncidentsByActivity(incidents: readonly Incident[]): Incident[] { + return [...incidents].sort((left, right) => incidentActivityTime(right) - incidentActivityTime(left)); +} + +export function mergeIncidents(history: readonly Incident[], preferred: readonly Incident[] = []): Incident[] { + const merged = new Map(history.map((incident) => [incident.id, incident])); + for (const incident of preferred) merged.set(incident.id, incident); + return sortIncidentsByActivity([...merged.values()]); +} diff --git a/extensions/ai-status-check/src/providers/utils/optional-enrichment.ts b/extensions/ai-status-check/src/providers/utils/optional-enrichment.ts new file mode 100644 index 00000000000..47f8cc914e0 --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/optional-enrichment.ts @@ -0,0 +1,63 @@ +import { isRequestTimeoutError } from "../../utils/request-timeout"; + +const DEFAULT_OPTIONAL_ENRICHMENT_TIMEOUT_MS = 6_000; +const OMITTED = Symbol("optional enrichment omitted"); + +/** + * Run non-essential source enrichment without letting it consume the provider's + * full request budget. External cancellation still propagates immediately; + * expiry of the provider deadline omits enrichment so completed core data wins. + */ +export async function fetchOptionalEnrichment( + parentSignal: AbortSignal, + request: (signal: AbortSignal) => Promise, + timeoutMs = DEFAULT_OPTIONAL_ENRICHMENT_TIMEOUT_MS, +): Promise { + if (parentSignal.aborted) { + if (isRequestTimeoutError(parentSignal.reason)) return undefined; + throw abortError(parentSignal.reason); + } + + const controller = new AbortController(); + let onParentAbort: (() => void) | undefined; + let timeout: ReturnType | undefined; + + const parentCancellation = new Promise((resolve, reject) => { + onParentAbort = () => { + const error = abortError(parentSignal.reason); + controller.abort(error); + if (isRequestTimeoutError(parentSignal.reason)) { + resolve(OMITTED); + } else { + reject(error); + } + }; + parentSignal.addEventListener("abort", onParentAbort, { once: true }); + }); + const localTimeout = new Promise((resolve) => { + timeout = setTimeout(() => { + controller.abort(new Error("Optional status history timed out")); + resolve(OMITTED); + }, timeoutMs); + }); + + try { + const result = await Promise.race([request(controller.signal), parentCancellation, localTimeout]); + if (parentSignal.aborted && !isRequestTimeoutError(parentSignal.reason)) throw abortError(parentSignal.reason); + return result === OMITTED ? undefined : result; + } catch { + if (parentSignal.aborted) { + if (isRequestTimeoutError(parentSignal.reason)) return undefined; + throw abortError(parentSignal.reason); + } + return undefined; + } finally { + if (timeout) clearTimeout(timeout); + if (onParentAbort) parentSignal.removeEventListener("abort", onParentAbort); + } +} + +function abortError(reason: unknown): Error { + if (reason instanceof Error) return reason; + return new Error(typeof reason === "string" && reason ? reason : "Status request aborted"); +} diff --git a/extensions/ai-status-check/src/providers/utils/rss.ts b/extensions/ai-status-check/src/providers/utils/rss.ts new file mode 100644 index 00000000000..796b10812aa --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/rss.ts @@ -0,0 +1,82 @@ +export interface RssItem { + title: string; + link?: string; + guid?: string; + description?: string; + publishedAt?: string; + categories: string[]; +} + +export function parseRssItems(xml: string): RssItem[] { + if (!/ ({ + title: tag(item, "title") ?? "", + link: normalizeUrl(tag(item, "link")), + guid: tag(item, "guid"), + description: tag(item, "description"), + publishedAt: tag(item, "pubDate"), + categories: matches(item, "category").map(cleanXmlValue), + })) + .filter((item) => item.title.length > 0); +} + +export function stripHtml(value: string): string { + return decodeEntities( + value + .replaceAll(//gi, "\n") + .replaceAll(/<\/p>|<\/li>|<\/h\d>/gi, "\n") + .replaceAll(/<[^>]+>/g, " ") + .replaceAll(/[ \t]+/g, " ") + .replaceAll(/\n\s+/g, "\n") + .trim(), + ); +} + +function tag(xml: string, name: string): string | undefined { + const pattern = new RegExp(`<${escapePattern(name)}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${escapePattern(name)}>`, "i"); + const value = pattern.exec(xml)?.[1]; + return value === undefined ? undefined : cleanXmlValue(value); +} + +function matches(xml: string, name: string): string[] { + const pattern = new RegExp(`<${escapePattern(name)}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${escapePattern(name)}>`, "gi"); + return [...xml.matchAll(pattern)].map((match) => match[1] ?? ""); +} + +function cleanXmlValue(value: string): string { + return decodeEntities( + value + .replace(/^\s*\s*$/, "") + .trim(), + ); +} + +function normalizeUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + if (/^https?:\/\//i.test(value)) return value; + return `https://${value.replace(/^\/+/, "")}`; +} + +function decodeEntities(value: string): string { + const named: Record = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + quot: '"', + nbsp: " ", + }; + + return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, body: string) => { + if (body.startsWith("#x")) return String.fromCodePoint(Number.parseInt(body.slice(2), 16)); + if (body.startsWith("#")) return String.fromCodePoint(Number.parseInt(body.slice(1), 10)); + return named[body.toLowerCase()] ?? entity; + }); +} + +function escapePattern(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/extensions/ai-status-check/src/providers/utils/runtime-values.ts b/extensions/ai-status-check/src/providers/utils/runtime-values.ts new file mode 100644 index 00000000000..1d3f3541c60 --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/runtime-values.ts @@ -0,0 +1,23 @@ +export type JsonRecord = Record; + +export function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) throw new Error(`Invalid ${label} response`); + return value; +} + +export function optionalRecordArray(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord); +} + +export function optionalRecord(value: unknown): JsonRecord | undefined { + return isRecord(value) ? value : undefined; +} + +export function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/extensions/ai-status-check/src/providers/utils/status-normalization.ts b/extensions/ai-status-check/src/providers/utils/status-normalization.ts new file mode 100644 index 00000000000..3618ee1bec7 --- /dev/null +++ b/extensions/ai-status-check/src/providers/utils/status-normalization.ts @@ -0,0 +1,67 @@ +import type { Health, IncidentState } from "../../domain/types"; +import { normalizeStatusToken } from "../../utils/status-token"; + +const HEALTH_BY_STATUS: Readonly>> = { + up: "operational", + available: "operational", + good: "operational", + none: "operational", + operational: "operational", + resolved: "operational", + all_system_operational: "operational", + all_systems_operational: "operational", + fully_operational: "operational", + no_incidents: "operational", + no_incidents_declared: "operational", + maintenance: "maintenance", + under_maintenance: "maintenance", + maintenance_scheduled: "maintenance", + scheduled: "maintenance", + degraded: "degraded", + degraded_performance: "degraded", + disruption: "degraded", + minor: "degraded", + warning: "degraded", + partial: "partial_outage", + partial_outage: "partial_outage", + medium: "partial_outage", + down: "major_outage", + downtime: "major_outage", + major: "major_outage", + major_outage: "major_outage", + full_outage: "major_outage", + critical: "major_outage", + outage: "major_outage", + disrupted: "major_outage", + high: "major_outage", +}; + +const INCIDENT_STATE_BY_STATUS: Readonly>> = { + resolved: "resolved", + completed: "resolved", + available: "resolved", + closed: "resolved", + identified: "identified", + monitoring: "monitoring", + mitigated: "monitoring", + update: "monitoring", + scheduled: "scheduled", + maintenance: "scheduled", + maintenance_scheduled: "scheduled", + in_progress: "scheduled", + investigating: "investigating", + detected: "investigating", + open: "investigating", +}; + +export function mapFlexibleHealth(value: string | undefined): Health { + return HEALTH_BY_STATUS[normalizeStatusToken(value ?? "")] ?? "unknown"; +} + +export function mapFlexibleIncidentState(value: string | undefined): IncidentState { + return INCIDENT_STATE_BY_STATUS[normalizeStatusToken(value ?? "")] ?? "unknown"; +} + +export function statusComponentId(name: string): string { + return normalizeStatusToken(name).replaceAll("_", "-"); +} diff --git a/extensions/ai-status-check/src/services/fetch-provider-statuses.ts b/extensions/ai-status-check/src/services/fetch-provider-statuses.ts new file mode 100644 index 00000000000..b9f5a540666 --- /dev/null +++ b/extensions/ai-status-check/src/services/fetch-provider-statuses.ts @@ -0,0 +1,109 @@ +import { getDataFreshness } from "../domain/freshness"; +import type { ProviderStatusRecord } from "../domain/types"; +import type { ProviderDefinition } from "../providers/types"; +import { RequestTimeoutError } from "../utils/request-timeout"; +import type { StatusCache } from "./status-cache"; + +const DEFAULT_REQUEST_TIMEOUT_MS = 8_000; +const DEFAULT_CONCURRENCY = 6; + +interface RefreshProviderOptions { + cache: StatusCache; + force?: boolean; + isCurrent?(providerId: string): boolean; + now?: () => number; + signal?: AbortSignal; + timeoutMs?: number; +} + +interface RefreshProvidersOptions extends RefreshProviderOptions { + concurrency?: number; +} + +export function recordFromCache(providerId: string, cache: StatusCache, now = Date.now()): ProviderStatusRecord { + const snapshot = cache.getSnapshot(providerId); + return { + providerId, + snapshot, + freshness: getDataFreshness(snapshot, now), + refreshState: "idle", + }; +} + +export async function refreshProviderStatus( + provider: ProviderDefinition, + options: RefreshProviderOptions, +): Promise { + const now = options.now ?? Date.now; + const cachedSnapshot = options.cache.getSnapshot(provider.id); + const cachedFreshness = getDataFreshness(cachedSnapshot, now()); + + if (!options.force && cachedFreshness === "fresh") { + return { + providerId: provider.id, + snapshot: cachedSnapshot, + freshness: cachedFreshness, + refreshState: "idle", + }; + } + + const timeoutController = new AbortController(); + const abortFromParent = () => timeoutController.abort(options.signal?.reason); + options.signal?.addEventListener("abort", abortFromParent, { once: true }); + if (options.signal?.aborted) abortFromParent(); + const timeout = setTimeout( + () => timeoutController.abort(new RequestTimeoutError("Status request timed out")), + options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + ); + + try { + const snapshot = await provider.adapter.fetch(timeoutController.signal); + if (options.isCurrent?.(provider.id) !== false) options.cache.setSnapshot(snapshot); + + return { + providerId: provider.id, + snapshot, + freshness: "fresh", + refreshState: "idle", + }; + } catch (error) { + return { + providerId: provider.id, + snapshot: cachedSnapshot, + freshness: getDataFreshness(cachedSnapshot, now()), + refreshState: "failed", + refreshError: errorMessage(error), + }; + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortFromParent); + } +} + +export async function refreshProviderStatuses( + providers: readonly ProviderDefinition[], + options: RefreshProvidersOptions, +): Promise { + if (providers.length === 0) return []; + + const results = new Array(providers.length); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, providers.length)); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < providers.length) { + const index = nextIndex++; + const provider = providers[index]; + const result = await refreshProviderStatus(provider, options); + results[index] = result; + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "Could not retrieve provider status"; +} diff --git a/extensions/ai-status-check/src/services/provider-request-order.ts b/extensions/ai-status-check/src/services/provider-request-order.ts new file mode 100644 index 00000000000..82f6024ebe2 --- /dev/null +++ b/extensions/ai-status-check/src/services/provider-request-order.ts @@ -0,0 +1,15 @@ +export type ProviderRequestToken = number; + +export class ProviderRequestOrder { + readonly #latest = new Map(); + + begin(providerId: string): ProviderRequestToken { + const token = (this.#latest.get(providerId) ?? 0) + 1; + this.#latest.set(providerId, token); + return token; + } + + isCurrent(providerId: string, token: ProviderRequestToken | undefined): boolean { + return token !== undefined && this.#latest.get(providerId) === token; + } +} diff --git a/extensions/ai-status-check/src/services/status-cache.ts b/extensions/ai-status-check/src/services/status-cache.ts new file mode 100644 index 00000000000..8ec95fd67b3 --- /dev/null +++ b/extensions/ai-status-check/src/services/status-cache.ts @@ -0,0 +1,54 @@ +import { Cache } from "@raycast/api"; +import type { ProviderSnapshot } from "../domain/types"; + +const CACHE_VERSION = 2; +const CACHE_PREFIX = `provider-status:v${CACHE_VERSION}:`; + +export interface StatusCache { + getSnapshot(providerId: string): ProviderSnapshot | undefined; + setSnapshot(snapshot: ProviderSnapshot): void; +} + +export class RaycastStatusCache implements StatusCache { + readonly #cache: Cache; + + constructor(cache = new Cache()) { + this.#cache = cache; + } + + getSnapshot(providerId: string): ProviderSnapshot | undefined { + const raw = this.#cache.get(cacheKey(providerId)); + if (!raw) return undefined; + + try { + const value: unknown = JSON.parse(raw); + return isProviderSnapshot(value, providerId) ? value : undefined; + } catch { + return undefined; + } + } + + setSnapshot(snapshot: ProviderSnapshot): void { + this.#cache.set(cacheKey(snapshot.providerId), JSON.stringify(snapshot)); + } +} + +function cacheKey(providerId: string): string { + return `${CACHE_PREFIX}${providerId}`; +} + +function isProviderSnapshot(value: unknown, providerId: string): value is ProviderSnapshot { + if (!isRecord(value)) return false; + + return ( + value.providerId === providerId && + typeof value.health === "string" && + typeof value.fetchedAt === "string" && + Array.isArray(value.components) && + Array.isArray(value.incidents) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/extensions/ai-status-check/src/utils/component-history-markdown.ts b/extensions/ai-status-check/src/utils/component-history-markdown.ts new file mode 100644 index 00000000000..9ed99a1047b --- /dev/null +++ b/extensions/ai-status-check/src/utils/component-history-markdown.ts @@ -0,0 +1,43 @@ +import type { ComponentHistory, ComponentHistoryLevel } from "../domain/types"; + +const LEVEL_COLOR: Readonly> = { + operational: "#34C759", + informational: "#0A84FF", + maintenance: "#8E8E93", + degraded: "#FFD60A", + partial_outage: "#FF9F0A", + major_outage: "#FF453A", + not_monitored: "#636366", + unknown: "#636366", +}; + +export function buildComponentHistoryMarkdown(history: ComponentHistory | undefined): string | undefined { + if (!history?.days.length) return undefined; + const chart = buildComponentHistoryChart(history); + const summary = + history.uptimePercent === undefined + ? `Past ${history.windowDays} days — ${history.basis === "incidents" ? "incident history" : "availability history"}` + : `Past ${history.windowDays} days — **${formatUptimePercent(history.uptimePercent, history.uptimeText)} uptime**`; + return `${chart}\n\n${summary}`; +} + +export function buildComponentHistoryChart(history: ComponentHistory, width = 540, height = 32): string { + const days = history.days; + if (days.length === 0) return ""; + const gap = 2; + const barWidth = Math.max(1, (width - gap * (days.length - 1)) / days.length); + const rectangles = days + .map((day, index) => { + const x = index * (barWidth + gap); + return ``; + }) + .join(""); + const svg = `${rectangles}`; + return `![Component status history](data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")})`; +} + +export function formatUptimePercent(value: number | undefined, sourceText?: string): string | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + if (sourceText) return sourceText; + return `${Number(value.toFixed(6))}%`; +} diff --git a/extensions/ai-status-check/src/utils/dates.ts b/extensions/ai-status-check/src/utils/dates.ts new file mode 100644 index 00000000000..5ee5d884764 --- /dev/null +++ b/extensions/ai-status-check/src/utils/dates.ts @@ -0,0 +1,37 @@ +const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + +export function parseTimestamp(value: string | undefined): number { + if (!value) return 0; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function formatRelativeTime(value: string | undefined, now = Date.now()): string | undefined { + if (!value) return undefined; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return undefined; + + const differenceSeconds = Math.round((timestamp - now) / 1_000); + const absoluteSeconds = Math.abs(differenceSeconds); + if (absoluteSeconds < 60) return relativeTime.format(differenceSeconds, "second"); + + const differenceMinutes = Math.round(differenceSeconds / 60); + if (Math.abs(differenceMinutes) < 60) return relativeTime.format(differenceMinutes, "minute"); + + const differenceHours = Math.round(differenceMinutes / 60); + if (Math.abs(differenceHours) < 24) return relativeTime.format(differenceHours, "hour"); + + const differenceDays = Math.round(differenceHours / 24); + return relativeTime.format(differenceDays, "day"); +} + +export function formatDateTime(value: string | undefined): string | undefined { + if (!value) return undefined; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return undefined; + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} diff --git a/extensions/ai-status-check/src/utils/incident-markdown.ts b/extensions/ai-status-check/src/utils/incident-markdown.ts new file mode 100644 index 00000000000..05db9dfa6d4 --- /dev/null +++ b/extensions/ai-status-check/src/utils/incident-markdown.ts @@ -0,0 +1,35 @@ +import { incidentImpactLabel, incidentStateLabel, incidentUpdateStateLabel } from "../domain/status-presentation"; +import type { Incident } from "../domain/types"; +import { formatDateTime } from "./dates"; +import { escapeMarkdown } from "./markdown"; + +export function buildIncidentMarkdown(incident: Incident): string { + const lines = [ + `# ${escapeMarkdown(incident.title)}`, + "", + `- **State:** ${escapeMarkdown(incidentStateLabel(incident))}`, + ]; + const impact = incidentImpactLabel(incident); + if (impact) lines.push(`- **Impact:** ${escapeMarkdown(impact)}`); + + const startedAt = formatDateTime(incident.startedAt); + const updatedAt = formatDateTime(incident.updatedAt); + const resolvedAt = formatDateTime(incident.resolvedAt); + if (startedAt) lines.push(`- **Started:** ${startedAt}`); + if (updatedAt) lines.push(`- **Last updated:** ${updatedAt}`); + if (resolvedAt) lines.push(`- **Resolved:** ${resolvedAt}`); + + if (incident.updates.length > 0) { + lines.push("", "## Updates"); + for (const update of incident.updates) { + lines.push( + "", + `### ${escapeMarkdown(incidentUpdateStateLabel(update))} · ${formatDateTime(update.createdAt) ?? "Unknown time"}`, + "", + escapeMarkdown(update.body), + ); + } + } + + return lines.join("\n"); +} diff --git a/extensions/ai-status-check/src/utils/markdown.ts b/extensions/ai-status-check/src/utils/markdown.ts new file mode 100644 index 00000000000..9bafb24b9e3 --- /dev/null +++ b/extensions/ai-status-check/src/utils/markdown.ts @@ -0,0 +1,4 @@ +export function escapeMarkdown(value: string): string { + const markdownCharacters = new Set("\\`*_{}[]()#+.!<>|~-".split("")); + return [...value].map((character) => (markdownCharacters.has(character) ? `\\${character}` : character)).join(""); +} diff --git a/extensions/ai-status-check/src/utils/request-timeout.ts b/extensions/ai-status-check/src/utils/request-timeout.ts new file mode 100644 index 00000000000..4c27fa7a197 --- /dev/null +++ b/extensions/ai-status-check/src/utils/request-timeout.ts @@ -0,0 +1,7 @@ +export class RequestTimeoutError extends Error { + override readonly name = "RequestTimeoutError"; +} + +export function isRequestTimeoutError(value: unknown): value is RequestTimeoutError { + return value instanceof RequestTimeoutError; +} diff --git a/extensions/ai-status-check/src/utils/status-token.ts b/extensions/ai-status-check/src/utils/status-token.ts new file mode 100644 index 00000000000..e6c48d8eeaa --- /dev/null +++ b/extensions/ai-status-check/src/utils/status-token.ts @@ -0,0 +1,7 @@ +export function normalizeStatusToken(value: string): string { + return value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "_") + .replaceAll(/^_|_$/g, ""); +} diff --git a/extensions/ai-status-check/src/utils/url.ts b/extensions/ai-status-check/src/utils/url.ts new file mode 100644 index 00000000000..87239db9a9a --- /dev/null +++ b/extensions/ai-status-check/src/utils/url.ts @@ -0,0 +1,7 @@ +export function withoutTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function withTrailingSlash(value: string): string { + return `${withoutTrailingSlash(value)}/`; +} diff --git a/extensions/ai-status-check/tests/component-history.test.ts b/extensions/ai-status-check/tests/component-history.test.ts new file mode 100644 index 00000000000..dbae102f980 --- /dev/null +++ b/extensions/ai-status-check/tests/component-history.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + applyHistoryRange, + componentHistory, + historyWindow, + markBeforeMonitoredSince, +} from "../src/providers/utils/component-history"; +import { + buildComponentHistoryMarkdown, + formatUptimePercent, +} from "../src/utils/component-history-markdown"; + +test("normalizes calendar history without treating pre-monitoring days as healthy", () => { + const days = historyWindow(3, new Date("2026-08-11T16:00:00Z")); + applyHistoryRange(days, "2026-08-10T10:00:00Z", "2026-08-10T11:00:00Z", "partial_outage"); + markBeforeMonitoredSince(days, "2026-08-10T00:00:00Z"); + + assert.deepEqual(days, [ + { date: "2026-08-09", level: "not_monitored" }, + { date: "2026-08-10", level: "partial_outage" }, + { date: "2026-08-11", level: "operational" }, + ]); +}); + +test("keeps an unfamiliar incident state visible instead of leaving the day green", () => { + const days = historyWindow(1, new Date("2026-08-11T16:00:00Z")); + applyHistoryRange(days, "2026-08-11T10:00:00Z", "2026-08-11T11:00:00Z", "unknown"); + + assert.deepEqual(days, [{ date: "2026-08-11", level: "unknown" }]); +}); + +test("preserves provider precision but never derives uptime from green days", () => { + const days = historyWindow(2, new Date("2026-08-11T16:00:00Z")); + const chartOnly = componentHistory("incidents", days); + const measured = componentHistory("availability", days, { uptimePercent: 99.996, uptimeText: "99.996%" }); + + assert.doesNotMatch(buildComponentHistoryMarkdown(chartOnly) ?? "", /% uptime/); + assert.match(buildComponentHistoryMarkdown(measured) ?? "", /99\.996% uptime/); + assert.equal(formatUptimePercent(99.996), "99.996%"); + assert.equal(formatUptimePercent(99.9, "99.90%"), "99.90%"); + assert.equal(formatUptimePercent(100), "100%"); +}); + +test("encodes every status level into the SVG chart", () => { + const history = componentHistory("availability", [ + { date: "2026-08-09", level: "operational" }, + { date: "2026-08-10", level: "degraded" }, + { date: "2026-08-11", level: "major_outage" }, + ]); + const markdown = buildComponentHistoryMarkdown(history); + const encoded = /base64,([^\)]+)/.exec(markdown ?? "")?.[1]; + assert.ok(encoded); + const svg = Buffer.from(encoded, "base64").toString("utf8"); + assert.match(svg, /#34C759/); + assert.match(svg, /#FFD60A/); + assert.match(svg, /#FF453A/); +}); diff --git a/extensions/ai-status-check/tests/derive-health.test.ts b/extensions/ai-status-check/tests/derive-health.test.ts new file mode 100644 index 00000000000..903311e06a0 --- /dev/null +++ b/extensions/ai-status-check/tests/derive-health.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { deriveProviderHealth, highestHealth } from "../src/domain/derive-health"; +import type { ComponentStatus, Incident } from "../src/domain/types"; + +test("highestHealth returns the most severe known status", () => { + assert.equal(highestHealth(["operational", "degraded", "unknown", "partial_outage"]), "partial_outage"); + assert.equal(highestHealth(["unknown"]), "unknown"); +}); + +test("uses a provider-published overall status without overriding it", () => { + const components: ComponentStatus[] = [{ id: "api", name: "API", health: "operational" }]; + const activeIncident: Incident = { + id: "incident", + title: "Outage under observation", + health: "major_outage", + state: "monitoring", + affectedComponentIds: ["api"], + updates: [], + }; + + assert.equal(deriveProviderHealth("operational", components, [activeIncident]), "operational"); + assert.equal(deriveProviderHealth("partial_outage", components, []), "partial_outage"); +}); + +test("aggregates components and incidents only when no overall status is published", () => { + const scheduled: Incident = { + id: "maintenance", + title: "Scheduled maintenance", + health: "unknown", + state: "scheduled", + affectedComponentIds: [], + updates: [], + }; + + assert.equal(deriveProviderHealth("unknown", [], [scheduled]), "maintenance"); + assert.equal( + deriveProviderHealth("unknown", [{ id: "api", name: "API", health: "partial_outage" }], [scheduled]), + "partial_outage", + ); +}); diff --git a/extensions/ai-status-check/tests/extended-adapters.test.ts b/extensions/ai-status-check/tests/extended-adapters.test.ts new file mode 100644 index 00000000000..b4b09aa3e0c --- /dev/null +++ b/extensions/ai-status-check/tests/extended-adapters.test.ts @@ -0,0 +1,408 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import type { Incident } from "../src/domain/types"; +import { parseBetterStack } from "../src/providers/adapters/betterstack"; +import { parseFlashcatStatus } from "../src/providers/adapters/flashcat"; +import { geminiComponents, parseGeminiBootConfig, parseGeminiIncidents } from "../src/providers/adapters/gemini"; +import { + parseInstatus, + parseInstatusComponentHistories, + parseInstatusHistory, +} from "../src/providers/adapters/instatus"; +import { parseMistralStatusPage } from "../src/providers/adapters/mistral"; +import { parseOpenRouterStatusPage } from "../src/providers/adapters/openrouter"; +import { parseIncidentRss } from "../src/providers/adapters/page-and-feed"; +import { parseXaiComponentHistory, parseXaiStatusPage } from "../src/providers/adapters/xai"; + +test("normalizes Better Stack sections, resources, incidents, and updates", async () => { + const parsed = parseBetterStack(await jsonFixture("betterstack/index-degraded.json")); + + assert.equal(parsed.reportedHealth, "degraded"); + assert.equal(parsed.statusText, "degraded"); + assert.deepEqual(parsed.components[0], { + id: "api", + name: "Inference API", + health: "degraded", + statusText: "degraded", + group: "Inference", + history: { + basis: "availability", + windowDays: 3, + days: [ + { date: "2026-08-09", level: "not_monitored" }, + { date: "2026-08-10", level: "major_outage" }, + { date: "2026-08-11", level: "operational" }, + ], + uptimePercent: 99.5, + uptimeText: "99.500%", + monitoredSince: "2026-08-10", + }, + }); + assert.equal(parsed.incidents[0]?.state, "investigating"); + assert.equal(parsed.incidents[0]?.stateText, "degraded"); + assert.equal(parsed.incidents[0]?.updates[0]?.body, "We are investigating elevated latency."); + assert.equal(parsed.incidents[0]?.updates[0]?.stateText, "investigating"); + assert.throws(() => parseBetterStack({}), /Better Stack status page data/); +}); + +test("normalizes Instatus page and component health", async () => { + const parsed = parseInstatus( + await jsonFixture("instatus/summary.json"), + await jsonFixture("instatus/components.json"), + ); + + assert.equal(parsed.reportedHealth, "operational"); + assert.deepEqual( + parsed.components.map(({ id, group, health }) => ({ id, group, health })), + [ + { id: "api", group: "Services", health: "operational" }, + { id: "web", group: undefined, health: "degraded" }, + { id: "computer", group: undefined, health: "operational" }, + ], + ); + const incidents = parseInstatusHistory(await textFixture("instatus/history.rss"), parsed.components); + assert.equal(incidents[0]?.title, "Computer Sandbox Issue"); + assert.equal(incidents[0]?.state, "resolved"); + assert.equal(incidents[0]?.updates.length, 2); + assert.deepEqual(incidents[0]?.affectedComponentIds, ["computer"]); + assert.throws(() => parseInstatus({ page: {} }, { components: [] }), /did not contain a status/); +}); + +test("parses Instatus component uptime from its rendered flight payload", () => { + const flight = `"componentsUptime":${JSON.stringify({ + api: { + uptime: "99.75", + outages: [ + { + from: "2026-08-10T10:00:00Z", + to: "2026-08-10T11:00:00Z", + status: "PARTIALOUTAGE", + }, + ], + }, + })}`; + const html = ``; + const history = parseInstatusComponentHistories(html, new Date("2026-08-11T16:00:00Z")).get("api"); + + assert.equal(history?.uptimePercent, 99.75); + assert.equal(history?.uptimeText, "99.75%"); + assert.deepEqual(history?.days.slice(-2), [ + { date: "2026-08-10", level: "partial_outage" }, + { date: "2026-08-11", level: "operational" }, + ]); +}); + +test("normalizes Flashcat components, active changes, and incident history", async () => { + const parsed = parseFlashcatStatus( + await jsonFixture("flashcat/current.json"), + await jsonFixture("flashcat/history.json"), + "https://status.deepseek.com/", + await jsonFixture("flashcat/structure.json"), + new Date("2026-08-11T16:00:00Z"), + ); + + assert.equal(parsed.reportedHealth, "unknown"); + assert.equal(parsed.incidents.filter((incident) => incident.state !== "resolved").length, 1); + assert.deepEqual( + parsed.components.map(({ id, group, health }) => ({ id, group, health })), + [ + { id: "api", group: undefined, health: "operational" }, + { id: "chat", group: "Chat", health: "degraded" }, + ], + ); + assert.equal(parsed.components[1]?.statusText, "degraded"); + assert.equal(parsed.components[1]?.history?.uptimePercent, 99.8); + assert.equal(parsed.components[1]?.history?.uptimeText, "99.80%"); + assert.deepEqual(parsed.components[1]?.history?.days.slice(-3), [ + { date: "2026-08-09", level: "operational" }, + { date: "2026-08-10", level: "degraded" }, + { date: "2026-08-11", level: "operational" }, + ]); + assert.equal(parsed.incidents.length, 2); + assert.equal(parsed.incidents[0]?.state, "investigating"); + assert.equal(parsed.incidents[0]?.stateText, "investigating"); + assert.equal(parsed.incidents[1]?.state, "resolved"); + assert.equal(parsed.incidents[1]?.health, "degraded"); + assert.equal(parsed.incidents[1]?.url, "https://status.deepseek.com/incidents/100"); + assert.throws( + () => parseFlashcatStatus({ data: { page: { components: [] } } }, { data: { items: [] } }, "https://example.com"), + /contained no components/, + ); +}); + +test("normalizes server-rendered component pages and groups repeated RSS updates", async () => { + const mistralPage = await textFixture("html-rss/mistral.html"); + const mistral = parseMistralStatusPage( + mistralPage.replace( + '

    ', + `
    `, + ), + ); + const openRouter = parseOpenRouterStatusPage(await textFixture("html-rss/online-or-not.html")); + const incidents = parseIncidentRss(await textFixture("html-rss/incidents.rss")); + const xaiIncidents = parseIncidentRss(await textFixture("html-rss/xai-components.rss")); + + assert.equal(mistral.reportedHealth, "operational"); + assert.equal(mistral.statusText, "All systems operational"); + assert.deepEqual( + mistral.components.map(({ name, group, health }) => ({ name, group, health })), + [ + { name: "API", group: "La Plateforme", health: "operational" }, + { name: "Batch", group: "La Plateforme", health: "degraded" }, + ], + ); + assert.deepEqual( + openRouter.components.map(({ id, health, statusText }) => ({ id, health, statusText })), + [ + { id: "chat-api-v1-chat-completions", health: "operational", statusText: "Operational" }, + { id: "data-api", health: "operational", statusText: "Operational" }, + { id: "homepage", health: "operational", statusText: "Operational" }, + { id: "clerk-ui-account-auth", health: "operational", statusText: "Operational" }, + ], + ); + assert.equal(incidents.length, 1); + assert.equal(incidents[0]?.state, "resolved"); + assert.equal(incidents[0]?.stateText, "RESOLVED"); + assert.equal(incidents[0]?.updates.length, 2); + assert.deepEqual(incidents[0]?.affectedComponentIds, ["api"]); + assert.equal(xaiIncidents[0]?.state, "resolved"); + assert.equal(xaiIncidents[0]?.stateText, "RESOLVED"); + assert.equal(xaiIncidents[0]?.updates.length, 2); + assert.equal(xaiIncidents[0]?.updates.at(-1)?.state, "unknown"); + assert.equal(xaiIncidents[0]?.updates.at(-1)?.stateText, "Service Restored"); + assert.equal(xaiIncidents[0]?.startedAt, "2026-08-11T15:00:00.000Z"); + assert.equal(xaiIncidents[0]?.resolvedAt, "2026-08-11T15:30:00.000Z"); + assert.ok(xaiIncidents.every((incident) => incident.state === "resolved")); + assert.throws(() => parseMistralStatusPage(""), /contained no services/); + assert.throws(() => parseIncidentRss("not xml"), /was not RSS/); +}); + +test("preserves rendered Mistral monitoring gaps and exact uptime", () => { + const nuxtValues: unknown[] = [ + ["ShallowReactive", 1], + { data: 2 }, + ["ShallowReactive", 3], + { "uptime-example": 4 }, + { uptime: 5 }, + [6], + { services: 7 }, + [8], + { name: 9, days: 10, uptime: 11 }, + "API", + [12, 15], + 99.5, + { date: 13, events: 14 }, + "2026-08-10T00:00:00.000Z", + [], + { date: 16, events: 17 }, + "2026-08-11T00:00:00.000Z", + [18], + { severity: 19 }, + "MAJOR", + ]; + const html = `

    All systems operational

    `; + const component = parseMistralStatusPage(html, new Date("2026-08-11T16:00:00Z")).components[0]; + + assert.equal(component?.history?.uptimePercent, 99.5); + assert.equal(component?.history?.uptimeText, "99.5%"); + assert.equal(component?.history?.monitoredSince, "2026-08-10"); + assert.deepEqual(component?.history?.days.slice(-3), [ + { date: "2026-08-09", level: "not_monitored" }, + { date: "2026-08-10", level: "operational" }, + { date: "2026-08-11", level: "major_outage" }, + ]); +}); + +test("keeps unfamiliar Mistral history severity unknown", () => { + const nuxtValues: unknown[] = [ + ["ShallowReactive", 1], + { data: 2 }, + ["ShallowReactive", 3], + { "uptime-example": 4 }, + { uptime: 5 }, + [6], + { services: 7 }, + [8], + { name: 9, days: 10, uptime: 11 }, + "API", + [12], + 100, + { date: 13, events: 14 }, + "2026-08-11T00:00:00.000Z", + [15], + { severity: 16 }, + "NEW_SEVERITY", + ]; + const html = `

    All systems operational

    `; + const component = parseMistralStatusPage(html, new Date("2026-08-11T16:00:00Z")).components[0]; + + assert.equal(component?.history?.days.at(-1)?.level, "unknown"); +}); + +test("adds OpenRouter history only to rows that publish a chart", () => { + const chart = ["green", "zinc", "red"] + .map((color) => `
    `) + .join(""); + const html = `

    Chat API

    Operational${chart}99.9% uptime

    Clerk

    Operational`; + const components = parseOpenRouterStatusPage(html, new Date("2026-08-11T16:00:00Z")).components; + + assert.deepEqual(components[0]?.history?.days, [ + { date: "2026-08-09", level: "operational" }, + { date: "2026-08-10", level: "unknown" }, + { date: "2026-08-11", level: "major_outage" }, + ]); + assert.equal(components[0]?.history?.uptimePercent, 99.9); + assert.equal(components[0]?.history?.uptimeText, "99.9%"); + assert.equal(components[1]?.history, undefined); +}); + +test("discovers every live xAI service from the rendered status payload", async () => { + const parsed = parseXaiStatusPage(await textFixture("html-rss/xai-status.rsc")); + + assert.equal(parsed.reportedHealth, "operational"); + assert.equal(parsed.statusText, "No incidents declared"); + assert.deepEqual(parsed.components, [ + { + id: "api-us-east-1", + name: "API (us-east-1.api.x.ai)", + health: "operational", + statusText: "available", + url: "https://status.x.ai/api-us-east-1", + }, + { + id: "docs", + name: "Docs", + health: "operational", + statusText: "available", + url: "https://status.x.ai/docs", + }, + ]); + assert.throws(() => parseXaiStatusPage("not rsc"), /was malformed/); +}); + +test("reproduces xAI's official 30-day incident chart without inventing uptime", () => { + const payload = { + name: "Grok (iOS)", + slug: "ios-app", + incidents: [ + { + status: "resolved", + startTime: "2026-08-10T10:00:00Z", + endTime: "2026-08-11T11:00:00Z", + severity: "available", + updates: [ + { createTime: "2026-08-10T10:00:00Z", severity: "info" }, + { createTime: "2026-08-11T09:00:00Z", severity: "disruption" }, + { createTime: "2026-08-11T11:00:00Z", severity: "available" }, + ], + }, + ], + }; + const history = parseXaiComponentHistory(`prefix "product":${JSON.stringify(payload)} suffix`, new Date("2026-08-11T16:00:00Z")); + + assert.equal(history.uptimePercent, undefined); + assert.deepEqual(history.days.slice(-2), [ + { date: "2026-08-10", level: "informational" }, + { date: "2026-08-11", level: "degraded" }, + ]); +}); + +test("keeps unfamiliar xAI history severity unknown", () => { + const payload = { + incidents: [ + { + status: "active", + startTime: "2026-08-11T10:00:00Z", + severity: "new-severity", + updates: [{ createTime: "2026-08-11T10:00:00Z", severity: "new-severity" }], + }, + ], + }; + const history = parseXaiComponentHistory( + `prefix "product":${JSON.stringify(payload)} suffix`, + new Date("2026-08-11T16:00:00Z"), + ); + + assert.equal(history.days.at(-1)?.level, "unknown"); +}); + +test("maps only verified Gemini status RPC enums and preserves their rendered labels", async () => { + const page = await textFixture("gemini/page.html"); + const incidents = parseGeminiIncidents( + await jsonFixture("gemini/incidents.json"), + "https://aistudio.google.com/status", + ); + + assert.deepEqual(parseGeminiBootConfig(page), { + apiKey: "public-status-key", + rpcBase: "https://status-rpc.example.com", + }); + + const major = incidents.find((incident) => incident.id === "major-update"); + assert.equal(major?.state, "monitoring"); + assert.equal(major?.stateText, "Update"); + assert.equal(major?.health, "major_outage"); + assert.deepEqual(major?.affectedComponentIds, ["1"]); + assert.deepEqual( + major?.updates.map(({ state, stateText }) => ({ state, stateText })), + [ + { state: "investigating", stateText: "Detected" }, + { state: "monitoring", stateText: "Update" }, + ], + ); + + const moderate = incidents.find((incident) => incident.id === "moderate-lifecycle"); + assert.equal(moderate?.state, "resolved"); + assert.equal(moderate?.stateText, "Resolved"); + assert.equal(moderate?.health, "degraded"); + assert.deepEqual( + moderate?.updates.map(({ state, stateText }) => ({ state, stateText })), + [ + { state: "investigating", stateText: "Detected" }, + { state: "identified", stateText: "Identified" }, + { state: "monitoring", stateText: "Mitigated" }, + { state: "resolved", stateText: "Resolved" }, + ], + ); + + const future = incidents.find((incident) => incident.id === "future-enums"); + assert.equal(future?.state, "unknown"); + assert.equal(future?.stateText, undefined); + assert.equal(future?.health, "unknown"); + assert.equal(future?.updates[0]?.stateText, undefined); + const components = geminiComponents(incidents, new Date("2026-08-11T16:00:00Z")); + assert.equal(components[0]?.history?.uptimePercent, undefined); + assert.equal(components[0]?.history?.days.at(-1)?.level, "major_outage"); + assert.equal(components[2]?.history?.days.at(-2)?.level, "degraded"); + assert.throws(() => parseGeminiBootConfig(""), /boot configuration was missing/); + assert.throws(() => parseGeminiIncidents({}, "https://example.com"), /response was malformed/); +}); + +test("extends an active Gemini incident through the current day", () => { + const incident: Incident = { + id: "active-api-incident", + title: "API disruption", + health: "degraded", + state: "monitoring", + startedAt: "2026-08-09T10:00:00Z", + updatedAt: "2026-08-09T12:00:00Z", + affectedComponentIds: ["1"], + updates: [], + }; + + const component = geminiComponents([incident], new Date("2026-08-11T16:00:00Z"))[0]; + assert.deepEqual(component?.history?.days.slice(-3), [ + { date: "2026-08-09", level: "degraded" }, + { date: "2026-08-10", level: "degraded" }, + { date: "2026-08-11", level: "degraded" }, + ]); +}); + +async function jsonFixture(path: string): Promise { + return JSON.parse(await textFixture(path)) as unknown; +} + +async function textFixture(path: string): Promise { + return readFile(`tests/fixtures/${path}`, "utf8"); +} diff --git a/extensions/ai-status-check/tests/fetch-provider-statuses.test.ts b/extensions/ai-status-check/tests/fetch-provider-statuses.test.ts new file mode 100644 index 00000000000..44f2db5bd94 --- /dev/null +++ b/extensions/ai-status-check/tests/fetch-provider-statuses.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ProviderSnapshot } from "../src/domain/types"; +import type { ProviderDefinition } from "../src/providers/types"; +import { fetchOptionalEnrichment } from "../src/providers/utils/optional-enrichment"; +import { refreshProviderStatus } from "../src/services/fetch-provider-statuses"; + +class MemoryCache { + readonly snapshots = new Map(); + + getSnapshot(providerId: string) { + return this.snapshots.get(providerId); + } + + setSnapshot(snapshot: ProviderSnapshot) { + this.snapshots.set(snapshot.providerId, snapshot); + } +} + +test("a failed refresh retains the last successful snapshot", async () => { + const cache = new MemoryCache(); + const cached = snapshot("2026-08-11T15:00:00Z"); + cache.setSnapshot(cached); + + const result = await refreshProviderStatus(failingProvider(), { + cache, + force: true, + now: () => Date.parse("2026-08-11T16:00:00Z"), + }); + + assert.equal(result.snapshot, cached); + assert.equal(result.freshness, "stale"); + assert.equal(result.refreshState, "failed"); + assert.match(result.refreshError ?? "", /source unavailable/); + assert.equal(cache.getSnapshot("example"), cached); +}); + +test("a fresh cache avoids a provider request unless refresh is forced", async () => { + const cache = new MemoryCache(); + const cached = snapshot("2026-08-11T15:59:30Z"); + cache.setSnapshot(cached); + let calls = 0; + const provider = providerWithFetch(async () => { + calls += 1; + return snapshot("2026-08-11T16:00:00Z"); + }); + + const result = await refreshProviderStatus(provider, { + cache, + now: () => Date.parse("2026-08-11T16:00:00Z"), + }); + + assert.equal(calls, 0); + assert.equal(result.snapshot, cached); + assert.equal(result.refreshState, "idle"); +}); + +test("an optional enrichment timeout does not discard completed core status", async () => { + const cache = new MemoryCache(); + const provider = providerWithFetch(async (signal) => { + await fetchOptionalEnrichment( + signal, + (historySignal) => + new Promise((_resolve, reject) => + historySignal.addEventListener("abort", () => reject(historySignal.reason), { once: true }), + ), + 1_000, + ); + return snapshot("2026-08-11T16:00:00Z"); + }); + + const result = await refreshProviderStatus(provider, { cache, force: true, timeoutMs: 20 }); + + assert.equal(result.refreshState, "idle"); + assert.equal(result.snapshot?.health, "operational"); +}); + +function failingProvider(): ProviderDefinition { + return providerWithFetch(async () => { + throw new Error("source unavailable"); + }); +} + +function providerWithFetch(fetchSnapshot: (signal: AbortSignal) => Promise): ProviderDefinition { + return { + id: "example", + name: "Example", + aliases: [], + category: "model-providers", + preferenceKey: "showExample", + icon: "provider-icons/example.png", + statusPageUrl: "https://status.example.com/", + adapter: { + fetch: fetchSnapshot, + }, + }; +} + +function snapshot(fetchedAt: string): ProviderSnapshot { + return { + providerId: "example", + health: "operational", + components: [], + incidents: [], + fetchedAt, + }; +} diff --git a/extensions/ai-status-check/tests/fixtures/betterstack/index-degraded.json b/extensions/ai-status-check/tests/fixtures/betterstack/index-degraded.json new file mode 100644 index 00000000000..403707b3bf1 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/betterstack/index-degraded.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "status_page", + "id": "page-1", + "attributes": { "aggregate_state": "degraded" } + }, + "included": [ + { + "type": "status_page_section", + "id": "section-1", + "attributes": { "name": "Inference" } + }, + { + "type": "status_page_resource", + "id": "api", + "attributes": { + "public_name": "Inference API", + "status": "degraded", + "status_page_section_id": "section-1", + "availability": 0.995, + "status_history": [ + { "day": "2026-08-09", "status": "not_monitored" }, + { "day": "2026-08-10", "status": "downtime" }, + { "day": "2026-08-11", "status": "operational" } + ] + } + }, + { + "type": "status_report", + "id": "incident-1", + "attributes": { + "title": "Elevated API latency", + "aggregate_state": "degraded", + "report_type": "incident", + "starts_at": "2026-08-11T15:00:00Z", + "affected_resources": [{ "status_page_resource_id": "api", "status": "degraded" }] + }, + "relationships": { + "status_updates": { "data": [{ "type": "status_update", "id": "update-1" }] } + } + }, + { + "type": "status_update", + "id": "update-1", + "attributes": { + "message": "We are investigating elevated latency.", + "published_at": "2026-08-11T15:10:00Z", + "status": "investigating", + "affected_resources": [{ "status": "degraded" }] + } + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/flashcat/current.json b/extensions/ai-status-check/tests/fixtures/flashcat/current.json new file mode 100644 index 00000000000..83714b09f30 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/flashcat/current.json @@ -0,0 +1,33 @@ +{ + "request_id": "request-current", + "data": { + "page": { + "page_id": 6410630422455, + "name": "DeepSeek", + "components": [ + { "component_id": "api", "name": "API Service" }, + { "component_id": "chat", "section_id": "chat-section", "name": "Chat Service" } + ], + "sections": [{ "section_id": "chat-section", "name": "Chat" }] + }, + "active_changes": [ + { + "change_id": 200, + "type": "incident", + "title": "Chat latency", + "status": "investigating", + "start_at_seconds": 1786460400, + "affected_components": [{ "component_id": "chat", "status": "degraded" }], + "updates": [ + { + "update_id": "update-active", + "at_seconds": 1786461000, + "status": "investigating", + "description": "We are investigating chat latency.", + "component_changes": [{ "component_id": "chat", "status": "degraded" }] + } + ] + } + ] + } +} diff --git a/extensions/ai-status-check/tests/fixtures/flashcat/history.json b/extensions/ai-status-check/tests/fixtures/flashcat/history.json new file mode 100644 index 00000000000..14ae9d8bad5 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/flashcat/history.json @@ -0,0 +1,25 @@ +{ + "request_id": "request-history", + "data": { + "items": [ + { + "change_id": 100, + "type": "incident", + "title": "Earlier API errors", + "status": "resolved", + "start_at_seconds": 1786370400, + "close_at_seconds": 1786371000, + "affected_components": [{ "component_id": "api", "status": "operational" }], + "updates": [ + { + "update_id": "update-resolved", + "at_seconds": 1786371000, + "status": "resolved", + "description": "The API has recovered.", + "component_changes": [{ "component_id": "api", "status": "degraded" }] + } + ] + } + ] + } +} diff --git a/extensions/ai-status-check/tests/fixtures/flashcat/structure.json b/extensions/ai-status-check/tests/fixtures/flashcat/structure.json new file mode 100644 index 00000000000..7bbfc3b0a57 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/flashcat/structure.json @@ -0,0 +1,25 @@ +{ + "request_id": "request-structure", + "data": { + "component_impacts": [ + { + "component_id": "chat", + "start_at_seconds": 1786363200, + "end_at_seconds": 1786366800, + "status": "degraded" + } + ], + "component_uptimes": [ + { + "component_id": "api", + "uptime": 100, + "available_since_seconds": 1786233600 + }, + { + "component_id": "chat", + "uptime": 99.8, + "available_since_seconds": 1786233600 + } + ] + } +} diff --git a/extensions/ai-status-check/tests/fixtures/gemini/incidents.json b/extensions/ai-status-check/tests/fixtures/gemini/incidents.json new file mode 100644 index 00000000000..68ce17d93ba --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/gemini/incidents.json @@ -0,0 +1,36 @@ +[ + [ + [ + "major-update", + "AI Studio Build outage", + 2, + [ + [1, "2026-08-11 15:00", [1786460400], "Issue has been detected."], + [5, "2026-08-11 15:15", [1786461300], "Mitigations are underway."] + ], + 3, + [1] + ], + [ + "moderate-lifecycle", + "Gemini API configuration issue", + 1, + [ + [1, "2026-08-10 15:00", [1786374000], "An issue was detected."], + [2, "2026-08-10 15:15", [1786374900], "The cause was identified."], + [3, "2026-08-10 15:30", [1786375800], "A fix was implemented."], + [4, "2026-08-10 15:45", [1786376700], "The issue has been resolved."] + ], + 1, + [3] + ], + [ + "future-enums", + "Future enum values", + 3, + [[6, "2026-08-09 15:00", [1786287600], "A new update type was published."]], + 3, + [2] + ] + ] +] diff --git a/extensions/ai-status-check/tests/fixtures/gemini/page.html b/extensions/ai-status-check/tests/fixtures/gemini/page.html new file mode 100644 index 00000000000..1feb2e17925 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/gemini/page.html @@ -0,0 +1 @@ + diff --git a/extensions/ai-status-check/tests/fixtures/html-rss/incidents.rss b/extensions/ai-status-check/tests/fixtures/html-rss/incidents.rss new file mode 100644 index 00000000000..6ad7a4845dc --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/html-rss/incidents.rss @@ -0,0 +1,19 @@ + + + + + [API] Elevated error rate + https://status.example.com/incidents/incident-1 + https://status.example.com/incidents/incident-1 + Tue, 11 Aug 2026 15:00:00 GMT + We are investigating.
    • API
    ]]>
    +
    + + [API] Elevated error rate + https://status.example.com/incidents/incident-1 + https://status.example.com/incidents/incident-1 + Tue, 11 Aug 2026 15:30:00 GMT + The service has recovered.
    • API
    ]]>
    +
    +
    +
    diff --git a/extensions/ai-status-check/tests/fixtures/html-rss/mistral.html b/extensions/ai-status-check/tests/fixtures/html-rss/mistral.html new file mode 100644 index 00000000000..d676909dbae --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/html-rss/mistral.html @@ -0,0 +1,9 @@ + + +

    All systems operational

    +
    +
    +
    +
    + + diff --git a/extensions/ai-status-check/tests/fixtures/html-rss/online-or-not.html b/extensions/ai-status-check/tests/fixtures/html-rss/online-or-not.html new file mode 100644 index 00000000000..4dbb6788346 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/html-rss/online-or-not.html @@ -0,0 +1,13 @@ + + +

    Fully operational

    +

    Chat (/api/v1/chat/completions)

    Operational +
    +

    Data API

    +
    Operational +
    +

    Homepage

    +
    Operational +

    Clerk (UI account auth)

    Operational + + diff --git a/extensions/ai-status-check/tests/fixtures/html-rss/xai-components.rss b/extensions/ai-status-check/tests/fixtures/html-rss/xai-components.rss new file mode 100644 index 00000000000..a08e15a795e --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/html-rss/xai-components.rss @@ -0,0 +1,42 @@ + + + + xAI System Status + + [API (us-east-1.api.x.ai)] New API incident + https://status.x.ai/api-us-east-1/INC-new + INC-new + Status: RESOLVED

    Updates:

    +

    Tue, 11 Aug 2026 15:30:00 GMT

    Service Restored

    Recovered.

    +

    Tue, 11 Aug 2026 15:00:00 GMT

    Investigating

    Investigating.

    + ]]> + Tue, 11 Aug 2026 15:00:00 GMT + resolved + + + [Grok (Web)] Web incident + https://status.x.ai/grok-com/INC-web + INC-web + Status: RESOLVED]]> + Mon, 10 Aug 2026 16:00:00 GMT + resolved + + + [API (us-east-1.api.x.ai)] Old API incident + https://status.x.ai/old-api-id/INC-old + INC-old + Status: RESOLVED]]> + Sun, 09 Aug 2026 16:00:00 GMT + resolved + + + General announcement without a component + https://status.x.ai/INC-general + INC-general + Status: RESOLVED]]> + Sat, 08 Aug 2026 16:00:00 GMT + resolved + + + diff --git a/extensions/ai-status-check/tests/fixtures/html-rss/xai-status.rsc b/extensions/ai-status-check/tests/fixtures/html-rss/xai-status.rsc new file mode 100644 index 00000000000..56bc96e9949 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/html-rss/xai-status.rsc @@ -0,0 +1,2 @@ +12:["$","div",null,{"children":[["$","h3",null,{"children":"No incidents declared"}],["$","p",null,{"children":"Live service data"}],["$","$L9","api-us-east-1",{"href":"/api-us-east-1","children":[["$","div",null,{"className":"heading-2","children":"API (us-east-1.api.x.ai)"}],"$L20"]}],["$","$L9","docs",{"href":"/docs","children":[["$","div",null,{"className":"heading-2","children":"Docs"}],["$","div",null,{"children":"available"}]]}]]}] +20:["$","div",null,{"children":"available"}] diff --git a/extensions/ai-status-check/tests/fixtures/incidentio/component-impacts.json b/extensions/ai-status-check/tests/fixtures/incidentio/component-impacts.json new file mode 100644 index 00000000000..f1f6a9e680c --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/incidentio/component-impacts.json @@ -0,0 +1,22 @@ +{ + "component_impacts": [ + { + "component_id": "embed", + "start_at": "2026-08-10T10:00:00Z", + "end_at": "2026-08-10T11:00:00Z", + "status": "degraded_performance" + } + ], + "component_uptimes": [ + { + "component_id": "chat", + "data_available_since": "2026-08-01T00:00:00Z", + "uptime": "100.00" + }, + { + "component_id": "embed", + "data_available_since": "2026-08-01T00:00:00Z", + "uptime": "99.50" + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/incidentio/incidents.json b/extensions/ai-status-check/tests/fixtures/incidentio/incidents.json new file mode 100644 index 00000000000..ecc0443d0d2 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/incidentio/incidents.json @@ -0,0 +1,14 @@ +{ + "incidents": [ + { + "id": "active-incident", + "name": "Elevated latency", + "status": "investigating", + "impact": "minor", + "created_at": "2026-08-11T15:00:00Z", + "updated_at": "2026-08-11T15:05:00Z", + "components": [{ "id": "embed" }], + "incident_updates": [] + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/incidentio/proxy.json b/extensions/ai-status-check/tests/fixtures/incidentio/proxy.json new file mode 100644 index 00000000000..93e3a19c107 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/incidentio/proxy.json @@ -0,0 +1,66 @@ +{ + "summary": { + "display_uptime_mode": "chart_and_percentage", + "history_window_days": 3, + "components": [ + { "id": "chat", "name": "Chat" }, + { "id": "embed", "name": "Embed" }, + { "id": "docs", "name": "Documentation" }, + { "id": "internal", "name": "Internal", "hidden": true } + ], + "affected_components": [ + { "component_id": "embed", "component_status": "degraded_performance" } + ], + "structure": { + "items": [ + { + "group": { + "id": "endpoints", + "name": "Endpoints", + "hidden": false, + "components": [ + { + "component_id": "chat", + "name": "Chat", + "hidden": false, + "display_uptime": true, + "data_available_since": "2026-08-01T00:00:00Z" + } + ] + } + }, + { + "group": { + "id": "models", + "name": "Models", + "hidden": false, + "components": [ + { + "component_id": "embed", + "name": "Embed", + "hidden": false, + "display_uptime": true, + "data_available_since": "2026-08-01T00:00:00Z" + } + ] + } + }, + { + "component": { + "component_id": "docs", + "name": "Docs", + "hidden": false, + "display_uptime": false + } + }, + { + "component": { + "component_id": "internal", + "name": "Internal", + "hidden": true + } + } + ] + } + } +} diff --git a/extensions/ai-status-check/tests/fixtures/instatus/components.json b/extensions/ai-status-check/tests/fixtures/instatus/components.json new file mode 100644 index 00000000000..8d1af7af02b --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/instatus/components.json @@ -0,0 +1,7 @@ +{ + "components": [ + { "id": "api", "name": "API", "status": "UP", "parent": { "name": "Services" } }, + { "id": "web", "name": "Web", "status": "DEGRADED" }, + { "id": "computer", "name": "Computer", "status": "OPERATIONAL", "group": null } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/instatus/history.rss b/extensions/ai-status-check/tests/fixtures/instatus/history.rss new file mode 100644 index 00000000000..f9d52a0f5ea --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/instatus/history.rss @@ -0,0 +1,14 @@ + + + Computer Sandbox Issue + + Thu, 6 Aug 2026 14:30:00 +0000 + https://status.example.com/incident/computer-incident + https://status.example.com/incident/computer-incident + diff --git a/extensions/ai-status-check/tests/fixtures/instatus/summary.json b/extensions/ai-status-check/tests/fixtures/instatus/summary.json new file mode 100644 index 00000000000..15e0dfbbc6a --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/instatus/summary.json @@ -0,0 +1,3 @@ +{ + "page": { "status": "UP", "name": "Example Status" } +} diff --git a/extensions/ai-status-check/tests/fixtures/statuspage/incidents.json b/extensions/ai-status-check/tests/fixtures/statuspage/incidents.json new file mode 100644 index 00000000000..d1fe1c78947 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/statuspage/incidents.json @@ -0,0 +1,51 @@ +{ + "incidents": [ + { + "id": "resolved-incident", + "name": "Earlier API outage", + "status": "resolved", + "impact": "major", + "created_at": "2026-08-09T12:00:00Z", + "updated_at": "2026-08-09T12:30:00Z", + "resolved_at": "2026-08-09T12:30:00Z", + "incident_updates": [ + { + "id": "resolved-update", + "status": "resolved", + "body": "This incident has been resolved.", + "created_at": "2026-08-09T12:30:00Z" + } + ] + }, + { + "id": "active-incident", + "name": "Code requests are slow", + "status": "investigating", + "impact": "minor", + "created_at": "2026-08-11T15:00:00Z", + "updated_at": "2026-08-11T15:05:00Z", + "shortlink": "https://stspg.io/example", + "components": [ + { + "id": "code" + } + ], + "incident_updates": [ + { + "id": "active-update", + "status": "investigating", + "body": "We are investigating elevated latency.", + "created_at": "2026-08-11T15:05:00Z", + "affected_components": [ + { + "code": "code", + "name": "Code Assistant", + "old_status": "operational", + "new_status": "degraded_performance" + } + ] + } + ] + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/statuspage/summary-degraded.json b/extensions/ai-status-check/tests/fixtures/statuspage/summary-degraded.json new file mode 100644 index 00000000000..1f0b8901204 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/statuspage/summary-degraded.json @@ -0,0 +1,18 @@ +{ + "status": { + "indicator": "minor", + "description": "Degraded Performance" + }, + "components": [ + { + "id": "api", + "name": "API", + "status": "operational" + }, + { + "id": "code", + "name": "Code Assistant", + "status": "degraded_performance" + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/statuspage/summary-operational.json b/extensions/ai-status-check/tests/fixtures/statuspage/summary-operational.json new file mode 100644 index 00000000000..2c0a1d21362 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/statuspage/summary-operational.json @@ -0,0 +1,24 @@ +{ + "page": { + "id": "page-1", + "name": "Example AI", + "url": "https://status.example.com/", + "updated_at": "2026-08-11T16:00:00Z" + }, + "status": { + "indicator": "none", + "description": "All Systems Operational" + }, + "components": [ + { + "id": "api", + "name": "API", + "status": "operational" + }, + { + "id": "code", + "name": "Code Assistant", + "status": "operational" + } + ] +} diff --git a/extensions/ai-status-check/tests/fixtures/statuspage/uptime.html b/extensions/ai-status-check/tests/fixtures/statuspage/uptime.html new file mode 100644 index 00000000000..758aba50c39 --- /dev/null +++ b/extensions/ai-status-check/tests/fixtures/statuspage/uptime.html @@ -0,0 +1,22 @@ + + + 99.37 + 100.0 + diff --git a/extensions/ai-status-check/tests/freshness.test.ts b/extensions/ai-status-check/tests/freshness.test.ts new file mode 100644 index 00000000000..2195f5597d5 --- /dev/null +++ b/extensions/ai-status-check/tests/freshness.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FRESH_TTL_MS, STALE_TTL_MS, getDataFreshness } from "../src/domain/freshness"; +import type { ProviderSnapshot } from "../src/domain/types"; + +const now = Date.parse("2026-08-11T16:00:00Z"); + +test("classifies missing, fresh, stale, and expired snapshots", () => { + assert.equal(getDataFreshness(undefined, now), "unavailable"); + assert.equal(getDataFreshness(snapshotAt(now - FRESH_TTL_MS), now), "fresh"); + assert.equal(getDataFreshness(snapshotAt(now - FRESH_TTL_MS - 1), now), "stale"); + assert.equal(getDataFreshness(snapshotAt(now - STALE_TTL_MS), now), "stale"); + assert.equal(getDataFreshness(snapshotAt(now - STALE_TTL_MS - 1), now), "expired"); +}); + +test("treats an invalid cache timestamp as expired", () => { + assert.equal(getDataFreshness({ ...snapshotAt(now), fetchedAt: "not-a-date" }, now), "expired"); +}); + +function snapshotAt(timestamp: number): ProviderSnapshot { + return { + providerId: "example", + health: "operational", + components: [], + incidents: [], + fetchedAt: new Date(timestamp).toISOString(), + }; +} diff --git a/extensions/ai-status-check/tests/incident-markdown.test.ts b/extensions/ai-status-check/tests/incident-markdown.test.ts new file mode 100644 index 00000000000..34b789cbe2e --- /dev/null +++ b/extensions/ai-status-check/tests/incident-markdown.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Incident } from "../src/domain/types"; +import { buildIncidentMarkdown } from "../src/utils/incident-markdown"; + +test("renders incident metadata as separate list items", () => { + const incident: Incident = { + id: "embedding-api-degraded", + title: "Embedding API Degraded", + health: "degraded", + state: "investigating", + impactText: "Degraded performance", + startedAt: "2026-08-12T14:16:00Z", + updatedAt: "2026-08-12T14:30:00Z", + affectedComponentIds: [], + updates: [], + }; + + const metadata = buildIncidentMarkdown(incident) + .split("\n") + .filter((line) => line.startsWith("- ")); + + assert.equal(metadata.length, 4); + assert.equal(metadata[0], "- **State:** Investigating"); + assert.equal(metadata[1], "- **Impact:** Degraded performance"); + assert.match(metadata[2] ?? "", /^- \*\*Started:\*\* /); + assert.match(metadata[3] ?? "", /^- \*\*Last updated:\*\* /); +}); diff --git a/extensions/ai-status-check/tests/incidentio-adapter.test.ts b/extensions/ai-status-check/tests/incidentio-adapter.test.ts new file mode 100644 index 00000000000..3605e0c6071 --- /dev/null +++ b/extensions/ai-status-check/tests/incidentio-adapter.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createIncidentIoAdapter, parseIncidentIoSummary } from "../src/providers/adapters/incidentio"; + +test("uses the Incident.io proxy structure and affected component states", async () => { + const proxy = await fixture("proxy.json"); + const incidents = await fixture("incidents.json"); + const impacts = await fixture("component-impacts.json"); + const requestedUrls: string[] = []; + const adapter = createIncidentIoAdapter({ + providerId: "example", + statusPageUrl: "https://status.example.com/", + fetchJson: async (url) => { + requestedUrls.push(url); + if (url.includes("component_impacts")) return impacts; + return url.endsWith("/incidents") ? incidents : proxy; + }, + now: () => new Date("2026-08-11T16:00:00Z"), + }); + + const snapshot = await adapter.fetch(new AbortController().signal); + + assert.deepEqual(requestedUrls.sort(), [ + "https://status.example.com/proxy/status.example.com", + "https://status.example.com/proxy/status.example.com/component_impacts?start_at=2026-08-09T00%3A00%3A00.000Z&end_at=2026-08-11T23%3A59%3A59.999Z", + "https://status.example.com/proxy/status.example.com/incidents", + ]); + assert.equal(snapshot.health, "degraded"); + assert.equal(snapshot.statusText, "We're currently experiencing issues"); + assert.equal(snapshot.fetchedAt, "2026-08-11T16:00:00.000Z"); + assert.deepEqual( + snapshot.components.map(({ id, name, group, health, statusText }) => ({ + id, + name, + group, + health, + statusText, + })), + [ + { + id: "chat", + name: "Chat", + group: "Endpoints", + health: "operational", + statusText: undefined, + }, + { + id: "embed", + name: "Embed", + group: "Models", + health: "degraded", + statusText: "degraded_performance", + }, + { + id: "docs", + name: "Docs", + group: undefined, + health: "operational", + statusText: undefined, + }, + ], + ); + assert.equal(snapshot.incidents[0]?.id, "active-incident"); + assert.deepEqual(snapshot.components[1]?.history, { + basis: "availability", + windowDays: 3, + days: [ + { date: "2026-08-09", level: "operational" }, + { date: "2026-08-10", level: "degraded" }, + { date: "2026-08-11", level: "operational" }, + ], + uptimePercent: 99.5, + uptimeText: "99.50%", + monitoredSince: "2026-08-01", + }); + assert.equal(snapshot.components[2]?.history, undefined); +}); + +test("supports framework endpoint overrides without provider-specific branches", async () => { + const proxy = await fixture("proxy.json"); + const incidents = await fixture("incidents.json"); + const impacts = await fixture("component-impacts.json"); + const requestedUrls: string[] = []; + const adapter = createIncidentIoAdapter({ + providerId: "variant", + statusPageUrl: "https://status.variant.example/", + proxyUrl: "https://api.variant.example/current", + incidentsUrl: "https://api.variant.example/history", + componentImpactsUrl: "https://api.variant.example/uptime", + fetchJson: async (url) => { + requestedUrls.push(url); + if (url.endsWith("/uptime")) return impacts; + return url.endsWith("/history") ? incidents : proxy; + }, + }); + + const snapshot = await adapter.fetch(new AbortController().signal); + + assert.deepEqual(requestedUrls.sort(), [ + "https://api.variant.example/current", + "https://api.variant.example/history", + "https://api.variant.example/uptime", + ]); + assert.equal(snapshot.components.length, 3); +}); + +test("treats an empty affected component list as Incident.io operational state", async () => { + const payload = (await fixture("proxy.json")) as { summary: { affected_components: unknown[] } }; + payload.summary.affected_components = []; + + const parsed = parseIncidentIoSummary(payload); + + assert.equal(parsed.reportedHealth, "operational"); + assert.ok(parsed.components.every((component) => component.health === "operational")); + assert.ok(parsed.components.every((component) => component.statusText === undefined)); +}); + +test("does not collapse an unfamiliar affected status into operational", async () => { + const payload = (await fixture("proxy.json")) as { + summary: { affected_components: Array<{ component_status: string }> }; + }; + payload.summary.affected_components[0]!.component_status = "new_incident_io_state"; + + const parsed = parseIncidentIoSummary(payload); + + assert.equal(parsed.reportedHealth, "unknown"); + assert.equal(parsed.components[1]?.health, "unknown"); + assert.equal(parsed.components[1]?.statusText, "new_incident_io_state"); +}); + +test("falls back to the proxy component catalog when structure is unavailable", async () => { + const payload = (await fixture("proxy.json")) as { summary: { structure?: unknown } }; + delete payload.summary.structure; + + assert.deepEqual( + parseIncidentIoSummary(payload).components.map((component) => component.id), + ["chat", "embed", "docs"], + ); +}); + +test("uses an empty published structure without restoring unlisted components", async () => { + const payload = (await fixture("proxy.json")) as { summary: { structure: { items: unknown[] } } }; + payload.summary.structure.items = []; + + assert.deepEqual(parseIncidentIoSummary(payload).components, []); +}); + +test("rejects malformed Incident.io proxy responses", () => { + assert.throws(() => parseIncidentIoSummary({}), /Invalid Incident.io proxy summary response/); + assert.throws( + () => parseIncidentIoSummary({ summary: { components: [], structure: { items: [] } } }), + /contained no components/, + ); +}); + +async function fixture(name: string): Promise { + return JSON.parse(await readFile(`tests/fixtures/incidentio/${name}`, "utf8")) as unknown; +} diff --git a/extensions/ai-status-check/tests/optional-enrichment.test.ts b/extensions/ai-status-check/tests/optional-enrichment.test.ts new file mode 100644 index 00000000000..39052c605f9 --- /dev/null +++ b/extensions/ai-status-check/tests/optional-enrichment.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchOptionalEnrichment } from "../src/providers/utils/optional-enrichment"; +import { RequestTimeoutError } from "../src/utils/request-timeout"; + +test("omits optional enrichment after its own timeout", async () => { + const result = await fetchOptionalEnrichment( + new AbortController().signal, + (signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason))), + 5, + ); + + assert.equal(result, undefined); +}); + +test("propagates parent cancellation instead of treating it as optional failure", async () => { + const controller = new AbortController(); + const request = fetchOptionalEnrichment( + controller.signal, + (signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason))), + 1_000, + ); + controller.abort(new Error("Superseded refresh")); + + await assert.rejects(request, /Superseded refresh/); +}); + +test("omits enrichment when the provider deadline expires after core data is available", async () => { + const controller = new AbortController(); + const request = fetchOptionalEnrichment( + controller.signal, + (signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason))), + 1_000, + ); + controller.abort(new RequestTimeoutError("Status request timed out")); + + assert.equal(await request, undefined); +}); + +test("omits an ordinary enrichment error", async () => { + const result = await fetchOptionalEnrichment(new AbortController().signal, async () => { + throw new Error("History endpoint unavailable"); + }); + + assert.equal(result, undefined); +}); diff --git a/extensions/ai-status-check/tests/provider-architecture.test.ts b/extensions/ai-status-check/tests/provider-architecture.test.ts new file mode 100644 index 00000000000..58eda35b553 --- /dev/null +++ b/extensions/ai-status-check/tests/provider-architecture.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import test from "node:test"; +import { PROVIDERS } from "../src/providers/registry"; + +test("the catalog keeps exactly one provider in each file", async () => { + const catalogDirectory = "src/providers/catalog"; + const catalogFiles = (await readdir(catalogDirectory)).filter((file) => file.endsWith(".ts")).sort(); + + assert.equal(catalogFiles.length, PROVIDERS.length); + await Promise.all( + catalogFiles.map(async (file) => { + const source = await readFile(`${catalogDirectory}/${file}`, "utf8"); + const providerExports = source.match(/export const \w+Provider\s*=/g) ?? []; + assert.equal(providerExports.length, 1, `${file} must declare exactly one provider`); + assert.match(source, /createProvider\(/, `${file} must use the shared provider factory`); + assert.doesNotMatch(source, /parsers\//, `${file} must select an adapter without importing parser details`); + assert.doesNotMatch(source, /parse\w+/, `${file} must not configure adapter parsing`); + }), + ); +}); + +test("every provider adapter extends the shared config and exposes a ProviderAdapter factory", async () => { + const adapterDirectory = "src/providers/adapters"; + const adapterFiles = (await readdir(adapterDirectory)).filter((file) => file.endsWith(".ts")).sort(); + + assert.ok(adapterFiles.length > 0); + await Promise.all( + adapterFiles.map(async (file) => { + const source = await readFile(`${adapterDirectory}/${file}`, "utf8"); + assert.match( + source, + /export interface \w+AdapterConfig extends ProviderAdapterConfig\s*{/, + `${file} must extend the shared adapter config`, + ); + assert.match( + source, + /export function create\w+Adapter\(config: \w+AdapterConfig\): ProviderAdapter\s*{/, + `${file} must export a ProviderAdapter factory`, + ); + }), + ); +}); diff --git a/extensions/ai-status-check/tests/provider-factory.test.ts b/extensions/ai-status-check/tests/provider-factory.test.ts new file mode 100644 index 00000000000..c992ded8562 --- /dev/null +++ b/extensions/ai-status-check/tests/provider-factory.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createProvider } from "../src/providers/factories/provider"; +import type { ProviderAdapterConfig } from "../src/providers/types"; + +interface ExampleConfig extends ProviderAdapterConfig { + source: string; +} + +test("creates a provider with one normalized URL and passes adapter-specific options", () => { + let received: ExampleConfig | undefined; + const provider = createProvider( + { + id: "example", + name: "Example", + aliases: [], + category: "model-providers", + preferenceKey: "showExample", + icon: "provider-icons/example.png", + statusPageUrl: "https://status.example.com", + }, + (config: ExampleConfig) => { + received = config; + return { + async fetch() { + throw new Error("not used"); + }, + }; + }, + { source: "framework" }, + ); + + assert.equal(provider.statusPageUrl, "https://status.example.com/"); + assert.deepEqual(received, { + providerId: "example", + statusPageUrl: "https://status.example.com/", + source: "framework", + }); +}); diff --git a/extensions/ai-status-check/tests/provider-request-order.test.ts b/extensions/ai-status-check/tests/provider-request-order.test.ts new file mode 100644 index 00000000000..ffb89e34603 --- /dev/null +++ b/extensions/ai-status-check/tests/provider-request-order.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ProviderSnapshot } from "../src/domain/types"; +import type { ProviderDefinition } from "../src/providers/types"; +import { refreshProviderStatus } from "../src/services/fetch-provider-statuses"; +import { ProviderRequestOrder } from "../src/services/provider-request-order"; + +class MemoryCache { + readonly snapshots = new Map(); + + getSnapshot(providerId: string) { + return this.snapshots.get(providerId); + } + + setSnapshot(snapshot: ProviderSnapshot) { + this.snapshots.set(snapshot.providerId, snapshot); + } +} + +test("an older provider refresh cannot persist after a newer refresh", async () => { + const cache = new MemoryCache(); + const order = new ProviderRequestOrder(); + const responses: Array<(snapshot: ProviderSnapshot) => void> = []; + const provider = providerWithFetch( + () => new Promise((resolve) => responses.push(resolve)), + ); + + const olderToken = order.begin(provider.id); + const older = refreshProviderStatus(provider, { + cache, + force: true, + isCurrent: () => order.isCurrent(provider.id, olderToken), + }); + const newerToken = order.begin(provider.id); + const newer = refreshProviderStatus(provider, { + cache, + force: true, + isCurrent: () => order.isCurrent(provider.id, newerToken), + }); + + responses[1]?.(snapshot("2026-08-12T05:00:00Z")); + await newer; + responses[0]?.(snapshot("2026-08-12T04:00:00Z")); + await older; + + assert.equal(cache.getSnapshot(provider.id)?.fetchedAt, "2026-08-12T05:00:00Z"); + assert.equal(order.isCurrent(provider.id, olderToken), false); + assert.equal(order.isCurrent(provider.id, newerToken), true); +}); + +function providerWithFetch(fetchSnapshot: () => Promise): ProviderDefinition { + return { + id: "example", + name: "Example", + aliases: [], + category: "model-providers", + preferenceKey: "showExample", + icon: "provider-icons/example.png", + statusPageUrl: "https://status.example.com/", + adapter: { fetch: fetchSnapshot }, + }; +} + +function snapshot(fetchedAt: string): ProviderSnapshot { + return { + providerId: "example", + health: "operational", + components: [], + incidents: [], + fetchedAt, + }; +} diff --git a/extensions/ai-status-check/tests/provider-sections.test.ts b/extensions/ai-status-check/tests/provider-sections.test.ts new file mode 100644 index 00000000000..880e89383ca --- /dev/null +++ b/extensions/ai-status-check/tests/provider-sections.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Health, ProviderStatusRecord } from "../src/domain/types"; +import type { ProviderDefinition } from "../src/providers/types"; +import { buildProviderSections, unavailableProviderRecord } from "../src/providers/provider-sections"; + +test("uses one ordering policy for every provider", () => { + const providers = [provider("healthy"), provider("issue"), provider("maintenance"), provider("missing")]; + const records = { + healthy: record("healthy", "operational"), + issue: record("issue", "degraded"), + maintenance: record("maintenance", "maintenance"), + missing: unavailableProviderRecord("missing"), + }; + + const sections = buildProviderSections(providers, records); + + assert.deepEqual( + sections.map((section) => ({ id: section.id, providers: section.providers.map((item) => item.id) })), + [ + { id: "issues", providers: ["issue"] }, + { id: "maintenance", providers: ["maintenance"] }, + { id: "model-providers", providers: ["healthy"] }, + { id: "unavailable", providers: ["missing"] }, + ], + ); +}); + +test("keeps a fresh provider without overall status available and sorts its active incidents", () => { + const providers = [provider("quiet"), provider("incident")]; + const quiet = record("quiet", "unknown"); + const incident = record("incident", "unknown"); + incident.snapshot!.incidents = [ + { + id: "active", + title: "Published incident", + health: "major_outage", + state: "investigating", + affectedComponentIds: [], + updates: [], + }, + ]; + + const sections = buildProviderSections(providers, { quiet, incident }); + + assert.deepEqual( + sections.map((section) => ({ id: section.id, providers: section.providers.map((item) => item.id) })), + [ + { id: "issues", providers: ["incident"] }, + { id: "model-providers", providers: ["quiet"] }, + ], + ); +}); + +function provider(id: string): ProviderDefinition { + return { + id, + name: id, + aliases: [], + category: "model-providers", + preferenceKey: `show-${id}`, + icon: `provider-icons/${id}.png`, + statusPageUrl: `https://status.${id}.example/`, + adapter: { fetch: async () => record(id, "operational").snapshot! }, + }; +} + +function record(providerId: string, health: Health): ProviderStatusRecord { + return { + providerId, + freshness: "fresh", + refreshState: "idle", + snapshot: { + providerId, + health, + components: [], + incidents: [], + fetchedAt: "2026-08-11T16:00:00Z", + }, + }; +} diff --git a/extensions/ai-status-check/tests/provider-view.test.ts b/extensions/ai-status-check/tests/provider-view.test.ts new file mode 100644 index 00000000000..8e196e88f45 --- /dev/null +++ b/extensions/ai-status-check/tests/provider-view.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildComponentSections, getActiveIncidents, getRecentIncidents } from "../src/domain/provider-view"; +import type { ComponentStatus, Incident } from "../src/domain/types"; + +test("builds one shared component hierarchy with aggregate group health", () => { + const components: ComponentStatus[] = [ + { id: "api", name: "API", group: "Platform", health: "operational" }, + { id: "batch", name: "Batch", group: "Platform", health: "degraded" }, + { id: "site", name: "Status Site", health: "operational" }, + ]; + + const sections = buildComponentSections(components); + + assert.deepEqual( + sections.groups.map((group) => ({ + name: group.name, + health: group.health, + affectedCount: group.affectedCount, + ids: group.components.map((item) => item.id), + })), + [{ name: "Platform", health: "degraded", affectedCount: 1, ids: ["api", "batch"] }], + ); + assert.deepEqual( + sections.ungrouped.map((component) => component.id), + ["site"], + ); +}); + +test("selects active and recent incidents with the shared history policy", () => { + const now = Date.parse("2026-08-11T16:00:00Z"); + const incidents = [ + incident("active", "investigating", "2026-08-11T15:00:00Z"), + incident("recent", "resolved", "2026-08-01T15:00:00Z"), + incident("old", "resolved", "2026-06-01T15:00:00Z"), + incident("undated", "resolved"), + ]; + + assert.deepEqual( + getActiveIncidents(incidents).map((item) => item.id), + ["active"], + ); + assert.deepEqual( + getRecentIncidents(incidents, now).map((item) => item.id), + ["recent", "undated"], + ); +}); + +test("keeps long-running incidents when they were resolved recently", () => { + const now = Date.parse("2026-08-11T16:00:00Z"); + const longRunning = { + ...incident("long-running", "resolved", "2026-06-16T15:00:00Z"), + resolvedAt: "2026-07-23T15:00:00Z", + }; + + assert.deepEqual( + getRecentIncidents([longRunning], now).map((item) => item.id), + ["long-running"], + ); +}); + +function incident(id: string, state: Incident["state"], startedAt?: string): Incident { + return { + id, + title: id, + health: state === "resolved" ? "operational" : "degraded", + state, + startedAt, + affectedComponentIds: [], + updates: [], + }; +} diff --git a/extensions/ai-status-check/tests/registry.test.ts b/extensions/ai-status-check/tests/registry.test.ts new file mode 100644 index 00000000000..54900e04707 --- /dev/null +++ b/extensions/ai-status-check/tests/registry.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { PROVIDER_CATEGORY_TITLES } from "../src/providers/categories"; +import { + DEFAULT_ENABLED_PROVIDER_IDS, + getEnabledProviders, + isProviderEnabledByDefault, + PROVIDERS, +} from "../src/providers/registry"; + +const expectedProviderIds = [ + "openai", + "claude", + "gemini-api", + "xai", + "deepseek", + "mistral-ai", + "cohere", + "perplexity", + "openrouter", + "groq", + "together-ai", + "fireworks-ai", + "cerebras", + "replicate", + "hugging-face", + "baseten", + "elevenlabs", + "stability-ai", +]; + +test("common providers are enabled by default and every provider remains configurable", () => { + assert.deepEqual( + getEnabledProviders({}).map((provider) => provider.id), + DEFAULT_ENABLED_PROVIDER_IDS, + ); + assert.deepEqual( + getEnabledProviders({ showClaude: false, showElevenLabs: true }).map((provider) => provider.id), + [...DEFAULT_ENABLED_PROVIDER_IDS.filter((id) => id !== "claude"), "elevenlabs"], + ); +}); + +test("the first-release provider inventory remains complete and unique", () => { + assert.deepEqual( + PROVIDERS.map((provider) => provider.id), + expectedProviderIds, + ); + assert.equal(new Set(PROVIDERS.map((provider) => provider.id)).size, PROVIDERS.length); + assert.equal(new Set(PROVIDERS.map((provider) => provider.preferenceKey)).size, PROVIDERS.length); +}); + +test("the provider registry and manifest checkbox preferences stay in sync", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + preferences?: Array<{ name?: string; type?: string; title?: string; label?: string; default?: boolean }>; + }; + const manifestPreferences = (packageJson.preferences ?? []) + .filter((preference) => preference.type === "checkbox") + .map(({ name, title, label, default: defaultValue }) => ({ name, title, label, default: defaultValue })); + const registryPreferences = PROVIDERS.map((provider, index) => ({ + name: provider.preferenceKey, + title: + index === 0 || PROVIDERS[index - 1]?.category !== provider.category + ? PROVIDER_CATEGORY_TITLES[provider.category] + : "", + label: provider.name, + default: isProviderEnabledByDefault(provider.id), + })); + + assert.deepEqual(manifestPreferences, registryPreferences); +}); + +test("every provider has a bundled icon", async () => { + assert.equal(new Set(PROVIDERS.map((provider) => provider.icon)).size, PROVIDERS.length); + + await Promise.all( + PROVIDERS.map(async (provider) => { + await assert.doesNotReject(access(join("assets", provider.icon)), `${provider.name} icon is missing`); + }), + ); +}); diff --git a/extensions/ai-status-check/tests/status-presentation.test.ts b/extensions/ai-status-check/tests/status-presentation.test.ts new file mode 100644 index 00000000000..eacadca7a23 --- /dev/null +++ b/extensions/ai-status-check/tests/status-presentation.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + componentStatusPresentation, + incidentActivityLabel, + incidentImpactLabel, + incidentStateLabel, + incidentUpdateStateLabel, + providerStatusPresentation, + providerUpdatedLabel, +} from "../src/domain/status-presentation"; +import type { ComponentStatus, Incident, IncidentUpdate, ProviderStatusRecord } from "../src/domain/types"; + +const now = Date.parse("2026-08-11T16:00:00Z"); + +test("shows only the latest incident activity time", () => { + assert.equal(incidentActivityLabel(incident("investigating", "2026-04-29T16:00:00Z"), now), "updated 104 days ago"); + assert.equal(incidentActivityLabel(incident("scheduled", "2026-08-10T16:00:00Z"), now), "updated yesterday"); + assert.equal(incidentActivityLabel(incident("resolved", "2026-08-10T16:00:00Z"), now), "updated yesterday"); +}); + +test("prefers the provider's published incident impact language", () => { + const published = { ...incident("identified", "2026-08-11T15:00:00Z"), impactText: "none" }; + assert.equal(incidentImpactLabel(published), "None"); + + const unpublished = incident("identified", "2026-08-11T15:00:00Z"); + assert.equal(incidentImpactLabel(unpublished), undefined); +}); + +test("maps known provider status aliases to one label and icon health", () => { + const snapshot = providerRecord().snapshot!; + + for (const statusText of [ + "operational", + "UP", + "all system operational", + "All Systems Operational", + "fully_operational", + "No incidents declared", + ]) { + assert.deepEqual(providerStatusPresentation({ ...snapshot, health: "unknown", statusText }), { + label: "All Systems Operational", + health: "operational", + }); + } + + assert.equal(providerStatusPresentation({ ...snapshot, health: "operational" }).label, "All Systems Operational"); +}); + +test("preserves unknown provider and component wording exactly", () => { + const snapshot = providerRecord().snapshot!; + assert.equal( + providerStatusPresentation({ ...snapshot, health: "unknown", statusText: "Elevated failure rate" }).label, + "Elevated failure rate", + ); + assert.equal(providerStatusPresentation({ ...snapshot, health: "partial_outage" }).label, "Partial Outage"); + + const component: ComponentStatus = { + id: "api", + name: "API", + health: "major_outage", + statusText: "critical_issue", + }; + assert.equal(componentStatusPresentation(component).label, "critical_issue"); + assert.equal(componentStatusPresentation(component).health, "major_outage"); + assert.equal(componentStatusPresentation({ ...component, statusText: undefined }).label, "Major Outage"); +}); + +test("uses component-specific canonical labels for known health aliases", () => { + const component: ComponentStatus = { id: "api", name: "API", health: "unknown", statusText: "UP" }; + assert.deepEqual(componentStatusPresentation(component), { label: "Operational", health: "operational" }); + assert.deepEqual(componentStatusPresentation({ ...component, statusText: "degraded_performance" }), { + label: "Degraded Performance", + health: "degraded", + }); + assert.deepEqual(componentStatusPresentation({ ...component, statusText: "maintenance_scheduled" }), { + label: "Maintenance", + health: "maintenance", + }); +}); + +test("normalizes known incident states and preserves unfamiliar wording", () => { + assert.equal( + incidentStateLabel({ ...incident("monitoring", "2026-08-11T15:00:00Z"), stateText: "MONITORING" }), + "Monitoring", + ); + + const published = { ...incident("unknown", "2026-08-11T15:00:00Z"), stateText: "MITIGATION_IN_PROGRESS" }; + assert.equal(incidentStateLabel(published), "MITIGATION_IN_PROGRESS"); + + const update: IncidentUpdate = { + id: "update", + state: "monitoring", + stateText: "Watching recovery", + body: "Recovery is continuing.", + createdAt: "2026-08-11T15:00:00Z", + }; + assert.equal(incidentUpdateStateLabel(update), "Watching recovery"); + assert.equal( + incidentStateLabel({ ...incident("scheduled", "2026-08-11T15:00:00Z"), stateText: "maintenance_scheduled" }), + "Scheduled", + ); +}); + +test("shows provider timing only as an updated label", () => { + const record = providerRecord(); + assert.equal(providerUpdatedLabel(record.snapshot!, now), "updated now"); +}); + +function incident(state: Incident["state"], updatedAt: string): Incident { + return { + id: state, + title: state, + health: state === "resolved" ? "operational" : "degraded", + state, + updatedAt, + affectedComponentIds: [], + updates: [], + }; +} + +function providerRecord(): ProviderStatusRecord { + return { + providerId: "provider", + freshness: "fresh", + refreshState: "idle", + snapshot: { + providerId: "provider", + health: "operational", + components: [], + incidents: [], + fetchedAt: "2026-08-11T16:00:00Z", + }, + }; +} diff --git a/extensions/ai-status-check/tests/statuspage-adapter.test.ts b/extensions/ai-status-check/tests/statuspage-adapter.test.ts new file mode 100644 index 00000000000..d631fcef4b2 --- /dev/null +++ b/extensions/ai-status-check/tests/statuspage-adapter.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { + createStatuspageAdapter, + parseStatuspageUptimeHtml, + parseSummary, + statuspageEndpoints, +} from "../src/providers/adapters/statuspage"; +import { mapFlexibleHealth } from "../src/providers/utils/status-normalization"; + +test("normalizes components and incident history from Statuspage-compatible payloads", async () => { + const summary = await fixture("summary-degraded.json"); + const incidents = await fixture("incidents.json"); + const maintenances = { scheduled_maintenances: [] }; + const requestedUrls: string[] = []; + const uptimeHtml = await textFixture("uptime.html"); + const adapter = createStatuspageAdapter({ + providerId: "example", + statusPageUrl: "https://status.example.com/", + fetchJson: async (url) => { + requestedUrls.push(url); + if (url.includes("summary")) return summary; + return url.includes("scheduled-maintenances") ? maintenances : incidents; + }, + fetchText: async () => uptimeHtml, + now: () => new Date("2026-08-11T16:00:00Z"), + }); + + const snapshot = await adapter.fetch(new AbortController().signal); + + assert.deepEqual(requestedUrls.sort(), [ + "https://status.example.com/api/v2/incidents.json", + "https://status.example.com/api/v2/scheduled-maintenances.json", + "https://status.example.com/api/v2/summary.json", + ]); + assert.equal(snapshot.providerId, "example"); + assert.equal(snapshot.health, "degraded"); + assert.equal(snapshot.fetchedAt, "2026-08-11T16:00:00.000Z"); + assert.equal(snapshot.components[1]?.name, "Code Assistant"); + assert.equal(snapshot.components[1]?.health, "degraded"); + assert.equal(snapshot.components[1]?.statusText, "degraded_performance"); + assert.equal(snapshot.incidents[0]?.id, "active-incident"); + assert.equal(snapshot.incidents[0]?.stateText, "investigating"); + assert.equal(snapshot.incidents[0]?.impactText, "minor"); + assert.equal(snapshot.incidents[0]?.url, "https://stspg.io/example"); + assert.deepEqual(snapshot.incidents[0]?.affectedComponentIds, ["code"]); + assert.equal(snapshot.incidents[0]?.updates[0]?.body, "We are investigating elevated latency."); + assert.equal(snapshot.incidents[0]?.updates[0]?.stateText, "investigating"); + assert.deepEqual(snapshot.components[0]?.history?.days, [ + { date: "2026-08-10", level: "operational" }, + { date: "2026-08-11", level: "degraded" }, + ]); + assert.equal(snapshot.components[0]?.history?.uptimePercent, 99.37); + assert.equal(snapshot.components[0]?.history?.uptimeText, "99.37%"); + assert.deepEqual(snapshot.components[1]?.history?.days, [ + { date: "2026-08-10", level: "not_monitored" }, + { date: "2026-08-11", level: "operational" }, + ]); + assert.equal(snapshot.components[1]?.history?.uptimePercent, 100); + assert.equal(snapshot.components[1]?.history?.uptimeText, "100.0%"); + assert.equal(snapshot.components[1]?.history?.monitoredSince, "2026-08-11"); +}); + +test("derives framework endpoints and permits narrow overrides", () => { + assert.deepEqual(statuspageEndpoints("https://status.example.com/"), { + summary: "https://status.example.com/api/v2/summary.json", + incidents: "https://status.example.com/api/v2/incidents.json", + maintenances: "https://status.example.com/api/v2/scheduled-maintenances.json", + }); + assert.deepEqual(statuspageEndpoints("https://status.example.com/", { summary: "https://api.example.com/current" }), { + summary: "https://api.example.com/current", + incidents: "https://status.example.com/api/v2/incidents.json", + maintenances: "https://status.example.com/api/v2/scheduled-maintenances.json", + }); +}); + +test("keeps an explicitly impact-free active incident separate from operational system status", async () => { + const summary = await fixture("summary-operational.json"); + const adapter = createStatuspageAdapter({ + providerId: "example", + statusPageUrl: "https://status.example.com/", + fetchJson: async (url) => { + if (url.includes("summary")) return summary; + if (url.includes("scheduled-maintenances")) return { scheduled_maintenances: [] }; + return { + incidents: [ + { + id: "active-without-impact", + name: "Requests are failing", + status: "identified", + impact: "none", + created_at: "2026-08-11T15:00:00Z", + updated_at: "2026-08-11T15:05:00Z", + }, + ], + }; + }, + fetchText: async () => "", + }); + + const snapshot = await adapter.fetch(new AbortController().signal); + + assert.equal(snapshot.health, "operational"); + assert.equal(snapshot.statusText, "All Systems Operational"); + assert.equal(snapshot.incidents[0]?.state, "identified"); + assert.equal(snapshot.incidents[0]?.stateText, "identified"); + assert.equal(snapshot.incidents[0]?.impactText, "none"); +}); + +test("treats rendered uptime as optional without swallowing cancellation", async () => { + const summary = await fixture("summary-operational.json"); + const createAdapter = () => + createStatuspageAdapter({ + providerId: "example", + statusPageUrl: "https://status.example.com/", + fetchJson: async (url) => { + if (url.includes("summary")) return summary; + return url.includes("scheduled-maintenances") ? { scheduled_maintenances: [] } : { incidents: [] }; + }, + fetchText: async () => { + throw new Error("uptime page unavailable"); + }, + }); + + const snapshot = await createAdapter().fetch(new AbortController().signal); + assert.ok(snapshot.components.every((component) => component.history === undefined)); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects(createAdapter().fetch(controller.signal), /aborted/i); +}); + +test("normalizes operational status", async () => { + const summary = await fixture("summary-operational.json"); + const parsed = parseSummary(summary); + + assert.equal(parsed.reportedHealth, "operational"); + assert.deepEqual( + parsed.components.map((component) => component.id), + ["api", "code"], + ); +}); + +test("filters incidents together with components for shared platform status pages", async () => { + const summary = await fixture("summary-degraded.json"); + const incidents = await fixture("incidents.json"); + const adapter = createStatuspageAdapter({ + providerId: "filtered-example", + statusPageUrl: "https://status.example.com/", + componentFilter: (component) => component.id === "api", + incidentFilter: (incident) => incident.affectedComponentIds.includes("api"), + fetchJson: async (url) => { + if (url.includes("summary")) return summary; + return url.includes("scheduled-maintenances") ? { scheduled_maintenances: [] } : incidents; + }, + fetchText: async () => "", + }); + + const snapshot = await adapter.fetch(new AbortController().signal); + + assert.deepEqual( + snapshot.components.map((component) => component.id), + ["api"], + ); + assert.deepEqual(snapshot.incidents, []); + assert.equal(snapshot.health, "operational"); + assert.equal(snapshot.statusText, undefined); +}); + +test("rejects malformed summaries instead of reporting them operational", () => { + assert.throws(() => parseSummary({ components: [] }), /Invalid status summary status response/); +}); + +test("rejects malformed embedded uptime data without inventing history", () => { + assert.deepEqual(parseStatuspageUptimeHtml("window.uptimeData = {not-json};"), {}); +}); + +test("maps known source states and keeps unfamiliar states unknown", () => { + assert.equal(mapFlexibleHealth("degraded_performance"), "degraded"); + assert.equal(mapFlexibleHealth("full_outage"), "major_outage"); + assert.equal(mapFlexibleHealth("downtime"), "major_outage"); + assert.equal(mapFlexibleHealth("maintenance_scheduled"), "maintenance"); + assert.equal(mapFlexibleHealth("new-provider-state"), "unknown"); +}); + +async function fixture(name: string): Promise { + const contents = await textFixture(name); + return JSON.parse(contents) as unknown; +} + +async function textFixture(name: string): Promise { + return readFile(`tests/fixtures/statuspage/${name}`, "utf8"); +} diff --git a/extensions/ai-status-check/tests/support/check-status-sources.ts b/extensions/ai-status-check/tests/support/check-status-sources.ts new file mode 100644 index 00000000000..b5abe236741 --- /dev/null +++ b/extensions/ai-status-check/tests/support/check-status-sources.ts @@ -0,0 +1,205 @@ +import { providerStatusPresentation } from "../../src/domain/status-presentation"; +import type { + ComponentHistory, + ComponentHistoryLevel, + Health, + Incident, + IncidentState, + ProviderSnapshot, +} from "../../src/domain/types"; +import { PROVIDERS } from "../../src/providers/registry"; + +const HEALTH_VALUES = new Set([ + "operational", + "degraded", + "partial_outage", + "major_outage", + "maintenance", + "unknown", +]); +const INCIDENT_STATE_VALUES = new Set([ + "investigating", + "identified", + "monitoring", + "resolved", + "scheduled", + "unknown", +]); +const HISTORY_LEVEL_VALUES = new Set([ + "operational", + "degraded", + "partial_outage", + "major_outage", + "maintenance", + "informational", + "not_monitored", + "unknown", +]); + +async function main() { + const results = await Promise.allSettled( + PROVIDERS.map(async (provider) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error("Timed out")), 8_000); + + try { + const snapshot = await provider.adapter.fetch(controller.signal); + validateSnapshot(snapshot, provider.id); + const histories = await Promise.all( + snapshot.components.map(async (component) => { + if (component.history) return component.history; + if (!provider.adapter.fetchComponentHistory) return undefined; + const history = await provider.adapter.fetchComponentHistory(component.id, controller.signal); + if (!history) throw new Error(`${component.name} did not publish component history`); + return history; + }), + ); + const publishedHistories = histories.filter((history): history is ComponentHistory => Boolean(history)); + if (snapshot.components.length > 0 && publishedHistories.length === 0) { + throw new Error("no component history could be read from the official source"); + } + for (const history of publishedHistories) validateHistory(history); + return { + provider: provider.name, + health: + snapshot.health === "unknown" && !snapshot.statusText + ? "No Overall Status" + : providerStatusPresentation(snapshot).label, + components: snapshot.components.length, + incidents: snapshot.incidents.length, + histories: publishedHistories.length, + exactUptimes: publishedHistories.filter((history) => history.uptimeText !== undefined).length, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown source error"; + throw new Error(`${provider.name}: ${message}`, { cause: error }); + } finally { + clearTimeout(timeout); + } + }), + ); + + let failures = 0; + for (const result of results) { + if (result.status === "fulfilled") { + const { provider, health, components, incidents, histories, exactUptimes } = result.value; + console.log( + `${provider}: ${health}; ${components} components; ${incidents} incidents; ${histories} histories; ${exactUptimes} exact uptimes`, + ); + } else { + failures += 1; + console.error(result.reason instanceof Error ? result.reason.message : "Unknown source error"); + } + } + + const passed = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); + console.log( + `Verified ${passed.length}/${PROVIDERS.length} providers; ${passed.reduce((sum, result) => sum + result.histories, 0)} component histories; ${passed.reduce((sum, result) => sum + result.exactUptimes, 0)} exact uptimes`, + ); + + if (failures > 0) process.exitCode = 1; +} + +function validateSnapshot(snapshot: ProviderSnapshot, providerId: string): void { + if (snapshot.providerId !== providerId) { + throw new Error(`snapshot provider ID was ${snapshot.providerId || "missing"}; expected ${providerId}`); + } + if (!HEALTH_VALUES.has(snapshot.health)) throw new Error(`snapshot used unknown health ${snapshot.health}`); + if (!validTimestamp(snapshot.fetchedAt)) throw new Error("snapshot fetchedAt was not a valid timestamp"); + + const componentIds = snapshot.components.map((component) => component.id); + if (componentIds.some((id) => !id) || new Set(componentIds).size !== componentIds.length) { + throw new Error("component IDs were missing or duplicated"); + } + for (const component of snapshot.components) { + if (!component.name.trim()) throw new Error(`${component.id} had no component name`); + if (!HEALTH_VALUES.has(component.health)) { + throw new Error(`${component.name} used unknown health ${component.health}`); + } + } + + const incidentIds = snapshot.incidents.map((incident) => incident.id); + if (incidentIds.some((id) => !id) || new Set(incidentIds).size !== incidentIds.length) { + throw new Error("incident IDs were missing or duplicated"); + } + for (const incident of snapshot.incidents) validateIncident(incident); +} + +function validateIncident(incident: Incident): void { + if (!incident.title.trim()) throw new Error(`${incident.id} had no incident title`); + if (!HEALTH_VALUES.has(incident.health)) throw new Error(`${incident.title} used unknown health ${incident.health}`); + if (!INCIDENT_STATE_VALUES.has(incident.state)) { + throw new Error(`${incident.title} used unknown lifecycle state ${incident.state}`); + } + for (const [field, value] of [ + ["startedAt", incident.startedAt], + ["updatedAt", incident.updatedAt], + ["resolvedAt", incident.resolvedAt], + ] as const) { + if (value !== undefined && !validTimestamp(value)) throw new Error(`${incident.title} had an invalid ${field}`); + } + const updateIds = incident.updates.map((update) => update.id); + if (updateIds.some((id) => !id) || new Set(updateIds).size !== updateIds.length) { + throw new Error(`${incident.title} had missing or duplicate update IDs`); + } + for (const update of incident.updates) { + if (!INCIDENT_STATE_VALUES.has(update.state) || !validTimestamp(update.createdAt) || !update.body.trim()) { + throw new Error(`${incident.title} had a malformed incident update`); + } + } +} + +function validateHistory(history: ComponentHistory): void { + if (history.windowDays !== history.days.length || history.days.length === 0) { + throw new Error("component history window did not match its published days"); + } + const dates = history.days.map((day) => day.date); + if ( + dates.some((date) => !/^\d{4}-\d{2}-\d{2}$/.test(date)) || + new Set(dates).size !== dates.length || + dates.some((date, index) => index > 0 && date <= dates[index - 1]!) + ) { + throw new Error("component history dates were duplicated or out of order"); + } + for (let index = 1; index < dates.length; index += 1) { + const previous = new Date(`${dates[index - 1]}T00:00:00Z`); + previous.setUTCDate(previous.getUTCDate() + 1); + if (previous.toISOString().slice(0, 10) !== dates[index]) { + throw new Error("component history dates were not contiguous"); + } + } + if (history.days.some((day) => !HISTORY_LEVEL_VALUES.has(day.level))) { + throw new Error("component history used an unknown severity level"); + } + if ( + history.uptimePercent !== undefined && + (!Number.isFinite(history.uptimePercent) || history.uptimePercent < 0 || history.uptimePercent > 100) + ) { + throw new Error("component history uptime was outside the valid percentage range"); + } + if (history.uptimeText !== undefined) { + const match = /^\s*(\d+(?:\.\d+)?)%\s*$/.exec(history.uptimeText); + if ( + !match || + history.uptimePercent === undefined || + Math.abs(Number(match[1]) - history.uptimePercent) > 0.000_001 + ) { + throw new Error("component history source uptime text did not match its numeric value"); + } + } + if (history.monitoredSince !== undefined) { + const monitoredSince = history.monitoredSince; + if (!/^\d{4}-\d{2}-\d{2}$/.test(monitoredSince)) { + throw new Error("component history monitoredSince was not a date"); + } + if (history.days.some((day) => day.date < monitoredSince && day.level !== "not_monitored")) { + throw new Error("component history treated pre-monitoring dates as monitored"); + } + } +} + +function validTimestamp(value: string): boolean { + return value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +void main(); diff --git a/extensions/ai-status-check/tests/support/check-store-assets.ts b/extensions/ai-status-check/tests/support/check-store-assets.ts new file mode 100644 index 00000000000..e17f6622243 --- /dev/null +++ b/extensions/ai-status-check/tests/support/check-store-assets.ts @@ -0,0 +1,125 @@ +import { access, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +interface ExtensionManifest { + name?: string; + title?: string; + description?: string; + author?: string; + icon?: string; + license?: string; + platforms?: unknown[]; + commands?: unknown[]; + scripts?: Record; +} + +const root = process.cwd(); +const allowMissingScreenshots = process.argv.includes("--allow-missing-screenshots"); +const errors: string[] = []; + +async function exists(relativePath: string): Promise { + try { + await access(path.join(root, relativePath)); + return true; + } catch { + return false; + } +} + +async function requireFile(relativePath: string) { + if (!(await exists(relativePath))) errors.push(`Missing required file: ${relativePath}`); +} + +async function pngSize(relativePath: string): Promise<{ width: number; height: number } | undefined> { + const contents = await readFile(path.join(root, relativePath)); + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if (contents.length < 24 || !contents.subarray(0, 8).equals(signature)) { + errors.push(`${relativePath} is not a valid PNG file`); + return undefined; + } + + return { width: contents.readUInt32BE(16), height: contents.readUInt32BE(20) }; +} + +async function findFiles(relativeDirectory: string): Promise { + if (!(await exists(relativeDirectory))) return []; + + const entries = await readdir(path.join(root, relativeDirectory), { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const relativePath = path.join(relativeDirectory, entry.name); + return entry.isDirectory() ? findFiles(relativePath) : [relativePath]; + }), + ); + return files.flat(); +} + +async function main() { + for (const requiredFile of ["README.md", "CHANGELOG.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "package-lock.json"]) { + await requireFile(requiredFile); + } + + for (const forbiddenPath of [".firecrawl", ".test-dist", "docs", "scripts"]) { + if (await exists(forbiddenPath)) errors.push(`Development-only path must not be packaged: ${forbiddenPath}`); + } + + const packageContents = await readFile(path.join(root, "package.json"), "utf8"); + const manifest = JSON.parse(packageContents) as ExtensionManifest; + if (!manifest.name || !manifest.title || !manifest.description) + errors.push("Manifest identity fields are incomplete"); + if (manifest.author !== "Astatine-213") errors.push("Manifest author must match the Raycast username Astatine-213"); + if (manifest.license !== "MIT") errors.push("Manifest license must be MIT"); + if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0) + errors.push("Manifest platforms are missing"); + if (!Array.isArray(manifest.commands) || manifest.commands.length === 0) errors.push("Manifest commands are missing"); + + const unpublishedScriptPaths = Object.values(manifest.scripts ?? {}).filter( + (script): script is string => typeof script === "string" && script.includes(".github/"), + ); + if (unpublishedScriptPaths.length > 0) { + errors.push("Package scripts must not reference .github because Raycast excludes it from Store submissions"); + } + + const iconPath = manifest.icon ? path.join("assets", manifest.icon) : undefined; + if (!iconPath || !(await exists(iconPath))) { + errors.push("Manifest icon is missing"); + } else { + const size = await pngSize(iconPath); + if (size && (size.width !== 512 || size.height !== 512)) { + errors.push(`${iconPath} must be exactly 512x512 pixels`); + } + } + + const sourceAssets = (await findFiles("assets")).filter((file) => file.endsWith("-source.svg")); + if (sourceAssets.length > 0) errors.push(`Unused source assets remain: ${sourceAssets.join(", ")}`); + + const metadataFiles = await findFiles("metadata"); + const screenshots = metadataFiles.filter((file) => file.toLowerCase().endsWith(".png")); + const unsupportedMetadata = metadataFiles.filter((file) => !file.toLowerCase().endsWith(".png")); + if (unsupportedMetadata.length > 0) { + errors.push(`Store screenshot metadata must be PNG only: ${unsupportedMetadata.join(", ")}`); + } + if (screenshots.length === 0 && allowMissingScreenshots) { + console.warn("Store screenshots are not present yet; strict publish validation will require 3 to 6 PNG files."); + } else { + if (screenshots.length < 3 || screenshots.length > 6) { + errors.push(`Store metadata must contain 3 to 6 PNG screenshots; found ${screenshots.length}`); + } + for (const screenshot of screenshots) { + const size = await pngSize(screenshot); + if (size && (size.width !== 2000 || size.height !== 1250)) { + errors.push(`${screenshot} must be exactly 2000x1250 pixels`); + } + } + } + + if (errors.length > 0) { + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 1; + return; + } + + console.log("Raycast Store metadata and package assets passed validation."); +} + +void main(); diff --git a/extensions/ai-status-check/tests/support/run-tests.ts b/extensions/ai-status-check/tests/support/run-tests.ts new file mode 100644 index 00000000000..e3a3229e891 --- /dev/null +++ b/extensions/ai-status-check/tests/support/run-tests.ts @@ -0,0 +1,16 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import path from "node:path"; + +const testDirectory = path.resolve("node_modules/.cache/ai-status-check-tests/tests"); +const testFiles = readdirSync(testDirectory) + .filter((file) => file.endsWith(".test.js")) + .sort() + .map((file) => path.join(testDirectory, file)); + +if (testFiles.length === 0) throw new Error("No compiled test files were found"); + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { stdio: "inherit" }); + +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/extensions/ai-status-check/tsconfig.json b/extensions/ai-status-check/tsconfig.json new file mode 100644 index 00000000000..22cb474e687 --- /dev/null +++ b/extensions/ai-status-check/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*", "scripts/**/*", "tests/**/*", "raycast-env.d.ts"] +} diff --git a/extensions/ai-status-check/tsconfig.test.json b/extensions/ai-status-check/tsconfig.test.json new file mode 100644 index 00000000000..45e6ba8f166 --- /dev/null +++ b/extensions/ai-status-check/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "isolatedModules": false, + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": "node_modules/.cache/ai-status-check-tests", + "rootDir": "." + }, + "include": [ + "src/domain/**/*.ts", + "src/providers/adapters/**/*.ts", + "src/providers/registry.ts", + "src/services/fetch-provider-statuses.ts", + "src/services/provider-request-order.ts", + "tests/**/*.ts" + ] +}