diff --git a/extensions/mise/.gitignore b/extensions/mise/.gitignore new file mode 100644 index 00000000000..0e0a494673d --- /dev/null +++ b/extensions/mise/.gitignore @@ -0,0 +1,17 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# Raycast specific files +raycast-env.d.ts +.raycast-swift-build +.swiftpm +compiled_raycast_swift +compiled_raycast_rust + +# misc +.DS_Store + +# local agent tooling (verify-mise harness); not part of the extension +.agents/ diff --git a/extensions/mise/.prettierignore b/extensions/mise/.prettierignore new file mode 100644 index 00000000000..25758b95ab3 --- /dev/null +++ b/extensions/mise/.prettierignore @@ -0,0 +1,6 @@ +*.md +raycast-env.d.ts +node_modules +metadata +.agents +*.txt diff --git a/extensions/mise/.prettierrc b/extensions/mise/.prettierrc new file mode 100644 index 00000000000..fc0f5030683 --- /dev/null +++ b/extensions/mise/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "singleQuote": false +} diff --git a/extensions/mise/CHANGELOG.md b/extensions/mise/CHANGELOG.md new file mode 100644 index 00000000000..e09f63f61fc --- /dev/null +++ b/extensions/mise/CHANGELOG.md @@ -0,0 +1,3 @@ +# Mise Changelog + +## [Initial Version] - 2026-10-09 diff --git a/extensions/mise/README.md b/extensions/mise/README.md new file mode 100644 index 00000000000..0c970500403 --- /dev/null +++ b/extensions/mise/README.md @@ -0,0 +1,41 @@ +# Mise + +Raycast commands for [mise](https://mise.jdx.dev) (mise-en-place), the polyglot dev-tool version manager by [jdx](https://github.com/jdx/mise): search, install and upgrade the tools it manages, and run its tasks, from Raycast. + +## Requirements + +mise must be installed. The extension looks for it at `~/.local/bin/mise`, `/opt/homebrew/bin/mise` and `/usr/local/bin/mise`, then asks your login shell. If none of those find it, set the **Mise Path** preference to the binary's absolute path. + +Every mise command runs with your login shell's environment (`zsh -il` or `bash -il`, read once a day), so the `PATH` and exports such as `CARGO_HOME` from your `.zshrc` apply exactly as they do in a terminal. + +Install mise with the one-liner from [mise.jdx.dev](https://mise.jdx.dev/getting-started.html): + +```sh +curl https://mise.run | sh +``` + +## Commands + +- **Search Tools** browses the mise registry and adds a tool to your global config (`mise use -g`). +- **Show Installed Tools** shows every installed tool and version, and can set the global version, upgrade, uninstall, or reveal the install directory. +- **Show Outdated Tools** lists the tools behind their latest version with Upgrade and Upgrade All, and shows the count as its own subtitle in Raycast's root search. +- **Manage Tasks** lists your global mise tasks and runs one in your terminal (`mise run `), or runs it behind a toast and shows the captured output. +- **Tools Menu Bar** sits in the menu bar with the number of outdated tools, refreshed every twelve hours, and upgrades them with one click. +- **Upgrade All Tools** runs `mise upgrade` for every tool behind a progress toast, without opening a window. +- **Prune Unused Versions** runs `mise prune`, deleting installed versions that no config uses. +- **Clear Cache** runs `mise cache clear`. It is disabled by default; enable it in the extension's settings. + +All commands work on your global mise configuration, which is what applies when no project config is active. + +## Preferences + +- **Mise Path** points at the binary when automatic detection fails. +- **Terminal Application** is where **Run in Terminal** (⌘T on a tool, an upgrade or a version) opens the same mise command instead of running it behind a toast. Ghostty, iTerm2 and Terminal are supported; anything else falls back to Terminal. +- **Upgrade past the configured range** adds `--bump` to `mise upgrade` and `mise outdated`, rewriting the version in your config. +- **Include inactive tools** adds `--inactive` to `mise outdated` and `mise upgrade`. +- **Remove the tool from config when uninstalling a configured version** runs `mise unuse` on the config file instead of `mise uninstall`. +- **Parallel Jobs** passes `-j ` to `mise use` and `mise upgrade`. +- **Show inactive tools in Show Installed Tools** toggles the Inactive section. +- **Close window after actions** closes Raycast when an install, upgrade or uninstall starts and shows the result as a HUD. +- **Log mise commands to the console** prints every mise command line, exit code and duration to the extension's console. +- **Search Tools › Show details panel by default**, **Prune Unused Versions › What to prune** (`--tools` or `--configs`) and **Upgrade All Tools › Uninstall replaced versions** (`--prune`) are set on their own command. diff --git a/extensions/mise/assets/mise.png b/extensions/mise/assets/mise.png new file mode 100644 index 00000000000..843128d557d Binary files /dev/null and b/extensions/mise/assets/mise.png differ diff --git a/extensions/mise/eslint.config.mjs b/extensions/mise/eslint.config.mjs new file mode 100644 index 00000000000..e2471642cc6 --- /dev/null +++ b/extensions/mise/eslint.config.mjs @@ -0,0 +1,3 @@ +import raycast from "@raycast/eslint-config"; + +export default [...raycast, { ignores: ["raycast-env.d.ts"] }]; diff --git a/extensions/mise/metadata/mise-1.png b/extensions/mise/metadata/mise-1.png new file mode 100644 index 00000000000..4656dffcd87 Binary files /dev/null and b/extensions/mise/metadata/mise-1.png differ diff --git a/extensions/mise/metadata/mise-2.png b/extensions/mise/metadata/mise-2.png new file mode 100644 index 00000000000..0ace492b78b Binary files /dev/null and b/extensions/mise/metadata/mise-2.png differ diff --git a/extensions/mise/metadata/mise-3.png b/extensions/mise/metadata/mise-3.png new file mode 100644 index 00000000000..97edbfd2cb6 Binary files /dev/null and b/extensions/mise/metadata/mise-3.png differ diff --git a/extensions/mise/metadata/mise-4.png b/extensions/mise/metadata/mise-4.png new file mode 100644 index 00000000000..23a61971796 Binary files /dev/null and b/extensions/mise/metadata/mise-4.png differ diff --git a/extensions/mise/metadata/mise-5.png b/extensions/mise/metadata/mise-5.png new file mode 100644 index 00000000000..ee37fb61468 Binary files /dev/null and b/extensions/mise/metadata/mise-5.png differ diff --git a/extensions/mise/metadata/mise-6.png b/extensions/mise/metadata/mise-6.png new file mode 100644 index 00000000000..dcf25cf8187 Binary files /dev/null and b/extensions/mise/metadata/mise-6.png differ diff --git a/extensions/mise/package-lock.json b/extensions/mise/package-lock.json new file mode 100644 index 00000000000..e1e8a04f223 --- /dev/null +++ b/extensions/mise/package-lock.json @@ -0,0 +1,3974 @@ +{ + "name": "mise", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mise", + "license": "MIT", + "dependencies": { + "@raycast/api": "^2.4.1", + "@raycast/utils": "^2.3.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@raycast/eslint-plugin": "^2.2.0", + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "@types/react-test-renderer": "19.0.0", + "eslint": "^10.10.0", + "prettier": "^3.9.7", + "react-test-renderer": "19.0.0", + "typescript": "^6.0.3", + "vitest": "^5.0.1" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "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/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oclif/core": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.14.0.tgz", + "integrity": "sha512-QCJIZoVJxV7jywgVAUlsQwl3dr3Sa21kfi5z9KrYolbexWJUtFSEtMoNiBWojD05mYycLnB50gp/VxlGdaOCRA==", + "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": "^6", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.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", + "wsl-utils": "^0.4.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.3.0.tgz", + "integrity": "sha512-5CRpSH9FNub2IwAej2PxR6GEbQNLCy0XTzp/KqpZh6U4qiCTLPz20IXcUx9luarJBKRe2qu1cy1acHggo2MMcQ==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete/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/@oclif/plugin-help": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.3.0.tgz", + "integrity": "sha512-cpSRv7tyHHFPv39HrV22eNmu8ylrwjmPvqdlNo3URp79+p5319p+a8PI/3ez6A6GZmjwBj1EWUdVz1aPUAsSmw==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.3.0.tgz", + "integrity": "sha512-GbdWJOmmBO3xmrVjDXeWG7tXl4vzeGZ+af9ztCfAmhOPEkd/+eeAAadzQPeZoygedqCvsdux3QLq9/MAxusPKg==", + "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/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@raycast/api": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@raycast/api/-/api-2.4.1.tgz", + "integrity": "sha512-gXtrv6Ju4LV0b20/u9cTYte3OgFL2wXSBDG6E43Xo/sQDgkuJk4sRD6PRqOJoVo60F0SlyhD5yG9ysEF2PJJFA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@oclif/core": "^4.11.4", + "@oclif/plugin-autocomplete": "^3.2.50", + "@oclif/plugin-help": "^6.2.50", + "@oclif/plugin-not-found": "^3.2.87", + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "esbuild": "^0.28.0", + "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/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/@raycast/utils": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@raycast/utils/-/utils-2.3.1.tgz", + "integrity": "sha512-GaByXcbQP05vBkHw3rPevZRkSmVZikOIXwbKOLFlQqqC9T8MxoDYp9KyeXBngjnaDzS3ELGqGkmoxNueGhjJww==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "@raycast/api": ">=1.99.4", + "react": ">=19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.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": "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", + "peer": true, + "dependencies": { + "undici-types": "~6.21.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/@types/react-test-renderer": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.0.0.tgz", + "integrity": "sha512-qDVnNybqFm2eZKJ4jD34EvRd6VHD67KjgnWaEMM0Id9L22EpWe3nOSVKHWL1XWRCxUWe3lhXwlEeCKD1BlJCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.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.70.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.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.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.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.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/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "peer": true, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/ejs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", + "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "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/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "peer": true, + "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.3", + "@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": "11.1.5 || >11.1.6 <12", + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "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": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "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": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "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/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "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": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "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-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-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": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.1.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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "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/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.7.tgz", + "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "dev": true, + "license": "MIT", + "peer": true, + "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/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "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", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-test-renderer": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.0.0.tgz", + "integrity": "sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.0.0", + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "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/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "dev": true, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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/wsl-utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.4.0.tgz", + "integrity": "sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/mise/package.json b/extensions/mise/package.json new file mode 100644 index 00000000000..896b41f0eb8 --- /dev/null +++ b/extensions/mise/package.json @@ -0,0 +1,238 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "mise", + "title": "Mise", + "description": "Search, install and upgrade the dev tools managed by mise.", + "icon": "mise.png", + "author": "lachieh", + "platforms": [ + "macOS" + ], + "categories": [ + "Developer Tools" + ], + "license": "MIT", + "keywords": [ + "mise", + "asdf", + "version manager", + "node", + "python", + "go", + "rust", + "mise-en-place", + "jdx" + ], + "commands": [ + { + "name": "search-tools", + "title": "Search Tools", + "subtitle": "Mise", + "description": "Search the mise registry and add tools to your global config", + "mode": "view", + "preferences": [ + { + "name": "showDetails", + "type": "checkbox", + "required": false, + "title": "Details", + "label": "Show details panel by default", + "description": "Open Search Tools with the details panel showing.", + "default": false + } + ] + }, + { + "name": "installed-tools", + "title": "Show Installed Tools", + "subtitle": "Mise", + "description": "Browse installed mise tools, set global versions, upgrade and uninstall", + "mode": "view" + }, + { + "name": "show-outdated", + "title": "Show Outdated Tools", + "subtitle": "Mise", + "description": "List outdated mise tools and upgrade them", + "mode": "view" + }, + { + "name": "tasks", + "title": "Manage Tasks", + "subtitle": "Mise", + "description": "Run global mise tasks in your terminal or capture their output", + "mode": "view" + }, + { + "name": "outdated-tools", + "title": "Tools Menu Bar", + "subtitle": "Mise", + "description": "Show outdated mise tools in the menu bar and upgrade them", + "mode": "menu-bar", + "interval": "12h" + }, + { + "name": "upgrade-all", + "title": "Upgrade All Tools", + "subtitle": "Mise", + "description": "Upgrade every outdated mise tool", + "mode": "no-view", + "preferences": [ + { + "name": "pruneReplaced", + "type": "checkbox", + "required": false, + "title": "Replaced Versions", + "label": "Uninstall replaced versions", + "description": "Add --prune to mise upgrade, so the versions an upgrade replaces are uninstalled at once.", + "default": false + } + ] + }, + { + "name": "prune", + "title": "Prune Unused Versions", + "subtitle": "Mise", + "description": "Delete installed tool versions that no config uses", + "mode": "no-view", + "preferences": [ + { + "name": "pruneScope", + "type": "dropdown", + "required": false, + "title": "Prune Scope", + "description": "Prune unused tool versions, stale config links, or both.", + "default": "all", + "data": [ + { + "title": "Tool versions and config links", + "value": "all" + }, + { + "title": "Tool versions only (--tools)", + "value": "tools" + }, + { + "title": "Config links only (--configs)", + "value": "configs" + } + ] + } + ] + }, + { + "name": "clear-cache", + "title": "Clear Cache", + "subtitle": "Mise", + "description": "Delete mise's cache files", + "mode": "no-view", + "disabledByDefault": true + } + ], + "preferences": [ + { + "name": "misePath", + "type": "textfield", + "required": false, + "title": "Mise Path", + "description": "Absolute path to the mise binary. Leave empty to detect automatically.", + "placeholder": "/Users/you/.local/bin/mise" + }, + { + "name": "terminalApp", + "type": "appPicker", + "required": false, + "title": "Terminal Application", + "description": "Used by Run in Terminal actions. Defaults to Terminal." + }, + { + "name": "upgradeBump", + "type": "checkbox", + "required": false, + "title": "Upgrade Behavior", + "label": "Upgrade past the configured range", + "description": "Add --bump to mise upgrade and mise outdated, so a tool pinned to a range moves to the latest version and the version in your config is rewritten.", + "default": false + }, + { + "name": "includeInactive", + "type": "checkbox", + "required": false, + "title": "Inactive Tools", + "label": "Include inactive tools", + "description": "Add --inactive to mise outdated and mise upgrade, so installed versions no config activates are checked and upgraded too.", + "default": false + }, + { + "name": "uninstallRemovesConfig", + "type": "checkbox", + "required": false, + "title": "Uninstall Behavior", + "label": "Remove the tool from config when uninstalling a configured version", + "description": "Uninstalling a version that a config file requests runs mise unuse on that file instead of mise uninstall, so the tool is removed from the config as well.", + "default": false + }, + { + "name": "jobs", + "type": "textfield", + "required": false, + "title": "Parallel Jobs", + "description": "Number of tools mise installs or upgrades at once (-j). Leave empty for mise's default.", + "placeholder": "8" + }, + { + "name": "showInactive", + "type": "checkbox", + "required": false, + "title": "Show Installed Tools List", + "label": "Show inactive tools in Show Installed Tools", + "description": "List installed versions that no config activates in their own Inactive section.", + "default": true + }, + { + "name": "closeAfterAction", + "type": "checkbox", + "required": false, + "title": "Window Behavior", + "label": "Close window after actions", + "description": "Close the Raycast window after install, upgrade and uninstall and show the result as a HUD.", + "default": false + }, + { + "name": "debugLogging", + "type": "checkbox", + "required": false, + "title": "Debug Logging", + "label": "Log mise commands to the console", + "description": "Print every mise command line, its exit code and duration to the extension's console.", + "default": false + } + ], + "dependencies": { + "@raycast/api": "^2.4.1", + "@raycast/utils": "^2.3.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@raycast/eslint-plugin": "^2.2.0", + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "@types/react-test-renderer": "19.0.0", + "eslint": "^10.10.0", + "prettier": "^3.9.7", + "react-test-renderer": "19.0.0", + "typescript": "^6.0.3", + "vitest": "^5.0.1" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "test": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:all": "vitest run", + "prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/extensions/mise/src/clear-cache.ts b/extensions/mise/src/clear-cache.ts new file mode 100644 index 00000000000..bc847525640 --- /dev/null +++ b/extensions/mise/src/clear-cache.ts @@ -0,0 +1,6 @@ +import { clearCache } from "./mise/operations"; +import { runOperationWithoutView } from "./ui/runOperation"; + +export default function Command() { + return runOperationWithoutView(clearCache()); +} diff --git a/extensions/mise/src/installed-tools.test.tsx b/extensions/mise/src/installed-tools.test.tsx new file mode 100644 index 00000000000..17991808cee --- /dev/null +++ b/extensions/mise/src/installed-tools.test.tsx @@ -0,0 +1,185 @@ +import { LaunchType, type LaunchProps } from "@raycast/api"; +import type { ReactTestInstance } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { Action, ActionPanel, defaultPreferences, Detail, List, mocks } from "../test/raycast-api"; +import { useCachedPromiseFixtures } from "../test/raycast-utils"; +import { flush, render } from "../test/render"; +import Command from "./installed-tools"; +import { listConfigFiles, parseConfigFiles } from "./mise/config"; +import * as exec from "./mise/exec"; +import { listInstalled, parseInstalled } from "./mise/installed"; +import { listRemote, parseRemote } from "./mise/remote"; +import configFixture from "./mise/fixtures/config-ls.json"; +import lsFixture from "./mise/fixtures/ls.json"; +import lsRemoteFixture from "./mise/fixtures/ls-remote.json"; + +const launch: LaunchProps = { arguments: {}, launchType: LaunchType.UserInitiated }; + +function titles(node: ReactTestInstance) { + return node.findAllByType(List.Item).map((n) => n.props.title as string); +} + +describe("Installed Tools", () => { + function setup() { + useCachedPromiseFixtures.set(listInstalled, parseInstalled(lsFixture)); + useCachedPromiseFixtures.set(listConfigFiles, parseConfigFiles(configFixture)); + useCachedPromiseFixtures.set(listRemote, parseRemote(lsRemoteFixture)); + } + + it("splits tools into Active and Inactive by whether any version is active", async () => { + setup(); + const { root } = await render(); + const [active, inactive] = root.findAllByType(List.Section); + + expect(active.props).toMatchObject({ title: "Active", subtitle: "3" }); + expect(titles(active)).toEqual(["node", "npm:wrangler", "rust"]); + expect(active.findAllByType(List.Item)[0].props).toMatchObject({ + subtitle: "lts → 24.21.0", + accessories: [{ text: "8 versions" }, { tag: "tools.toml" }], + }); + expect(inactive.props).toMatchObject({ title: "Inactive", subtitle: "1" }); + expect(titles(inactive)).toEqual(["jq"]); + }); + + it("hides the Inactive section when the showInactive preference is off", async () => { + setup(); + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, showInactive: false }); + const { root } = await render(); + expect(root.findAllByType(List.Section).map((n) => n.props.title)).toEqual(["Active"]); + expect(titles(root)).toEqual(["node", "npm:wrangler", "rust"]); + }); + + it("runs mise upgrade with --bump --inactive -j 4 when the preferences ask for them", async () => { + setup(); + mocks.getPreferenceValues.mockReturnValue({ + ...defaultPreferences, + upgradeBump: true, + includeInactive: true, + jobs: "4", + }); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const { root } = await render(); + const node = root.findAllByType(List.Item).find((n) => n.props.title === "node"); + const panel = await render(node?.props.actions); + const action = panel.root.findAllByType(Action).find((n) => n.props.title === "Upgrade"); + await flush(() => action?.props.onAction()); + expect(runMise).toHaveBeenCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["upgrade", "--bump", "--inactive", "-j", "4", "node"], + expect.anything(), + ); + }); + + it("uninstalls a configured version through unuse on its config file when the preference is on", async () => { + setup(); + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, uninstallRemovesConfig: true }); + mocks.confirmAlert.mockResolvedValue(true); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const { root } = await render(); + const node = root.findAllByType(List.Item).find((n) => n.props.title === "node"); + const panel = await render(node?.props.actions); + const submenu = panel.root.findAllByType(ActionPanel.Submenu).find((n) => n.props.title === "Uninstall Version…"); + const versions = submenu?.findAllByType(Action) ?? []; + expect(versions.map((n) => n.props.title)).toContain("24.21.0"); + + await flush(() => versions.find((n) => n.props.title === "24.21.0")?.props.onAction()); + expect(mocks.confirmAlert).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Uninstall node@24.21.0?", message: "Also removes node from tools.toml" }), + ); + expect(runMise).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["unuse", "--path", "/Users/lachlan/.config/mise/conf.d/tools.toml", "node@lts"], + expect.anything(), + ); + + await flush(() => versions.find((n) => n.props.title === "22.21.0")?.props.onAction()); + expect(runMise).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["uninstall", "node@22.21.0"], + expect.anything(), + ); + }); + + it("pushes a version picker listing remote versions newest first with installed ones marked", async () => { + setup(); + const { root } = await render(); + const jq = root.findAllByType(List.Item).find((n) => n.props.title === "jq"); + const panel = await render(jq?.props.actions); + const push = panel.root.findAllByType(Action.Push).find((n) => n.props.title === "Set Global Version…"); + + const picker = await render(push?.props.target); + expect(picker.root.findByType(List).props.navigationTitle).toBe("Set Global Version — jq"); + const items = picker.root.findAllByType(List.Item); + expect(items.map((n) => n.props.title)).toEqual([ + "1.8.2", + "1.8.1", + "1.8.0", + "1.7.1", + "1.7", + "1.6", + "1.5", + "1.4", + "1.3", + ]); + expect(items.map((n) => n.props.icon)).toEqual([ + "CheckCircle", + "CheckCircle", + "Circle", + "Circle", + "Circle", + "Circle", + "Circle", + "Circle", + "Circle", + ]); + expect(items[0].props.accessories).toEqual([{ text: "2026-06-20" }]); + }); + + it("shows the error with a Retry that refetches when mise ls fails", async () => { + setup(); + useCachedPromiseFixtures.fail(listInstalled, new Error("boom")); + const { root } = await render(); + + const empty = root.findByType(List.EmptyView); + expect(empty.props).toMatchObject({ title: "Couldn't load from mise", description: "boom" }); + expect(root.findAllByType(List.Item)).toEqual([]); + + const panel = await render(empty.props.actions); + await flush(() => + panel.root + .findAllByType(Action) + .find((n) => n.props.title === "Retry") + ?.props.onAction(), + ); + expect(useCachedPromiseFixtures.revalidateOf(listInstalled)).toHaveBeenCalledTimes(1); + }); + + it("shows the error in the version picker with a Retry that refetches when ls-remote fails", async () => { + setup(); + useCachedPromiseFixtures.fail(listRemote, new Error("boom")); + const { root } = await render(); + const jq = root.findAllByType(List.Item).find((n) => n.props.title === "jq"); + const panel = await render(jq?.props.actions); + const push = panel.root.findAllByType(Action.Push).find((n) => n.props.title === "Set Global Version…"); + + const picker = await render(push?.props.target); + const empty = picker.root.findByType(List.EmptyView); + expect(empty.props).toMatchObject({ title: "Couldn't load from mise", description: "boom" }); + expect(picker.root.findAllByType(List.Item)).toEqual([]); + + const actions = await render(empty.props.actions); + await flush(() => + actions.root + .findAllByType(Action) + .find((n) => n.props.title === "Retry") + ?.props.onAction(), + ); + expect(useCachedPromiseFixtures.revalidateOf(listRemote)).toHaveBeenCalledTimes(1); + }); + + it("renders MissingMise when the configured mise path does not exist", async () => { + mocks.getPreferenceValues.mockReturnValue({ misePath: "/nonexistent/mise" }); + const { root } = await render(); + expect(root.findByType(Detail).props.markdown).toContain("curl https://mise.run"); + }); +}); diff --git a/extensions/mise/src/installed-tools.tsx b/extensions/mise/src/installed-tools.tsx new file mode 100644 index 00000000000..11e18bc4f1b --- /dev/null +++ b/extensions/mise/src/installed-tools.tsx @@ -0,0 +1,264 @@ +import { + Action, + ActionPanel, + Alert, + confirmAlert, + Icon, + launchCommand, + type LaunchProps, + LaunchType, + List, + useNavigation, +} from "@raycast/api"; +import { useCachedPromise } from "@raycast/utils"; +import { basename } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { listInstalled, type InstalledTool, type InstalledVersion } from "./mise/installed"; +import type { MiseLocation } from "./mise/locate"; +import { uninstall, upgrade } from "./mise/operations"; +import { listRemote } from "./mise/remote"; +import { miseCommandLine } from "./terminal/script"; +import { addGloballyTo, InstallTargetDropdown, UseGloballyIn, useInstallTarget } from "./ui/InstallTarget"; +import { LoadError, loadErrorInView } from "./ui/LoadError"; +import { MissingMise } from "./ui/MissingMise"; +import { readPreferences, upgradeOptions, type MisePreferences } from "./ui/preferences"; +import { RunInTerminalAction } from "./ui/runInTerminal"; +import { runOperation } from "./ui/runOperation"; +import { useMise } from "./ui/useMise"; + +type LaunchContext = { upgrade?: string }; + +export default function Command(props: LaunchProps<{ launchContext?: LaunchContext }>) { + const mise = useMise(); + if (mise.status === "loading") return ; + if (mise.status === "missing") return ; + return ; +} + +function InstalledTools({ location, upgradeOnLaunch }: { location: MiseLocation; upgradeOnLaunch?: string }) { + const installed = useCachedPromise(listInstalled, [location], loadErrorInView); + const [prefs] = useState(readPreferences); + const [showDetail, setShowDetail] = useState(false); + const launchHandled = useRef(false); + const afterUpgrade = useCallback(() => { + installed.revalidate(); + refreshOutdated(); + }, [installed.revalidate]); + + useEffect(() => { + if (!upgradeOnLaunch || launchHandled.current) return; + launchHandled.current = true; + const op = upgrade(upgradeOnLaunch === "all" ? undefined : upgradeOnLaunch, upgradeOptions(prefs)); + runOperation(location, op, afterUpgrade); + }, [location, upgradeOnLaunch, afterUpgrade, prefs]); + + const tools = installed.data ?? []; + const activeTools = tools.filter((tool) => tool.versions.some((v) => v.active)); + const inactiveTools = tools.filter((tool) => !tool.versions.some((v) => v.active)); + + const renderItem = (tool: InstalledTool) => ( + setShowDetail((value) => !value)} + /> + ); + + return ( + + {installed.error && !installed.data ? ( + + ) : ( + !installed.isLoading && ( + + ) + )} + + {activeTools.map(renderItem)} + + {prefs.showInactive && ( + + {inactiveTools.map(renderItem)} + + )} + + ); +} + +function refreshOutdated() { + launchCommand({ name: "outdated-tools", type: LaunchType.Background }).catch(() => undefined); +} + +function shownVersion(tool: InstalledTool): InstalledVersion { + return tool.versions.find((v) => v.active) ?? tool.versions[tool.versions.length - 1]; +} + +function ToolItem({ + tool, + location, + prefs, + revalidate, + afterUpgrade, + onToggleDetail, +}: { + tool: InstalledTool; + location: MiseLocation; + prefs: MisePreferences; + revalidate: () => void; + afterUpgrade: () => void; + onToggleDetail: () => void; +}) { + const shown = shownVersion(tool); + const subtitle = + shown.requestedVersion && shown.requestedVersion !== shown.version + ? `${shown.requestedVersion} → ${shown.version}` + : shown.version; + const accessories: List.Item.Accessory[] = []; + if (tool.versions.length > 1) accessories.push({ text: `${tool.versions.length} versions` }); + if (shown.source) accessories.push({ tag: basename(shown.source.path) }); + + const upgradeOp = upgrade(tool.name, upgradeOptions(prefs)); + const confirmUninstall = async (v: InstalledVersion) => { + const unuse = + prefs.uninstallRemovesConfig && v.source && v.requestedVersion + ? { configFile: v.source.path, requested: v.requestedVersion } + : undefined; + const confirmed = await confirmAlert({ + title: `Uninstall ${tool.name}@${v.version}?`, + message: unuse ? `Also removes ${tool.name} from ${basename(unuse.configFile)}` : undefined, + primaryAction: { title: "Uninstall", style: Alert.ActionStyle.Destructive }, + }); + if (confirmed) await runOperation(location, uninstall(tool.name, v.version, { unuse }), revalidate); + }; + + return ( + v.version)} + accessories={accessories} + detail={} + actions={ + + } + /> + runOperation(location, upgradeOp, afterUpgrade)} + /> + + + {tool.versions.map((v) => ( + confirmUninstall(v)} + /> + ))} + + + + + + } + /> + ); +} + +function detailMarkdown(tool: InstalledTool): string { + const lines = tool.versions.flatMap((v) => { + const requested = v.requestedVersion ? ` — requested ${v.requestedVersion}` : ""; + const entry = [`- ${v.version}${v.active ? " (active)" : ""}${requested}`, ` - \`${v.installPath}\``]; + if (v.source) entry.push(` - from \`${v.source.path}\``); + return entry; + }); + return [`# ${tool.name}`, "", ...lines].join("\n"); +} + +function VersionPicker({ + location, + tool, + jobs, + onChanged, +}: { + location: MiseLocation; + tool: InstalledTool; + jobs: number | undefined; + onChanged: () => void; +}) { + const remote = useCachedPromise(listRemote, [location, tool.name], loadErrorInView); + const install = useInstallTarget(location); + const { pop } = useNavigation(); + const installedByVersion = new Map(tool.versions.map((v) => [v.version, v])); + const versions = [...(remote.data ?? [])].reverse(); + + const useVersion = (version: string, configFile: string) => addGloballyTo(tool.name, version, { configFile, jobs }); + const setGlobal = async (version: string, configFile: string) => { + await runOperation(location, useVersion(version, configFile), onChanged); + pop(); + }; + + return ( + + ) + } + > + {remote.error && !remote.data ? ( + + ) : ( + !remote.isLoading && + )} + {versions.map((v) => { + const present = installedByVersion.get(v.version); + const accessories: List.Item.Accessory[] = []; + if (present?.active) accessories.push({ tag: "active" }); + if (v.createdAt) accessories.push({ text: v.createdAt.slice(0, 10) }); + return ( + + setGlobal(v.version, install.target)} + /> + setGlobal(v.version, configFile)} /> + + {v.releaseUrl && } + + } + /> + ); + })} + + ); +} diff --git a/extensions/mise/src/mise/backends.test.ts b/extensions/mise/src/mise/backends.test.ts new file mode 100644 index 00000000000..288887ad655 --- /dev/null +++ b/extensions/mise/src/mise/backends.test.ts @@ -0,0 +1,184 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { parseBackendQuery, searchBackend, type BackendSearchDeps } from "./backends"; +import { MiseExitError, MiseOutputError } from "./exec"; +import type { RemoteVersion } from "./remote"; +import cargoFixture from "./fixtures/cargo-search.json"; +import gemFixture from "./fixtures/gem-search.json"; +import lsRemoteFixture from "./fixtures/ls-remote.json"; +import npmFixture from "./fixtures/npm-search.json"; + +const missingStderr = readFileSync(join(__dirname, "fixtures/ls-remote-missing-stderr.txt"), "utf8"); + +function deps(overrides: Partial = {}): BackendSearchDeps { + return { + fetch: vi.fn(async () => new Response("null")), + listRemote: vi.fn(async () => [] as RemoteVersion[]), + ...overrides, + }; +} + +function fetchReturning(body: unknown, init?: ResponseInit): BackendSearchDeps["fetch"] { + return vi.fn(async () => new Response(JSON.stringify(body), init)); +} + +describe("parseBackendQuery", () => { + it("splits a known backend prefix from the query", () => { + expect(parseBackendQuery("npm:prettier")).toEqual({ backend: "npm", query: "prettier" }); + expect(parseBackendQuery(" cargo: ripgrep ")).toEqual({ backend: "cargo", query: "ripgrep" }); + }); + + it("keeps the slash in owner/repo names", () => { + expect(parseBackendQuery("github:cli/cli")).toEqual({ backend: "github", query: "cli/cli" }); + }); + + it("returns undefined for an unknown prefix, no prefix, or an empty query", () => { + expect(parseBackendQuery("brew:jq")).toBeUndefined(); + expect(parseBackendQuery("prettier")).toBeUndefined(); + expect(parseBackendQuery("npm:")).toBeUndefined(); + expect(parseBackendQuery("npm: ")).toBeUndefined(); + expect(parseBackendQuery("")).toBeUndefined(); + }); +}); + +describe("searchBackend", () => { + it("searches the npm registry and maps packages to specs", async () => { + const fetch = fetchReturning(npmFixture); + const results = await searchBackend("npm", "prettier", deps({ fetch })); + expect(fetch).toHaveBeenCalledWith("https://registry.npmjs.org/-/v1/search?text=prettier&size=20", { + headers: undefined, + }); + expect(results.map((r) => r.spec)).toEqual([ + "npm:prettier", + "npm:eslint-plugin-prettier", + "npm:eslint-config-prettier", + ]); + expect(results[0]).toEqual({ + backend: "npm", + spec: "npm:prettier", + name: "prettier", + description: "Prettier is an opinionated code formatter", + version: "3.9.7", + url: "https://www.npmjs.com/package/prettier", + }); + }); + + it("searches crates.io with a User-Agent and collapses multi-line descriptions", async () => { + const fetch = fetchReturning(cargoFixture); + const results = await searchBackend("cargo", "ripgrep", deps({ fetch })); + expect(fetch).toHaveBeenCalledWith("https://crates.io/api/v1/crates?q=ripgrep&per_page=20", { + headers: { "User-Agent": "raycast-mise" }, + }); + expect(results[0]).toEqual({ + backend: "cargo", + spec: "cargo:ripgrep", + name: "ripgrep", + description: + "ripgrep is a line-oriented search tool that recursively searches the current directory for a regex pattern while respecting gitignore rules. ripgrep has first class support on Windows, macOS and Linux.", + version: "15.2.0", + url: "https://crates.io/crates/ripgrep", + }); + }); + + it("searches rubygems and collapses the info text", async () => { + const fetch = fetchReturning(gemFixture); + const results = await searchBackend("gem", "rubocop", deps({ fetch })); + expect(fetch).toHaveBeenCalledWith("https://rubygems.org/api/v1/search.json?query=rubocop", { headers: undefined }); + expect(results.map((r) => r.spec)).toEqual(["gem:rubocop", "gem:rubocop-ast", "gem:rubocop-performance"]); + expect(results[0]).toEqual({ + backend: "gem", + spec: "gem:rubocop", + name: "rubocop", + description: + "RuboCop is a Ruby code style checking and code formatting tool. It aims to enforce the community-driven Ruby Style Guide.", + version: "1.91.0", + url: "https://rubygems.org/gems/rubocop", + }); + expect(results[1].description).toBe("RuboCop's Node and NodePattern classes."); + }); + + it("URL-encodes the query", async () => { + const fetch = fetchReturning({ objects: [] }); + await searchBackend("npm", "@scope/pkg", deps({ fetch })); + expect(fetch).toHaveBeenCalledWith("https://registry.npmjs.org/-/v1/search?text=%40scope%2Fpkg&size=20", { + headers: undefined, + }); + }); + + it("throws MiseOutputError when a search API changes shape", async () => { + await expect(searchBackend("npm", "prettier", deps({ fetch: fetchReturning({ results: [] }) }))).rejects.toThrow( + MiseOutputError, + ); + await expect(searchBackend("cargo", "ripgrep", deps({ fetch: fetchReturning({ crates: [{}] }) }))).rejects.toThrow( + /entry 0 is not a crate/, + ); + await expect(searchBackend("gem", "rubocop", deps({ fetch: fetchReturning({}) }))).rejects.toThrow(MiseOutputError); + }); + + it("throws with the host and status on a non-2xx response", async () => { + const fetch = fetchReturning({}, { status: 503 }); + await expect(searchBackend("cargo", "ripgrep", deps({ fetch }))).rejects.toThrow( + /crates\.io responded with HTTP 503/, + ); + }); + + it("validates the exact spec through ls-remote for other backends and reports the newest version", async () => { + const listRemote = vi.fn(async () => lsRemoteFixture); + const results = await searchBackend("pipx", "black", deps({ listRemote })); + expect(listRemote).toHaveBeenCalledWith("pipx:black", { signal: undefined }); + expect(results).toEqual([ + { + backend: "pipx", + spec: "pipx:black", + name: "black", + description: "", + version: "1.8.2", + url: "https://pypi.org/project/black", + }, + ]); + }); + + it("links github specs to the repository and leaves other backends without a page", async () => { + const listRemote = vi.fn(async () => [{ version: "2.101.0" }]); + expect((await searchBackend("github", "cli/cli", deps({ listRemote })))[0].url).toBe("https://github.com/cli/cli"); + expect((await searchBackend("go", "github.com/x/y", deps({ listRemote })))[0].url).toBeUndefined(); + }); + + it("returns no results when mise reports the package missing or lists no versions", async () => { + const missing = vi.fn(async () => { + throw new MiseExitError(["ls-remote", "pipx:this-does-not-exist-zz"], 1, missingStderr); + }); + expect(await searchBackend("pipx", "this-does-not-exist-zz", deps({ listRemote: missing }))).toEqual([]); + const notFound = vi.fn(async () => { + throw new MiseExitError(["ls-remote", "npm:nope"], 1, "mise ERROR package not found: nope\n"); + }); + expect(await searchBackend("asdf", "nope", deps({ listRemote: notFound }))).toEqual([]); + expect(await searchBackend("go", "example.invalid/nope", deps())).toEqual([]); + }); + + it("hands the abort signal to fetch and to listRemote", async () => { + const { signal } = new AbortController(); + const fetch = fetchReturning({ objects: [] }); + await searchBackend("npm", "prettier", deps({ fetch, signal })); + expect(fetch).toHaveBeenCalledWith(expect.any(String), { headers: undefined, signal }); + const listRemote = vi.fn(async () => [] as RemoteVersion[]); + await searchBackend("pipx", "black", deps({ listRemote, signal })); + expect(listRemote).toHaveBeenCalledWith("pipx:black", { signal }); + }); + + it("rethrows other ls-remote failures", async () => { + const rateLimited = vi.fn(async () => { + throw new MiseExitError( + ["ls-remote", "github:cli/cli"], + 1, + "mise ERROR HTTP status client error (403 Forbidden)", + ); + }); + await expect(searchBackend("github", "cli/cli", deps({ listRemote: rateLimited }))).rejects.toThrow(MiseExitError); + const offline = vi.fn(async () => { + throw new Error("spawn ENOENT"); + }); + await expect(searchBackend("ubi", "x/y", deps({ listRemote: offline }))).rejects.toThrow("spawn ENOENT"); + }); +}); diff --git a/extensions/mise/src/mise/backends.ts b/extensions/mise/src/mise/backends.ts new file mode 100644 index 00000000000..edbb3995544 --- /dev/null +++ b/extensions/mise/src/mise/backends.ts @@ -0,0 +1,156 @@ +import { isRecord, MiseExitError, MiseOutputError } from "./exec"; +import type { RemoteVersion } from "./remote"; + +export const BACKENDS = [ + "aqua", + "asdf", + "cargo", + "conda", + "core", + "dotnet", + "forgejo", + "gem", + "github", + "gitlab", + "go", + "npm", + "packslip", + "pipx", + "pkgx", + "spm", + "http", + "s3", + "ubi", + "vfox", +] as const; + +export type Backend = (typeof BACKENDS)[number]; +export type BackendQuery = { backend: Backend; query: string }; + +export type BackendResult = { + backend: Backend; + spec: string; + name: string; + description: string; + version?: string; + url?: string; +}; + +export type BackendSearchDeps = { + fetch: typeof fetch; + listRemote: (spec: string, options: { signal?: AbortSignal }) => Promise; + signal?: AbortSignal; +}; + +const QUERY = new RegExp(`^(${BACKENDS.join("|")}):(.*)$`); +const MISSING_PACKAGE = /package not found|404 Not Found|no aqua-registry found/; + +export function parseBackendQuery(text: string): BackendQuery | undefined { + const match = QUERY.exec(text.trim()); + if (!match) return undefined; + const query = match[2].trim(); + if (!query) return undefined; + return { backend: match[1] as Backend, query }; +} + +export async function searchBackend( + backend: Backend, + query: string, + deps: BackendSearchDeps, +): Promise { + switch (backend) { + case "npm": + return searchNpm(query, deps); + case "cargo": + return searchCargo(query, deps); + case "gem": + return searchGem(query, deps); + default: + return validateSpec(backend, query, deps); + } +} + +async function searchNpm(query: string, deps: BackendSearchDeps): Promise { + const raw = await fetchJson(deps, `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=20`); + if (!isRecord(raw) || !Array.isArray(raw.objects)) { + throw new MiseOutputError("npm search: expected an objects array", JSON.stringify(raw)); + } + return raw.objects.map((entry, index) => { + const pkg = isRecord(entry) ? entry.package : undefined; + if (!isRecord(pkg) || typeof pkg.name !== "string") { + throw new MiseOutputError(`npm search: entry ${index} is not a package`, JSON.stringify(entry)); + } + return result("npm", pkg.name, pkg.description, pkg.version, `https://www.npmjs.com/package/${pkg.name}`); + }); +} + +async function searchCargo(query: string, deps: BackendSearchDeps): Promise { + const raw = await fetchJson(deps, `https://crates.io/api/v1/crates?q=${encodeURIComponent(query)}&per_page=20`, { + "User-Agent": "raycast-mise", + }); + if (!isRecord(raw) || !Array.isArray(raw.crates)) { + throw new MiseOutputError("cargo search: expected a crates array", JSON.stringify(raw)); + } + return raw.crates.map((entry, index) => { + if (!isRecord(entry) || typeof entry.name !== "string") { + throw new MiseOutputError(`cargo search: entry ${index} is not a crate`, JSON.stringify(entry)); + } + return result("cargo", entry.name, entry.description, entry.max_version, `https://crates.io/crates/${entry.name}`); + }); +} + +async function searchGem(query: string, deps: BackendSearchDeps): Promise { + const raw = await fetchJson(deps, `https://rubygems.org/api/v1/search.json?query=${encodeURIComponent(query)}`); + if (!Array.isArray(raw)) throw new MiseOutputError("gem search: expected an array", JSON.stringify(raw)); + return raw.map((entry, index) => { + if (!isRecord(entry) || typeof entry.name !== "string") { + throw new MiseOutputError(`gem search: entry ${index} is not a gem`, JSON.stringify(entry)); + } + return result("gem", entry.name, entry.info, entry.version, `https://rubygems.org/gems/${entry.name}`); + }); +} + +async function validateSpec(backend: Backend, name: string, deps: BackendSearchDeps): Promise { + let versions: RemoteVersion[]; + try { + versions = await deps.listRemote(`${backend}:${name}`, { signal: deps.signal }); + } catch (error) { + if (error instanceof MiseExitError && MISSING_PACKAGE.test(error.stderr)) return []; + throw error; + } + if (versions.length === 0) return []; + return [result(backend, name, undefined, versions[versions.length - 1].version, packagePage(backend, name))]; +} + +function packagePage(backend: Backend, name: string): string | undefined { + switch (backend) { + case "github": + return `https://github.com/${name}`; + case "pipx": + return `https://pypi.org/project/${name}`; + default: + return undefined; + } +} + +async function fetchJson( + { fetch, signal }: BackendSearchDeps, + url: string, + headers?: Record, +): Promise { + const response = await fetch(url, { headers, signal }); + if (!response.ok) throw new Error(`${new URL(url).host} responded with HTTP ${response.status}`); + return response.json(); +} + +function result(backend: Backend, name: string, description: unknown, version: unknown, url?: string): BackendResult { + const entry: BackendResult = { + backend, + spec: `${backend}:${name}`, + name, + description: typeof description === "string" ? description.replace(/\s+/g, " ").trim() : "", + }; + if (typeof version === "string") entry.version = version; + if (url) entry.url = url; + return entry; +} diff --git a/extensions/mise/src/mise/config.test.ts b/extensions/mise/src/mise/config.test.ts new file mode 100644 index 00000000000..c80c8201ebe --- /dev/null +++ b/extensions/mise/src/mise/config.test.ts @@ -0,0 +1,43 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { configFileLabel, parseConfigFiles } from "./config"; +import { MiseOutputError } from "./exec"; +import fixture from "./fixtures/config-ls.json"; + +describe("parseConfigFiles", () => { + const files = parseConfigFiles(fixture); + + it("keeps every global config file in mise's order", () => { + expect(files.map((f) => f.path)).toEqual([ + "/Users/lachlan/.config/mise/config.local.toml", + "/Users/lachlan/.config/mise/conf.d/tools.toml", + "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "/Users/lachlan/.config/mise/conf.d/settings.toml", + "/Users/lachlan/.config/mise/conf.d/dotfiles.toml", + ]); + }); + + it("maps the tools each file declares, empty for files without a tools table", () => { + expect(files[1].tools).toContain("node"); + expect(files[2].tools).toEqual([]); + }); + + it("throws MiseOutputError on a shape mismatch", () => { + expect(() => parseConfigFiles({})).toThrow(MiseOutputError); + expect(() => parseConfigFiles([{ path: "/x.toml" }])).toThrow(/entry is malformed/); + expect(() => parseConfigFiles([{ path: "/x.toml", tools: [1] }])).toThrow(MiseOutputError); + }); +}); + +describe("configFileLabel", () => { + it("shows the path relative to the global config directory", () => { + expect(configFileLabel(join(homedir(), ".config/mise/config.local.toml"))).toBe("config.local.toml"); + expect(configFileLabel(join(homedir(), ".config/mise/conf.d/tools.toml"))).toBe("conf.d/tools.toml"); + }); + + it("falls back to the file name outside that directory", () => { + expect(configFileLabel(join(homedir(), ".tool-versions"))).toBe(".tool-versions"); + expect(configFileLabel("/etc/mise/config.toml")).toBe("config.toml"); + }); +}); diff --git a/extensions/mise/src/mise/config.ts b/extensions/mise/src/mise/config.ts new file mode 100644 index 00000000000..16d9650b919 --- /dev/null +++ b/extensions/mise/src/mise/config.ts @@ -0,0 +1,25 @@ +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { isRecord, isStringArray, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type ConfigFile = { path: string; tools: string[] }; + +export function parseConfigFiles(raw: unknown): ConfigFile[] { + if (!Array.isArray(raw)) throw new MiseOutputError("mise config ls: expected an array", JSON.stringify(raw)); + return raw.map((entry) => { + if (!isRecord(entry) || typeof entry.path !== "string" || !isStringArray(entry.tools)) { + throw new MiseOutputError("mise config ls: entry is malformed", JSON.stringify(entry)); + } + return { path: entry.path, tools: entry.tools }; + }); +} + +export function listConfigFiles(location: MiseLocation): Promise { + return miseJson(location, ["config", "ls"], parseConfigFiles); +} + +export function configFileLabel(path: string): string { + const globalDir = join(homedir(), ".config", "mise") + "/"; + return path.startsWith(globalDir) ? path.slice(globalDir.length) : basename(path); +} diff --git a/extensions/mise/src/mise/exec.test.ts b/extensions/mise/src/mise/exec.test.ts new file mode 100644 index 00000000000..da84c7bcd04 --- /dev/null +++ b/extensions/mise/src/mise/exec.test.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { miseJson, observeCommands, runMise, type CommandEvent } from "./exec"; +import type { MiseLocation } from "./locate"; + +const env = { PATH: "/usr/bin:/bin", MISE_YES: "1", NO_COLOR: "1" } as const; + +describe("mise processes run from the home directory", () => { + it("runMise", async () => { + const result = await runMise({ path: "/bin/pwd", env }, []); + expect(result.stdout.trim()).toBe(homedir()); + }); + + it("miseJson", async () => { + const location: MiseLocation = { path: "/bin/sh", env }; + const cwd = await miseJson(location, ["-c", 'printf \'"%s"\' "$PWD"', "--"], (raw) => raw as string); + expect(cwd).toBe(homedir()); + }); +}); + +describe("observeCommands", () => { + afterEach(() => observeCommands(undefined)); + + it("reports the full command line, exit code and duration of every run", async () => { + const events: CommandEvent[] = []; + observeCommands((event) => events.push(event)); + + await runMise({ path: "/bin/sh", env }, ["-c", "exit 3"]); + await miseJson({ path: "/bin/sh", env }, ["-c", "echo 1", "--"], (raw) => raw); + await expect(miseJson({ path: "/bin/sh", env }, ["-c", "exit 2", "--"], (raw) => raw)).rejects.toThrow(); + + expect(events).toEqual([ + { args: ["-c", "exit 3"], code: 3, ms: expect.any(Number) }, + { args: ["-c", "echo 1", "--", "--json"], code: 0, ms: expect.any(Number) }, + { args: ["-c", "exit 2", "--", "--json"], code: 2, ms: expect.any(Number) }, + ]); + }); + + it("is silent again once the observer is cleared", async () => { + const observer = vi.fn(); + observeCommands(observer); + observeCommands(undefined); + await runMise({ path: "/bin/pwd", env }, []); + expect(observer).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/mise/src/mise/exec.ts b/extensions/mise/src/mise/exec.ts new file mode 100644 index 00000000000..329a4e7579d --- /dev/null +++ b/extensions/mise/src/mise/exec.ts @@ -0,0 +1,129 @@ +import { execFile, spawn } from "node:child_process"; +import { homedir } from "node:os"; +import type { MiseLocation } from "./locate"; + +export class MiseOutputError extends Error { + constructor( + message: string, + readonly raw: string, + ) { + super(message); + this.name = "MiseOutputError"; + } +} + +export class MiseExitError extends Error { + constructor( + readonly args: string[], + readonly code: number | null, + readonly stderr: string, + ) { + super(`mise ${args.join(" ")} exited with ${code ?? "signal"}: ${stderr.trim()}`); + this.name = "MiseExitError"; + } +} + +export type RunResult = { code: number | null; stdout: string; stderr: string }; +export type RunOptions = { onLine?: (line: string) => void; signal?: AbortSignal }; +export type CommandEvent = { args: string[]; code: number | null; ms: number }; + +let observer: ((event: CommandEvent) => void) | undefined; + +export function observeCommands(next: ((event: CommandEvent) => void) | undefined): void { + observer = next; +} + +function envFor(location: MiseLocation): NodeJS.ProcessEnv { + return { ...process.env, ...location.env }; +} + +function report(args: string[], code: number | null, startedAt: number): void { + observer?.({ args, code, ms: Date.now() - startedAt }); +} + +export function miseJson( + location: MiseLocation, + args: string[], + parse: (raw: unknown) => T, + options: { signal?: AbortSignal } = {}, +): Promise { + const fullArgs = [...args, "--json"]; + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + execFile( + location.path, + fullArgs, + { + cwd: homedir(), + env: envFor(location), + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + signal: options.signal, + }, + (error, stdout, stderr) => { + const code = error ? (typeof error.code === "number" ? error.code : null) : 0; + report(fullArgs, code, startedAt); + if (error) { + reject(error.name === "AbortError" ? error : new MiseExitError(args, code, stderr || error.message)); + return; + } + let json: unknown; + try { + json = JSON.parse(stdout); + } catch { + reject(new MiseOutputError(`mise ${args.join(" ")} did not return JSON`, stdout)); + return; + } + try { + resolve(parse(json)); + } catch (parseError) { + reject(parseError); + } + }, + ); + }); +} + +export function runMise(location: MiseLocation, args: string[], options: RunOptions = {}): Promise { + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + const child = spawn(location.path, args, { + cwd: homedir(), + env: envFor(location), + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let pending = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) options.onLine?.(line); + }); + const onAbort = () => child.kill("SIGTERM"); + options.signal?.addEventListener("abort", onAbort, { once: true }); + child.on("error", (error) => { + options.signal?.removeEventListener("abort", onAbort); + reject(error); + }); + child.on("close", (code) => { + options.signal?.removeEventListener("abort", onAbort); + if (pending) options.onLine?.(pending); + report(args, code, startedAt); + resolve({ code, stdout, stderr }); + }); + }); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} diff --git a/extensions/mise/src/mise/fixtures/cargo-search.json b/extensions/mise/src/mise/fixtures/cargo-search.json new file mode 100644 index 00000000000..5a8c6f4df9d --- /dev/null +++ b/extensions/mise/src/mise/fixtures/cargo-search.json @@ -0,0 +1,37 @@ +{ + "crates": [ + { + "id": "ripgrep", + "name": "ripgrep", + "max_version": "15.2.0", + "newest_version": "15.2.0", + "description": "ripgrep is a line-oriented search tool that recursively searches the current\ndirectory for a regex pattern while respecting gitignore rules. ripgrep has\nfirst class support on Windows, macOS and Linux.\n", + "homepage": "https://github.com/BurntSushi/ripgrep", + "repository": "https://github.com/BurntSushi/ripgrep", + "exact_match": true + }, + { + "id": "gist-search", + "name": "gist-search", + "max_version": "1.2.6", + "newest_version": "1.2.6", + "description": "Indexed code search for Rust - ripgrep-parity regex search over a persisted trigram index, with ranked definition-first results.", + "homepage": "https://github.com/The-Billy-Company/gist", + "repository": "https://github.com/The-Billy-Company/gist", + "exact_match": false + }, + { + "id": "cgx-core", + "name": "cgx-core", + "max_version": "0.1.0", + "newest_version": "0.1.0", + "description": "Core library for cgx, the Rust equivalent of uvx or npx for running Rust crates quickly and easily", + "homepage": "https://github.com/anelson/cgx", + "repository": "https://github.com/anelson/cgx", + "exact_match": false + } + ], + "meta": { + "total": 671 + } +} diff --git a/extensions/mise/src/mise/fixtures/config-ls.json b/extensions/mise/src/mise/fixtures/config-ls.json new file mode 100644 index 00000000000..3e76a9f4ac3 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/config-ls.json @@ -0,0 +1,51 @@ +[ + { + "path": "/Users/lachlan/.config/mise/config.local.toml", + "tools": [ + "aube", + "bun", + "fnox", + "gh", + "herdr", + "npm:cf", + "npm:typescript-language-server", + "npm:wrangler", + "op", + "opencode", + "npm:@opencode-ai/cli", + "pipx:claude-swap", + "shellcheck", + "supabase", + "usage", + "pnpm" + ] + }, + { + "path": "/Users/lachlan/.config/mise/conf.d/tools.toml", + "tools": [ + "node", + "go", + "ubi", + "rust", + "python", + "fnox", + "neovim", + "tree-sitter", + "lua-language-server", + "stylua", + "http:pool" + ] + }, + { + "path": "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "tools": [] + }, + { + "path": "/Users/lachlan/.config/mise/conf.d/settings.toml", + "tools": [] + }, + { + "path": "/Users/lachlan/.config/mise/conf.d/dotfiles.toml", + "tools": [] + } +] diff --git a/extensions/mise/src/mise/fixtures/gem-search.json b/extensions/mise/src/mise/fixtures/gem-search.json new file mode 100644 index 00000000000..04132865b89 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/gem-search.json @@ -0,0 +1,26 @@ +[ + { + "name": "rubocop", + "version": "1.91.0", + "info": "RuboCop is a Ruby code style checking and code formatting tool.\nIt aims to enforce the community-driven Ruby Style Guide.\n", + "project_uri": "https://rubygems.org/gems/rubocop", + "homepage_uri": "https://rubocop.org/", + "downloads": 745668796 + }, + { + "name": "rubocop-ast", + "version": "1.50.0", + "info": " RuboCop's Node and NodePattern classes.\n", + "project_uri": "https://rubygems.org/gems/rubocop-ast", + "homepage_uri": "https://www.rubocop.org/", + "downloads": 562683343 + }, + { + "name": "rubocop-performance", + "version": "1.27.0", + "info": "A collection of RuboCop cops to check for performance optimizations\nin Ruby code.\n", + "project_uri": "https://rubygems.org/gems/rubocop-performance", + "homepage_uri": "https://docs.rubocop.org/rubocop-performance/", + "downloads": 327889516 + } +] diff --git a/extensions/mise/src/mise/fixtures/ls-remote-missing-stderr.txt b/extensions/mise/src/mise/fixtures/ls-remote-missing-stderr.txt new file mode 100644 index 00000000000..f635013115e --- /dev/null +++ b/extensions/mise/src/mise/fixtures/ls-remote-missing-stderr.txt @@ -0,0 +1,3 @@ +mise ERROR HTTP status client error (404 Not Found) for url (https://pypi.org/pypi/this-does-not-exist-zz/json) +mise ERROR Version: 2026.9.5 macos-arm64 (2026-09-10) +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information diff --git a/extensions/mise/src/mise/fixtures/ls-remote.json b/extensions/mise/src/mise/fixtures/ls-remote.json new file mode 100644 index 00000000000..3f5656fc9b9 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/ls-remote.json @@ -0,0 +1,47 @@ +[ + { + "version": "1.3", + "created_at": "2015-08-08T05:03:24.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.3" + }, + { + "version": "1.4", + "created_at": "2015-08-08T05:00:03.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.4" + }, + { + "version": "1.5", + "created_at": "2015-08-16T06:39:17.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.5" + }, + { + "version": "1.6", + "created_at": "2018-11-02T01:54:23.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.6" + }, + { + "version": "1.7", + "created_at": "2023-09-06T22:54:12.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.7" + }, + { + "version": "1.7.1", + "created_at": "2023-12-13T19:56:17.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.7.1" + }, + { + "version": "1.8.0", + "created_at": "2025-06-01T06:05:01.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.8.0" + }, + { + "version": "1.8.1", + "created_at": "2025-07-01T11:50:06.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.8.1" + }, + { + "version": "1.8.2", + "created_at": "2026-06-20T14:11:27.0Z", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.8.2" + } +] diff --git a/extensions/mise/src/mise/fixtures/ls.json b/extensions/mise/src/mise/fixtures/ls.json new file mode 100644 index 00000000000..232dcc7a9aa --- /dev/null +++ b/extensions/mise/src/mise/fixtures/ls.json @@ -0,0 +1,125 @@ +{ + "node": [ + { + "version": "22.21.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/22.21.0", + "installed": true, + "active": false + }, + { + "version": "22.21.1", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/22.21.1", + "installed": true, + "active": false + }, + { + "version": "22.22.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/22.22.0", + "installed": true, + "active": false + }, + { + "version": "22.22.1", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/22.22.1", + "installed": true, + "active": false + }, + { + "version": "24.3.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/24.3.0", + "installed": true, + "active": false + }, + { + "version": "24.13.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/24.13.0", + "installed": true, + "active": false + }, + { + "version": "24.13.1", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/24.13.1", + "installed": true, + "active": false + }, + { + "version": "24.21.0", + "requested_version": "lts", + "install_path": "/Users/lachlan/.local/share/mise/installs/node/24.21.0", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/conf.d/tools.toml" + }, + "installed": true, + "active": true + } + ], + "jq": [ + { + "version": "1.8.1", + "install_path": "/Users/lachlan/.local/share/mise/installs/jq/1.8.1", + "installed": true, + "active": false + }, + { + "version": "1.8.2", + "install_path": "/Users/lachlan/.local/share/mise/installs/jq/1.8.2", + "installed": true, + "active": false + } + ], + "npm:wrangler": [ + { + "version": "4.93.1", + "install_path": "/Users/lachlan/.local/share/mise/installs/npm-wrangler/4.93.1", + "installed": true, + "active": false + }, + { + "version": "4.131.1", + "requested_version": "latest", + "install_path": "/Users/lachlan/.local/share/mise/installs/npm-wrangler/4.131.1", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/config.local.toml" + }, + "installed": true, + "active": true + } + ], + "rust": [ + { + "version": "nightly-2026-01-16", + "install_path": "/Users/lachlan/.local/share/mise/installs/rust/nightly-2026-01-16", + "symlinked_to": "/Users/lachlan/.local/share/mise/installs/rust/nightly-2026-01-16", + "installed": true, + "active": false + }, + { + "version": "1.95.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/rust/1.95.0", + "symlinked_to": "/Users/lachlan/.local/share/mise/installs/rust/1.95.0", + "installed": true, + "active": false + }, + { + "version": "1.96.0", + "install_path": "/Users/lachlan/.local/share/mise/installs/rust/1.96.0", + "symlinked_to": "/Users/lachlan/.local/share/mise/installs/rust/1.96.0", + "installed": true, + "active": false + }, + { + "version": "1.98.1", + "requested_version": "latest", + "install_path": "/Users/lachlan/.local/share/mise/installs/rust/1.98.1", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/conf.d/tools.toml" + }, + "symlinked_to": "/Users/lachlan/.local/share/mise/installs/rust/1.98.1", + "installed": true, + "active": true + } + ] +} diff --git a/extensions/mise/src/mise/fixtures/npm-search.json b/extensions/mise/src/mise/fixtures/npm-search.json new file mode 100644 index 00000000000..7b5367d5c09 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/npm-search.json @@ -0,0 +1,48 @@ +{ + "objects": [ + { + "package": { + "name": "prettier", + "version": "3.9.7", + "description": "Prettier is an opinionated code formatter", + "links": { + "homepage": "https://prettier.io", + "repository": "git+https://github.com/prettier/prettier.git", + "bugs": "https://github.com/prettier/prettier/issues", + "npm": "https://www.npmjs.com/package/prettier" + } + }, + "score": 2734.7107 + }, + { + "package": { + "name": "eslint-plugin-prettier", + "version": "5.5.6", + "description": "Runs prettier as an eslint rule", + "links": { + "homepage": "https://github.com/prettier/eslint-plugin-prettier#readme", + "repository": "git+https://github.com/prettier/eslint-plugin-prettier.git", + "bugs": "https://github.com/prettier/eslint-plugin-prettier/issues", + "npm": "https://www.npmjs.com/package/eslint-plugin-prettier" + } + }, + "score": 477.47827 + }, + { + "package": { + "name": "eslint-config-prettier", + "version": "10.1.8", + "description": "Turns off all rules that are unnecessary or might conflict with Prettier.", + "links": { + "homepage": "https://github.com/prettier/eslint-config-prettier#readme", + "repository": "git+https://github.com/prettier/eslint-config-prettier.git", + "bugs": "https://github.com/prettier/eslint-config-prettier/issues", + "npm": "https://www.npmjs.com/package/eslint-config-prettier" + } + }, + "score": 424.29413 + } + ], + "total": 44337, + "time": "2026-09-16T17:29:22.962Z" +} diff --git a/extensions/mise/src/mise/fixtures/outdated-inactive.json b/extensions/mise/src/mise/fixtures/outdated-inactive.json new file mode 100644 index 00000000000..8d849230c63 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/outdated-inactive.json @@ -0,0 +1,24 @@ +{ + "gh": { + "name": "gh", + "requested": "2.99.0", + "current": "2.99.0", + "bump": "2.101.0", + "latest": "2.101.0", + "release_url": "https://github.com/cli/cli/releases/tag/v2.101.0", + "source": { + "type": "unknown" + } + }, + "jq": { + "name": "jq", + "requested": "1.8.1", + "current": "1.8.1", + "bump": "1.8.2", + "latest": "1.8.2", + "release_url": "https://github.com/jqlang/jq/releases/tag/jq-1.8.2", + "source": { + "type": "unknown" + } + } +} diff --git a/extensions/mise/src/mise/fixtures/outdated.json b/extensions/mise/src/mise/fixtures/outdated.json new file mode 100644 index 00000000000..18e79745ca9 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/outdated.json @@ -0,0 +1,49 @@ +{ + "gh": { + "name": "gh", + "requested": "latest", + "current": "2.100.0", + "bump": null, + "latest": "2.101.0", + "release_url": "https://github.com/cli/cli/releases/tag/v2.101.0", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/config.local.toml" + } + }, + "npm:wrangler": { + "name": "npm:wrangler", + "requested": "latest", + "current": "4.131.1", + "bump": null, + "latest": "4.131.2", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/config.local.toml" + } + }, + "opencode": { + "name": "opencode", + "requested": "latest", + "current": "1.18.30", + "bump": null, + "latest": "1.18.31", + "release_url": "https://github.com/anomalyco/opencode/releases/tag/v1.18.31", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/config.local.toml" + } + }, + "usage": { + "name": "usage", + "requested": "latest", + "current": "6.9.0", + "bump": null, + "latest": "6.9.1", + "release_url": "https://github.com/jdx/usage/releases/tag/v6.9.1", + "source": { + "type": "mise.toml", + "path": "/Users/lachlan/.config/mise/config.local.toml" + } + } +} diff --git a/extensions/mise/src/mise/fixtures/registry.json b/extensions/mise/src/mise/fixtures/registry.json new file mode 100644 index 00000000000..aa77f4ff4b1 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/registry.json @@ -0,0 +1,33 @@ +[ + { + "short": "jq", + "backends": ["aqua:jqlang/jq", "asdf:mise-plugins/asdf-jq"], + "bins": ["jq"], + "description": "Command-line JSON processor" + }, + { + "short": "1password", + "backends": ["vfox:mise-plugins/vfox-1password", "aqua:1password/cli"], + "bins": ["op"], + "description": "Password manager developed by AgileBits Inc", + "aliases": ["1password-cli", "op"] + }, + { + "short": "node", + "backends": ["core:node"], + "bins": ["node", "npm", "npx"], + "description": "Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts (Builtin plugin)" + }, + { + "short": "ripgrep", + "backends": ["aqua:BurntSushi/ripgrep", "asdf:https://gitlab.com/wt0f/asdf-ripgrep", "cargo:ripgrep"], + "bins": ["rg"], + "description": "ripgrep recursively searches directories for a regex pattern while respecting your gitignore", + "aliases": ["rg"] + }, + { + "short": "ag", + "backends": ["vfox:mise-plugins/vfox-ag", "asdf:mise-plugins/mise-ag"], + "description": "The Silver Searcher: A code searching tool similar to ack, with a focus on speed" + } +] diff --git a/extensions/mise/src/mise/fixtures/tasks-ls.json b/extensions/mise/src/mise/fixtures/tasks-ls.json new file mode 100644 index 00000000000..5b8712b9812 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/tasks-ls.json @@ -0,0 +1,110 @@ +[ + { + "name": "dotfiles:apply", + "aliases": [], + "description": "Apply chezmoi files, then mise-managed dotfiles", + "source": "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "config_sources": ["/Users/lachlan/.config/mise/conf.d/tasks.toml"], + "depends": [], + "depends_post": [], + "wait_for": [], + "env": [], + "dir": "/Users/lachlan", + "hide": false, + "global": true, + "raw": false, + "interactive": false, + "sources": [], + "outputs": [], + "shell": null, + "quiet": false, + "silent": false, + "tools": {}, + "usage": "", + "timeout": null, + "run": ["chezmoi apply", "mise bootstrap dotfiles apply --yes"], + "args": [], + "file": null + }, + { + "name": "dotfiles:status", + "aliases": [], + "description": "Show chezmoi and mise-managed dotfile drift", + "source": "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "config_sources": ["/Users/lachlan/.config/mise/conf.d/tasks.toml"], + "depends": [], + "depends_post": [], + "wait_for": [], + "env": [], + "dir": "/Users/lachlan", + "hide": false, + "global": true, + "raw": false, + "interactive": false, + "sources": [], + "outputs": [], + "shell": null, + "quiet": false, + "silent": false, + "tools": {}, + "usage": "", + "timeout": null, + "run": ["chezmoi status", "mise bootstrap dotfiles status"], + "args": [], + "file": null + }, + { + "name": "nvim:check", + "aliases": [], + "description": "Verify that the Neovim configuration starts cleanly", + "source": "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "config_sources": ["/Users/lachlan/.config/mise/conf.d/tasks.toml"], + "depends": [], + "depends_post": [], + "wait_for": [], + "env": [], + "dir": "/Users/lachlan", + "hide": false, + "global": true, + "raw": false, + "interactive": false, + "sources": [], + "outputs": [], + "shell": null, + "quiet": false, + "silent": false, + "tools": {}, + "usage": "", + "timeout": null, + "run": ["nvim --headless '+lua vim.print(vim.version())' +qa"], + "args": [], + "file": null + }, + { + "name": "nvim:sync", + "aliases": [], + "description": "Install missing Neovim plugins and Treesitter parsers", + "source": "/Users/lachlan/.config/mise/conf.d/tasks.toml", + "config_sources": ["/Users/lachlan/.config/mise/conf.d/tasks.toml"], + "depends": [], + "depends_post": [], + "wait_for": [], + "env": [], + "dir": "/Users/lachlan", + "hide": false, + "global": true, + "raw": false, + "interactive": false, + "sources": [], + "outputs": [], + "shell": null, + "quiet": false, + "silent": false, + "tools": {}, + "usage": "", + "timeout": null, + "run": ["nvim --headless \"+lua require('config.treesitter').sync()\" +qa"], + "args": [], + "file": null + } +] diff --git a/extensions/mise/src/mise/fixtures/uninstall-stderr.txt b/extensions/mise/src/mise/fixtures/uninstall-stderr.txt new file mode 100644 index 00000000000..c914935adb3 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/uninstall-stderr.txt @@ -0,0 +1,3 @@ +mise by @jdx – removing 1 tool +mise ✓ jq@1.7.1 0ms +mise ████████████████ 1/1 · removed 1 tool in 0ms diff --git a/extensions/mise/src/mise/fixtures/use-low-downloads-stderr.txt b/extensions/mise/src/mise/fixtures/use-low-downloads-stderr.txt new file mode 100644 index 00000000000..22e8071c2e7 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/use-low-downloads-stderr.txt @@ -0,0 +1,7 @@ +mise by @jdx – installing 1 tool +mise ✗ npm:agent-peek@0.6.0 779ms · failed: aube install failed: refusing to add agent-peek: only 17 weekly downloads (threshold: 1000) +mise ████████████████ 1/1 · installed 0 tools · 1 failed in 781ms +mise ERROR Failed to install npm:agent-peek@latest: aube install failed: refusing to add agent-peek: only 17 weekly downloads (threshold: 1000) + help: after verifying the package, set `allow_low_downloads = true` on `npm:agent-peek` to approve it +mise ERROR Version: 2026.9.10 macos-arm64 (2026-09-16) +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information diff --git a/extensions/mise/src/mise/fixtures/use-stderr.txt b/extensions/mise/src/mise/fixtures/use-stderr.txt new file mode 100644 index 00000000000..be804d5fa65 --- /dev/null +++ b/extensions/mise/src/mise/fixtures/use-stderr.txt @@ -0,0 +1,3 @@ +mise by @jdx – installing 1 tool +mise ✓ jq@1.7.1 613ms jq-macos-arm64 +mise ████████████████ 1/1 · installed 1 tool in 616ms diff --git a/extensions/mise/src/mise/installed.test.ts b/extensions/mise/src/mise/installed.test.ts new file mode 100644 index 00000000000..826de96a871 --- /dev/null +++ b/extensions/mise/src/mise/installed.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { MiseOutputError } from "./exec"; +import { parseInstalled } from "./installed"; +import fixture from "./fixtures/ls.json"; + +describe("parseInstalled", () => { + const tools = parseInstalled(fixture); + + it("keeps the tool names as mise ls keys, backend prefixes included", () => { + expect(tools.map((t) => t.name)).toEqual(["node", "jq", "npm:wrangler", "rust"]); + }); + + it("maps versions with active marker, requested version and source", () => { + const node = tools.find((t) => t.name === "node"); + expect(node?.versions).toHaveLength(8); + expect(node?.versions.filter((v) => v.active)).toEqual([ + { + version: "24.21.0", + installPath: "/Users/lachlan/.local/share/mise/installs/node/24.21.0", + active: true, + requestedVersion: "lts", + source: { type: "mise.toml", path: "/Users/lachlan/.config/mise/conf.d/tools.toml" }, + }, + ]); + expect(node?.versions[0]).toEqual({ + version: "22.21.0", + installPath: "/Users/lachlan/.local/share/mise/installs/node/22.21.0", + active: false, + }); + }); + + it("drops versions that are requested but not installed, and tools left with none", () => { + const parsed = parseInstalled({ + jq: [ + { version: "1.7.1", install_path: "/x/jq/1.7.1", installed: false, active: true }, + { version: "1.8.2", install_path: "/x/jq/1.8.2", installed: true, active: false }, + ], + ghost: [{ version: "1.0.0", install_path: "/x/ghost/1.0.0", installed: false, active: false }], + }); + expect(parsed).toEqual([ + { name: "jq", versions: [{ version: "1.8.2", installPath: "/x/jq/1.8.2", active: false }] }, + ]); + }); + + it("throws MiseOutputError on a shape mismatch", () => { + expect(() => parseInstalled([])).toThrow(MiseOutputError); + expect(() => parseInstalled({ jq: "1.8.2" })).toThrow(/jq is not an array/); + expect(() => parseInstalled({ jq: [{ version: 1 }] })).toThrow(/malformed version/); + }); +}); diff --git a/extensions/mise/src/mise/installed.ts b/extensions/mise/src/mise/installed.ts new file mode 100644 index 00000000000..cdf3e87e8df --- /dev/null +++ b/extensions/mise/src/mise/installed.ts @@ -0,0 +1,44 @@ +import { isRecord, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type InstalledVersion = { + version: string; + installPath: string; + active: boolean; + requestedVersion?: string; + source?: { type: string; path: string }; +}; + +export type InstalledTool = { name: string; versions: InstalledVersion[] }; + +export function parseInstalled(raw: unknown): InstalledTool[] { + if (!isRecord(raw)) throw new MiseOutputError("mise ls: expected an object keyed by tool", JSON.stringify(raw)); + const tools: InstalledTool[] = []; + for (const [name, entries] of Object.entries(raw)) { + if (!Array.isArray(entries)) throw new MiseOutputError(`mise ls: ${name} is not an array`, JSON.stringify(entries)); + const versions = entries + .filter((entry) => !(isRecord(entry) && entry.installed === false)) + .map((entry) => { + if (!isRecord(entry) || typeof entry.version !== "string" || typeof entry.install_path !== "string") { + throw new MiseOutputError(`mise ls: ${name} has a malformed version`, JSON.stringify(entry)); + } + const version: InstalledVersion = { + version: entry.version, + installPath: entry.install_path, + active: entry.active === true, + }; + if (typeof entry.requested_version === "string") version.requestedVersion = entry.requested_version; + const source = entry.source; + if (isRecord(source) && typeof source.type === "string" && typeof source.path === "string") { + version.source = { type: source.type, path: source.path }; + } + return version; + }); + if (versions.length > 0) tools.push({ name, versions }); + } + return tools; +} + +export function listInstalled(location: MiseLocation): Promise { + return miseJson(location, ["ls"], parseInstalled); +} diff --git a/extensions/mise/src/mise/locate.test.ts b/extensions/mise/src/mise/locate.test.ts new file mode 100644 index 00000000000..ab4e24c48b0 --- /dev/null +++ b/extensions/mise/src/mise/locate.test.ts @@ -0,0 +1,230 @@ +import { homedir } from "node:os"; +import { describe, expect, it, vi } from "vitest"; +import { + BASE_PATH, + ENV_TTL_MS, + parseProbeOutput, + PROBE_BACKOFF_MS, + PROBE_LABEL, + resolveMise, + staticCandidates, + type MiseStorage, + type ProbeResult, +} from "./locate"; + +const LOCAL = `${homedir()}/.local/bin/mise`; +const HOMEBREW = "/opt/homebrew/bin/mise"; +const HOME_BIN = `${homedir()}/bin`; +const PROBED: ProbeResult = { + command: "mise", + installPath: "", + env: { HOME: homedir(), PATH: `${HOME_BIN}:/usr/bin:/bin`, CARGO_HOME: "/x/cargo" }, +}; +// The probed PATH with the base directories it lacks appended, then the fixed overlay. +const PROBED_ENV = { + HOME: homedir(), + CARGO_HOME: "/x/cargo", + PATH: `${HOME_BIN}:/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin`, + MISE_YES: "1", + NO_COLOR: "1", +}; +const BASE_ENV = { PATH: BASE_PATH, MISE_YES: "1", NO_COLOR: "1" }; + +function memoryStorage(initial: Record = {}): MiseStorage & { data: Record } { + const data = { ...initial }; + return { data, get: (key) => data[key], set: (key, value) => void (data[key] = value) }; +} + +function existsAt(...paths: string[]) { + return (path: string) => paths.includes(path); +} + +describe("resolveMise", () => { + it("checks the preference, then ~/.local/bin, /opt/homebrew/bin, /usr/local/bin in order", () => { + expect(staticCandidates()).toEqual([LOCAL, HOMEBREW, "/usr/local/bin/mise"]); + }); + + it("prefers the misePath preference over every candidate and runs it in the login shell's env", async () => { + const probe = vi.fn().mockResolvedValue({ ...PROBED, command: "/nix/bin/mise" }); + const result = await resolveMise({ + preferredPath: "/custom/mise", + storage: memoryStorage(), + exists: existsAt("/custom/mise", LOCAL, "/nix/bin/mise"), + probe, + }); + expect(result).toEqual({ path: "/custom/mise", env: PROBED_ENV }); + }); + + it("treats a blank preference as unset", async () => { + for (const preferredPath of ["", " "]) { + const probe = vi.fn().mockResolvedValue(PROBED); + const result = await resolveMise({ preferredPath, storage: memoryStorage(), exists: existsAt(LOCAL), probe }); + expect(result).toEqual({ path: LOCAL, env: PROBED_ENV }); + } + }); + + it("reports only the preference as searched when it is set but does not exist", async () => { + const probe = vi.fn(); + const result = await resolveMise({ + preferredPath: "/custom/mise", + storage: memoryStorage(), + exists: existsAt(HOMEBREW), + probe, + }); + expect(result).toEqual({ searched: ["/custom/mise"] }); + expect(probe).not.toHaveBeenCalled(); + }); + + it("probes the login shell once for a static candidate and reuses the cached env", async () => { + const storage = memoryStorage(); + const probe = vi.fn().mockResolvedValue(PROBED); + const first = await resolveMise({ storage, exists: existsAt(LOCAL), probe }); + expect(first).toEqual({ path: LOCAL, env: PROBED_ENV }); + const second = await resolveMise({ storage, exists: existsAt(LOCAL), probe }); + expect(second).toEqual(first); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("re-probes once the cached env is a day old", async () => { + const storage = memoryStorage(); + const probe = vi.fn().mockResolvedValue(PROBED); + let clock = 1_000_000; + const now = () => clock; + await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + clock += ENV_TTL_MS - 1; + await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + expect(probe).toHaveBeenCalledTimes(1); + clock += 1; + probe.mockResolvedValue({ ...PROBED, env: { ...PROBED.env, CARGO_HOME: "/y/cargo" } }); + const result = await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + expect(probe).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ env: { CARGO_HOME: "/y/cargo" } }); + }); + + it("re-probes when the mise the cached PATH held has gone", async () => { + const storage = memoryStorage(); + await resolveMise({ storage, exists: existsAt(`${HOME_BIN}/mise`), probe: vi.fn().mockResolvedValue(PROBED) }); + const probe = vi.fn().mockResolvedValue({ ...PROBED, env: { ...PROBED.env, PATH: "/new/bin" } }); + const result = await resolveMise({ storage, exists: existsAt("/new/bin/mise"), probe }); + expect(probe).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ path: "/new/bin/mise", env: { PATH: `/new/bin:${BASE_PATH}` } }); + }); + + it("overlays MISE_YES and NO_COLOR over the user's values and appends the base PATH", async () => { + const probe = vi.fn().mockResolvedValue({ + ...PROBED, + env: { PATH: "/opt/homebrew/bin:/mine", MISE_YES: "0", NO_COLOR: "0", GOPATH: "/go" }, + }); + const result = await resolveMise({ storage: memoryStorage(), exists: existsAt(LOCAL), probe }); + expect(result).toEqual({ + path: LOCAL, + env: { + PATH: "/opt/homebrew/bin:/mine:/usr/local/bin:/usr/bin:/bin", + MISE_YES: "1", + NO_COLOR: "1", + GOPATH: "/go", + }, + }); + }); + + it("falls back to the base PATH when the probe fails, then backs off for five minutes", async () => { + const storage = memoryStorage(); + const probe = vi.fn().mockResolvedValue(undefined); + let clock = 1_000_000; + const now = () => clock; + + const failed = await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + expect(failed).toEqual({ path: LOCAL, env: BASE_ENV }); + + clock += PROBE_BACKOFF_MS - 1; + await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + expect(probe).toHaveBeenCalledTimes(1); + + clock += 1; + probe.mockResolvedValue(PROBED); + const recovered = await resolveMise({ storage, exists: existsAt(LOCAL), probe, now }); + expect(probe).toHaveBeenCalledTimes(2); + expect(recovered).toEqual({ path: LOCAL, env: PROBED_ENV }); + }); + + it("finds mise on the probed PATH when no candidate exists, and caches the hit", async () => { + const storage = memoryStorage(); + const probe = vi.fn().mockResolvedValue(PROBED); + const exists = existsAt(`${HOME_BIN}/mise`); + const first = await resolveMise({ storage, exists, probe }); + expect(first).toEqual({ path: `${HOME_BIN}/mise`, env: PROBED_ENV }); + const second = await resolveMise({ storage, exists, probe }); + expect(second).toEqual(first); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("uses an absolute command from the probe directly", async () => { + const probe = vi.fn().mockResolvedValue({ ...PROBED, command: "/nix/bin/mise" }); + const result = await resolveMise({ storage: memoryStorage(), exists: existsAt("/nix/bin/mise"), probe }); + expect(result).toMatchObject({ path: "/nix/bin/mise" }); + }); + + it("reports every path searched when the probe finds nothing, backs off, then probes again", async () => { + const storage = memoryStorage(); + const probe = vi.fn().mockResolvedValue(PROBED); + let clock = 1_000_000; + const now = () => clock; + const exists = () => false; + + const miss = await resolveMise({ storage, exists, probe, now }); + expect(miss).toEqual({ searched: [LOCAL, HOMEBREW, "/usr/local/bin/mise", PROBE_LABEL] }); + + clock += PROBE_BACKOFF_MS - 1; + expect(await resolveMise({ storage, exists, probe, now })).toEqual(miss); + expect(probe).toHaveBeenCalledTimes(1); + + clock += 1; + const found = await resolveMise({ storage, exists: existsAt(`${HOME_BIN}/mise`), probe, now }); + expect(probe).toHaveBeenCalledTimes(2); + expect(found).toMatchObject({ path: `${HOME_BIN}/mise` }); + }); + + it("treats a probe that returns nothing as a miss", async () => { + const result = await resolveMise({ storage: memoryStorage(), exists: () => false, probe: async () => undefined }); + expect(result).toMatchObject({ searched: expect.arrayContaining([PROBE_LABEL]) }); + }); + + it("ignores a cached record it cannot read", async () => { + const probe = vi.fn().mockResolvedValue(PROBED); + for (const raw of ["not json", JSON.stringify({ env: { PATH: 1 } })]) { + const storage = memoryStorage({ "locate.env": raw }); + expect(await resolveMise({ storage, exists: existsAt(LOCAL), probe })).toEqual({ path: LOCAL, env: PROBED_ENV }); + } + expect(probe).toHaveBeenCalledTimes(2); + }); +}); + +describe("parseProbeOutput", () => { + it("reads NUL-separated pairs and the sentinel, dropping shell state and unnameable keys", () => { + const stdout = [ + "banner from .zshrc", + "HOME=/Users/x", + "PATH=/a:/b", + "FZF_OPTS=--bind=ctrl-d:down\n--multi", + "SHLVL=1", + "PWD=/Users/x", + "OLDPWD=/", + "TERM_SESSION_ID=abc", + "_=/usr/bin/env", + "BASH_FUNC_x%%=() { :; }", + "=nokey", + "", + "<>", + ].join("\0"); + expect(parseProbeOutput(stdout)).toEqual({ + env: { HOME: "/Users/x", PATH: "/a:/b", FZF_OPTS: "--bind=ctrl-d:down\n--multi" }, + command: "mise", + installPath: "/Users/x/.local/bin/mise", + }); + }); + + it("returns nothing when the sentinel never arrived", () => { + expect(parseProbeOutput("HOME=/Users/x\0PATH=/a\0")).toBeUndefined(); + expect(parseProbeOutput("zsh: command not found")).toBeUndefined(); + }); +}); diff --git a/extensions/mise/src/mise/locate.ts b/extensions/mise/src/mise/locate.ts new file mode 100644 index 00000000000..7e64118ca36 --- /dev/null +++ b/extensions/mise/src/mise/locate.ts @@ -0,0 +1,139 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; + +export type MiseEnv = Record & { PATH: string; MISE_YES: "1"; NO_COLOR: "1" }; +export type MiseLocation = { path: string; env: MiseEnv }; +export type MiseNotFound = { searched: string[] }; + +export type MiseStorage = { get(key: string): string | undefined; set(key: string, value: string): void }; +export type ProbeResult = { env: Record; command: string; installPath: string }; + +export type ResolveOptions = { + preferredPath?: string; + storage: MiseStorage; + exists?: (path: string) => boolean; + probe?: () => Promise; + now?: () => number; +}; + +type EnvRecord = { capturedAt: number; path?: string; env: Record }; + +export const BASE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; +export const PROBE_BACKOFF_MS = 5 * 60 * 1000; +export const ENV_TTL_MS = 24 * 60 * 60 * 1000; +const ENV_KEY = "locate.env"; +const PROBE_MISS_KEY = "locate.probeMissAt"; +const DROPPED_KEYS = new Set(["_", "SHLVL", "PWD", "OLDPWD", "TERM_SESSION_ID"]); +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function staticCandidates(): string[] { + return [join(homedir(), ".local/bin/mise"), "/opt/homebrew/bin/mise", "/usr/local/bin/mise"]; +} + +// Raycast launches the extension with launchd's environment, which has none of the exports a +// .zshrc or .zprofile makes (CARGO_HOME, GOPATH, MISE_*, PATH additions), so every mise call runs +// with the login shell's environment instead. +export function loginShell(): string { + const shell = process.env.SHELL ?? ""; + return ["zsh", "bash"].includes(basename(shell)) ? shell : "/bin/zsh"; +} + +export const PROBE_LABEL = `login shell (${basename(loginShell())} -ilc)`; + +export async function resolveMise(options: ResolveOptions): Promise { + const { storage, exists = existsSync, probe = loginShellProbe, now = Date.now } = options; + + const preferred = options.preferredPath?.trim() || undefined; + if (preferred && !exists(preferred)) return { searched: [preferred] }; + const candidates = staticCandidates(); + const staticPath = preferred ?? candidates.find(exists); + const searched = staticPath ? [] : candidates; + + const cached = readCachedEnv(storage); + const fresh = cached && now() - cached.capturedAt < ENV_TTL_MS && (!cached.path || exists(cached.path)); + const needsBinary = !staticPath && !(fresh && cached.path); + const missedAt = Number(storage.get(PROBE_MISS_KEY)); + const backingOff = missedAt > 0 && now() - missedAt < PROBE_BACKOFF_MS; + + let record = fresh ? cached : undefined; + if (needsBinary) searched.push(PROBE_LABEL); + if ((!fresh || needsBinary) && !backingOff) { + const result = await probe(); + if (result) { + record = { capturedAt: now(), path: pickFromProbe(result, exists), env: result.env }; + storage.set(ENV_KEY, JSON.stringify(record)); + } + if (!result || !(staticPath ?? record?.path)) storage.set(PROBE_MISS_KEY, String(now())); + } + + const path = staticPath ?? record?.path; + return path ? { path, env: miseEnv(record?.env) } : { searched }; +} + +function miseEnv(env: Record = {}): MiseEnv { + const dirs = (env.PATH ?? "").split(":").filter(Boolean); + for (const dir of BASE_PATH.split(":")) if (!dirs.includes(dir)) dirs.push(dir); + return { ...env, PATH: dirs.join(":"), MISE_YES: "1", NO_COLOR: "1" }; +} + +function readCachedEnv(storage: MiseStorage): EnvRecord | undefined { + const raw = storage.get(ENV_KEY); + if (!raw) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + return isEnvRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function isEnvRecord(value: unknown): value is EnvRecord { + if (typeof value !== "object" || value === null) return false; + const { capturedAt, path, env } = value as Record; + return ( + typeof capturedAt === "number" && + (path === undefined || typeof path === "string") && + typeof env === "object" && + env !== null && + Object.values(env).every((v) => typeof v === "string") + ); +} + +// `command -v mise` returns the bare word "mise" when `mise activate` has defined a shell function, +// so the probed PATH is scanned as a fallback. +function pickFromProbe(result: ProbeResult, exists: (path: string) => boolean): string | undefined { + const direct = [result.command, result.installPath].filter((p) => p.startsWith("/")); + const fromPath = (result.env.PATH ?? "") + .split(":") + .filter(Boolean) + .map((dir) => join(dir, "mise")); + return [...direct, ...fromPath].find((p) => exists(p)); +} + +const PROBE_SCRIPT = `printf '\\0'; env -0; printf '\\0<<%s||%s>>' "$(command -v mise)" "$MISE_INSTALL_PATH"`; + +export function parseProbeOutput(stdout: string): ProbeResult | undefined { + const segments = stdout.split("\0"); + const sentinel = /<<(.*?)\|\|(.*?)>>/s.exec(segments.pop() ?? ""); + if (!sentinel) return undefined; + const env: Record = {}; + for (const segment of segments) { + const eq = segment.indexOf("="); + const key = segment.slice(0, eq); + if (eq > 0 && KEY_PATTERN.test(key) && !DROPPED_KEYS.has(key)) env[key] = segment.slice(eq + 1); + } + return { env, command: sentinel[1], installPath: sentinel[2] }; +} + +function loginShellProbe(): Promise { + return new Promise((resolve) => { + execFile( + loginShell(), + ["-ilc", PROBE_SCRIPT], + { timeout: 5000, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }, + (error, stdout) => resolve(error ? undefined : parseProbeOutput(stdout)), + ); + }); +} diff --git a/extensions/mise/src/mise/mise.integration.test.ts b/extensions/mise/src/mise/mise.integration.test.ts new file mode 100644 index 00000000000..62ffe82e6b7 --- /dev/null +++ b/extensions/mise/src/mise/mise.integration.test.ts @@ -0,0 +1,199 @@ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { searchBackend } from "./backends"; +import { listConfigFiles } from "./config"; +import { runMise } from "./exec"; +import { listInstalled } from "./installed"; +import { resolveMise, type MiseLocation } from "./locate"; +import { addGlobally, clearCache, prune, remove, runTask, uninstall, upgrade, type MiseOperation } from "./operations"; +import { listOutdated } from "./outdated"; +import { listRegistry } from "./registry"; +import { listRemote } from "./remote"; +import { listTasks } from "./tasks"; + +const NETWORK_TIMEOUT = 15_000; + +let mise: MiseLocation | undefined; +let searched: string[] = []; + +beforeAll(async () => { + const data = new Map(); + const result = await resolveMise({ + storage: { get: (key) => data.get(key), set: (key, value) => void data.set(key, value) }, + }); + if ("path" in result) mise = result; + else searched = result.searched; +}); + +beforeEach((ctx) => { + if (!mise) ctx.skip(`mise not found on this machine (searched ${searched.join(", ")})`); +}); + +function loc(): MiseLocation { + if (!mise) throw new Error("mise not resolved"); + return mise; +} + +async function dryRun(op: MiseOperation) { + const result = await runMise(loc(), [...op.args, "--dry-run"]); + expect(result, `mise ${op.args.join(" ")} --dry-run`).toMatchObject({ code: 0 }); + return result; +} + +describe("resolveMise runs mise in the login shell's environment", () => { + it("carries the shell's exports and keeps the resolved mise on PATH", () => { + const { path, env } = loc(); + expect(env.PATH.split(":")).toContain(dirname(path)); + if (process.env.CARGO_HOME) expect(env.CARGO_HOME).toBe(process.env.CARGO_HOME); + }); +}); + +describe("operations dry-run against the real mise", () => { + it("addGlobally writes to the default global config", async () => { + const result = await dryRun(addGlobally("jq")); + expect(result.stdout).toMatch(/would (update|install)/); + }); + + it("addGlobally writes to a chosen config file", async () => { + const [file] = await listConfigFiles(loc()); + const result = await dryRun(addGlobally("jq", "latest", { configFile: file.path })); + expect(result.stdout).toMatch(/would (update|install)/); + expect(result.stdout).toContain(file.path.replace(homedir(), "~")); + }); + + it("addGlobally passes the job count", async () => { + const result = await dryRun(addGlobally("jq", "latest", { jobs: 2 })); + expect(result.stdout).toMatch(/would (update|install)/); + }); + + it("uninstall removes an installed jq version", async (ctx) => { + const jq = (await listInstalled(loc())).find((tool) => tool.name === "jq"); + if (!jq) return ctx.skip("jq is not installed; run `mise install jq` to cover uninstall"); + const result = await dryRun(uninstall("jq", jq.versions[0].version)); + expect(result.stdout + result.stderr).toMatch(/dry-?run|would/i); + }); + + it("upgrade of one tool", async () => { + await dryRun(upgrade("jq")); + }); + + it("upgrade of every tool", async () => { + await dryRun(upgrade()); + }); + + it("upgrade with --bump --inactive -j 2 --prune", async () => { + const result = await dryRun(upgrade("jq", { bump: true, inactive: true, jobs: 2, prune: true })); + expect(result.stdout + result.stderr).toMatch(/would|up to date|nothing/i); + }); + + it("uninstall through unuse is a subcommand that takes --path and prunes by default", async () => { + const op = uninstall("jq", "1.8.1", { unuse: { configFile: "/tmp/mise.toml", requested: "1.8" } }); + const result = await runMise(loc(), [op.args[0], "--help"]); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/--path/); + expect(result.stdout).toMatch(/--no-prune/); + }); + + it("remove runs unuse, which prunes by default, then uninstall --all", async () => { + const op = remove("jq", { configured: true }); + const unuse = await runMise(loc(), [op.args[0], "--help"]); + expect(unuse.code).toBe(0); + expect(unuse.stdout).toMatch(/prune/); + const [uninstallStep] = op.andThen ?? []; + const uninstall = await runMise(loc(), [uninstallStep[0], "--help"]); + expect(uninstall.code).toBe(0); + expect(uninstall.stdout).toMatch(/--all\b/); + }); + + // `mise run --dry-run` hands --dry-run to the task and runs it; the flag goes before the name. + it("runTask names a task mise run resolves, checked with --dry-run before the name", async () => { + const [verb, name] = runTask("nvim:check").args; + const result = await runMise(loc(), [verb, "--dry-run", name]); + expect(result.code).toBe(0); + expect(result.stdout + result.stderr).toContain("[nvim:check]"); + }); + + it("prune", async () => { + await dryRun(prune()); + }); + + it("prune --tools and prune --configs", async () => { + await dryRun(prune("tools")); + await dryRun(prune("configs")); + }); + + it("clearCache is a subcommand mise cache lists", async () => { + const [group, sub] = clearCache().args; + const result = await runMise(loc(), [group, "--help"]); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(new RegExp(`^\\s*${sub}\\b`, "m")); + }); +}); + +describe("read paths parse the real mise output", () => { + it("listRegistry", async () => { + const tools = await listRegistry(loc()); + expect(tools.length).toBeGreaterThan(1000); + expect(tools.find((t) => t.short === "jq")?.bins).toContain("jq"); + }); + + it("listInstalled", async () => { + const tools = await listInstalled(loc()); + expect(tools.length).toBeGreaterThan(0); + expect(tools.every((t) => t.versions.length > 0)).toBe(true); + }); + + it("listOutdated", async () => { + expect(Array.isArray(await listOutdated(loc()))).toBe(true); + }); + + it("listOutdated with --bump --inactive parses rows whose source is unknown", async () => { + const tools = await listOutdated(loc(), { bump: true, inactive: true }); + expect(Array.isArray(tools)).toBe(true); + expect(tools.every((t) => typeof t.latest === "string")).toBe(true); + }); + + it("listRemote", async () => { + const versions = await listRemote(loc(), "jq"); + expect(versions.map((v) => v.version)).toContain("1.8.1"); + }); + + it("listTasks lists the global tasks without running any", async () => { + const tasks = await listTasks(loc()); + expect(tasks.map((t) => t.name)).toEqual(["dotfiles:apply", "dotfiles:status", "nvim:check", "nvim:sync"]); + expect(tasks.every((t) => t.source.endsWith("tasks.toml"))).toBe(true); + }); + + it("listConfigFiles", async () => { + const files = await listConfigFiles(loc()); + expect(files.length).toBeGreaterThan(0); + const globalDir = join(homedir(), ".config", "mise") + "/"; + expect(files.every((f) => f.path.startsWith(globalDir))).toBe(true); + }); +}); + +describe("searchBackend against the real package indexes", () => { + const deps = () => ({ fetch, listRemote: (spec: string) => listRemote(loc(), spec) }); + + it.each([ + ["npm", "prettier"], + ["cargo", "ripgrep"], + ["gem", "rubocop"], + ] as const)( + "%s search leads with the exact match", + async (backend, query) => { + const results = await searchBackend(backend, query, deps()); + expect(results[0].spec).toBe(`${backend}:${query}`); + }, + NETWORK_TIMEOUT, + ); + + it( + "a missing pipx package yields no results", + async () => { + expect(await searchBackend("pipx", "this-does-not-exist-zz", deps())).toEqual([]); + }, + NETWORK_TIMEOUT, + ); +}); diff --git a/extensions/mise/src/mise/operations.test.ts b/extensions/mise/src/mise/operations.test.ts new file mode 100644 index 00000000000..d6404964840 --- /dev/null +++ b/extensions/mise/src/mise/operations.test.ts @@ -0,0 +1,226 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + addGlobally, + clearCache, + failureMessage, + parseProgressLine, + prune, + remove, + runTask, + uninstall, + upgrade, +} from "./operations"; + +const useStderr = readFileSync(join(__dirname, "fixtures/use-stderr.txt"), "utf8"); +const uninstallStderr = readFileSync(join(__dirname, "fixtures/uninstall-stderr.txt"), "utf8"); +const lowDownloadsStderr = readFileSync(join(__dirname, "fixtures/use-low-downloads-stderr.txt"), "utf8"); + +describe("addGlobally", () => { + it("builds mise use -g @latest by default", () => { + expect(addGlobally("jq")).toMatchObject({ + args: ["use", "-g", "jq@latest"], + title: "Adding jq…", + successTitle: "jq added", + failureTitle: "Adding jq failed", + }); + expect(addGlobally("jq", "1.7.1").args).toEqual(["use", "-g", "jq@1.7.1"]); + }); + + it("writes to one config file with --path instead of -g when a file is given", () => { + const op = addGlobally("jq", "1.7.1", { configFile: "/Users/lachlan/.config/mise/conf.d/tools.toml" }); + expect(op.args).toEqual(["use", "--path", "/Users/lachlan/.config/mise/conf.d/tools.toml", "jq@1.7.1"]); + expect(op.successTitle).toBe("jq added"); + }); + + it("passes the parallel job count as -j before the spec", () => { + expect(addGlobally("jq", "latest", { jobs: 4 }).args).toEqual(["use", "-g", "-j", "4", "jq@latest"]); + expect(addGlobally("jq", "latest", { configFile: "/tmp/mise.toml", jobs: 2 }).args).toEqual([ + "use", + "--path", + "/tmp/mise.toml", + "-j", + "2", + "jq@latest", + ]); + }); +}); + +describe("addGlobally retry", () => { + it("offers to allow low downloads when mise refuses an npm package for that reason", () => { + const retry = addGlobally("npm:agent-peek").retry?.(lowDownloadsStderr); + expect(retry?.title).toBe("Allow Low Downloads and Retry"); + expect(retry?.op.args).toEqual(["use", "-g", "--tool-option", "allow_low_downloads=true", "npm:agent-peek@latest"]); + }); + + it("keeps the config file and does not offer the retry again once the option is set", () => { + const op = addGlobally("npm:agent-peek", "latest", { configFile: "/cfg/tools.toml" }); + const retry = op.retry?.(lowDownloadsStderr); + expect(retry?.op.args.slice(0, 3)).toEqual(["use", "--path", "/cfg/tools.toml"]); + expect(retry?.op.retry?.(lowDownloadsStderr)).toBeUndefined(); + }); + + it("offers nothing for other failures", () => { + expect(addGlobally("jq").retry?.("mise ERROR no such tool")).toBeUndefined(); + }); +}); + +describe("failureMessage", () => { + it("prefers mise's own error line over the trailing boilerplate", () => { + expect(failureMessage(lowDownloadsStderr)).toBe( + "Failed to install npm:agent-peek@latest: aube install failed: refusing to add agent-peek: only 17 weekly downloads (threshold: 1000)", + ); + }); + + it("falls back to the last line when there is no mise ERROR line", () => { + expect(failureMessage("first\nlast")).toBe("last"); + }); +}); + +describe("upgrade", () => { + it("upgrades one tool within its configured range, never bumping the config", () => { + expect(upgrade("jq")).toEqual({ + args: ["upgrade", "jq"], + title: "Upgrading jq…", + successTitle: "jq upgraded", + failureTitle: "Upgrading jq failed", + }); + }); + + it("upgrades everything when no tool is given", () => { + expect(upgrade()).toEqual({ + args: ["upgrade"], + title: "Upgrading all tools…", + successTitle: "All tools upgraded", + failureTitle: "Upgrading all tools failed", + }); + }); + + it("adds each flag on its own", () => { + expect(upgrade("jq", { bump: true }).args).toEqual(["upgrade", "--bump", "jq"]); + expect(upgrade("jq", { inactive: true }).args).toEqual(["upgrade", "--inactive", "jq"]); + expect(upgrade("jq", { jobs: 4 }).args).toEqual(["upgrade", "-j", "4", "jq"]); + expect(upgrade(undefined, { prune: true }).args).toEqual(["upgrade", "--prune"]); + expect(upgrade("jq", { bump: false, inactive: false, prune: false }).args).toEqual(["upgrade", "jq"]); + }); + + it("orders the flags --bump --inactive -j N --prune before the tool", () => { + expect(upgrade("jq", { bump: true, inactive: true, jobs: 4, prune: true }).args).toEqual([ + "upgrade", + "--bump", + "--inactive", + "-j", + "4", + "--prune", + "jq", + ]); + }); +}); + +describe("uninstall", () => { + it("removes exactly one version", () => { + expect(uninstall("jq", "1.7.1")).toEqual({ + args: ["uninstall", "jq@1.7.1"], + title: "Uninstalling jq@1.7.1…", + successTitle: "jq@1.7.1 uninstalled", + failureTitle: "Uninstalling jq@1.7.1 failed", + }); + }); + + it("runs unuse on the config file with the request as written there when asked to remove the config entry", () => { + const op = uninstall("node", "24.21.0", { + unuse: { configFile: "/Users/lachlan/.config/mise/conf.d/tools.toml", requested: "lts" }, + }); + expect(op.args).toEqual(["unuse", "--path", "/Users/lachlan/.config/mise/conf.d/tools.toml", "node@lts"]); + expect(op.title).toBe("Uninstalling node@24.21.0…"); + expect(op.successTitle).toBe("node@24.21.0 uninstalled and removed from config"); + }); +}); + +describe("remove", () => { + it("drops a configured tool from config, then deletes every installed version", () => { + expect(remove("jq", { configured: true })).toEqual({ + args: ["unuse", "jq"], + andThen: [["uninstall", "--all", "jq"]], + title: "Removing jq…", + successTitle: "jq removed", + failureTitle: "Removing jq failed", + }); + }); + + it("deletes every installed version of a tool no config declares without running unuse", () => { + expect(remove("jq", { configured: false })).toEqual({ + args: ["uninstall", "--all", "jq"], + title: "Removing jq…", + successTitle: "jq removed", + failureTitle: "Removing jq failed", + }); + }); +}); + +describe("prune", () => { + it("removes every installed version no config uses", () => { + expect(prune()).toEqual({ + args: ["prune"], + title: "Pruning unused versions…", + successTitle: "Pruned unused versions", + failureTitle: "Prune failed", + }); + }); + + it("narrows to tools or config links", () => { + expect(prune("tools").args).toEqual(["prune", "--tools"]); + expect(prune("configs")).toMatchObject({ args: ["prune", "--configs"], successTitle: "Pruned stale config links" }); + }); +}); + +describe("clearCache", () => { + it("deletes mise's cache files", () => { + expect(clearCache()).toEqual({ + args: ["cache", "clear"], + title: "Clearing cache…", + successTitle: "Cache cleared", + failureTitle: "Clearing cache failed", + }); + }); +}); + +describe("runTask", () => { + it("runs one task by name", () => { + expect(runTask("nvim:check")).toEqual({ + args: ["run", "nvim:check"], + title: "Running nvim:check…", + successTitle: "nvim:check finished", + failureTitle: "nvim:check failed", + }); + }); +}); + +describe("parseProgressLine", () => { + it("turns the captured stderr of mise use -g jq@1.7.1 into progress then a summary", () => { + const parsed = useStderr.split("\n").map(parseProgressLine); + expect(parsed).toEqual([ + { kind: "progress", message: "installing 1 tool" }, + { kind: "progress", message: "✓ jq@1.7.1 613ms" }, + { kind: "summary", message: "installed 1 tool in 616ms" }, + undefined, + ]); + }); + + it("ignores warnings, errors and lines not from mise", () => { + expect(parseProgressLine("mise WARN deprecated [idiomatic.go.mod.go-directive]: 1/2 · noise")).toBeUndefined(); + expect(parseProgressLine("mise ERROR failed to install jq@1.7.1")).toBeUndefined(); + expect(parseProgressLine("Downloading jq-macos-arm64")).toBeUndefined(); + expect(parseProgressLine("")).toBeUndefined(); + }); + + it("turns the captured stderr of mise uninstall jq@1.7.1 into progress then a summary", () => { + expect(uninstallStderr.split("\n").map(parseProgressLine)).toEqual([ + { kind: "progress", message: "removing 1 tool" }, + { kind: "progress", message: "✓ jq@1.7.1 0ms" }, + { kind: "summary", message: "removed 1 tool in 0ms" }, + undefined, + ]); + }); +}); diff --git a/extensions/mise/src/mise/operations.ts b/extensions/mise/src/mise/operations.ts new file mode 100644 index 00000000000..0393b16e93b --- /dev/null +++ b/extensions/mise/src/mise/operations.ts @@ -0,0 +1,150 @@ +// `andThen` holds further mise command lines run in order after `args`; the first non-zero exit stops the run. +export type MiseOperation = { + args: string[]; + andThen?: string[][]; + title: string; + successTitle: string; + failureTitle: string; + retry?: (stderr: string) => Retry | undefined; +}; + +export type Retry = { title: string; op: MiseOperation }; + +export type Progress = { kind: "progress"; message: string } | { kind: "summary"; message: string }; + +export type UseOptions = { configFile?: string; jobs?: number; toolOptions?: Record }; +export type UpgradeOptions = { bump?: boolean; inactive?: boolean; jobs?: number; prune?: boolean }; +export type UninstallOptions = { unuse?: { configFile: string; requested: string } }; +export type PruneScope = "all" | "tools" | "configs"; + +function jobsFlag(jobs: number | undefined): string[] { + return jobs === undefined ? [] : ["-j", String(jobs)]; +} + +export function addGlobally(tool: string, version = "latest", options: UseOptions = {}): MiseOperation { + const spec = `${tool}@${version}`; + const target = options.configFile ? ["--path", options.configFile] : ["-g"]; + const toolOptions = Object.entries(options.toolOptions ?? {}).flatMap(([key, value]) => [ + "--tool-option", + `${key}=${value}`, + ]); + return { + args: ["use", ...target, ...jobsFlag(options.jobs), ...toolOptions, spec], + title: `Adding ${tool}…`, + successTitle: `${tool} added`, + failureTitle: `Adding ${tool} failed`, + retry: (stderr) => + LOW_DOWNLOADS_HINT.test(stderr) && !options.toolOptions?.allow_low_downloads + ? { + title: "Allow Low Downloads and Retry", + op: addGlobally(tool, version, { + ...options, + toolOptions: { ...options.toolOptions, allow_low_downloads: "true" }, + }), + } + : undefined, + }; +} + +// mise refuses npm packages under its weekly-download threshold and prints this hint. +const LOW_DOWNLOADS_HINT = /allow_low_downloads = true/; + +export function failureMessage(stderr: string): string { + const lines = stderr + .trim() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const error = lines.find((line) => line.startsWith("mise ERROR ") && !line.startsWith("mise ERROR Version")); + return error?.slice("mise ERROR ".length) ?? lines[lines.length - 1] ?? ""; +} + +export function upgrade(tool?: string, options: UpgradeOptions = {}): MiseOperation { + const flags = [ + ...(options.bump ? ["--bump"] : []), + ...(options.inactive ? ["--inactive"] : []), + ...jobsFlag(options.jobs), + ...(options.prune ? ["--prune"] : []), + ]; + return { + args: ["upgrade", ...flags, ...(tool ? [tool] : [])], + title: tool ? `Upgrading ${tool}…` : "Upgrading all tools…", + successTitle: tool ? `${tool} upgraded` : "All tools upgraded", + failureTitle: tool ? `Upgrading ${tool} failed` : "Upgrading all tools failed", + }; +} + +// `mise unuse` matches the request as written in the config (node@lts), not the version it resolved to. +export function uninstall(tool: string, version: string, options: UninstallOptions = {}): MiseOperation { + const spec = `${tool}@${version}`; + const { unuse } = options; + return { + args: unuse ? ["unuse", "--path", unuse.configFile, `${tool}@${unuse.requested}`] : ["uninstall", spec], + title: `Uninstalling ${spec}…`, + successTitle: unuse ? `${spec} uninstalled and removed from config` : `${spec} uninstalled`, + failureTitle: `Uninstalling ${spec} failed`, + }; +} + +// `mise unuse` drops the request from whichever config declares it and prunes only that version; +// versions no config asked for stay installed until uninstall --all. A tool no config declares +// has nothing to unuse, so it goes straight to uninstall --all. +export function remove(tool: string, { configured }: { configured: boolean }): MiseOperation { + const uninstallAll = ["uninstall", "--all", tool]; + return { + ...(configured ? { args: ["unuse", tool], andThen: [uninstallAll] } : { args: uninstallAll }), + title: `Removing ${tool}…`, + successTitle: `${tool} removed`, + failureTitle: `Removing ${tool} failed`, + }; +} + +const PRUNE: Record = { + all: { flags: [], what: "unused versions" }, + tools: { flags: ["--tools"], what: "unused versions" }, + configs: { flags: ["--configs"], what: "stale config links" }, +}; + +export function prune(scope: PruneScope = "all"): MiseOperation { + const { flags, what } = PRUNE[scope]; + return { + args: ["prune", ...flags], + title: `Pruning ${what}…`, + successTitle: `Pruned ${what}`, + failureTitle: "Prune failed", + }; +} + +export function clearCache(): MiseOperation { + return { + args: ["cache", "clear"], + title: "Clearing cache…", + successTitle: "Cache cleared", + failureTitle: "Clearing cache failed", + }; +} + +export function runTask(name: string): MiseOperation { + return { + args: ["run", name], + title: `Running ${name}…`, + successTitle: `${name} finished`, + failureTitle: `${name} failed`, + }; +} + +const HEADER = /^by @jdx \S+ (.+)$/; +const TOOL = /^✓ (\S+)\s+(\S+)/; +const SUMMARY = /^\S*\s*\d+\/\d+ · (.+)$/; + +export function parseProgressLine(line: string): Progress | undefined { + const body = line.startsWith("mise ") ? line.slice("mise ".length).trim() : undefined; + if (!body) return undefined; + const header = HEADER.exec(body); + if (header) return { kind: "progress", message: header[1] }; + const tool = TOOL.exec(body); + if (tool) return { kind: "progress", message: `✓ ${tool[1]} ${tool[2]}` }; + const summary = SUMMARY.exec(body); + if (summary) return { kind: "summary", message: summary[1] }; + return undefined; +} diff --git a/extensions/mise/src/mise/outdated.test.ts b/extensions/mise/src/mise/outdated.test.ts new file mode 100644 index 00000000000..dcdbf74b932 --- /dev/null +++ b/extensions/mise/src/mise/outdated.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import * as exec from "./exec"; +import { MiseOutputError } from "./exec"; +import { listOutdated, outdatedSubtitle, parseOutdated } from "./outdated"; +import fixture from "./fixtures/outdated.json"; +import inactiveFixture from "./fixtures/outdated-inactive.json"; + +describe("parseOutdated", () => { + const tools = parseOutdated(fixture); + + it("keeps the tool names as mise outdated keys, backend prefixes included", () => { + expect(tools.map((t) => t.name)).toEqual(["gh", "npm:wrangler", "opencode", "usage"]); + }); + + it("maps requested, current, latest, release URL and source path", () => { + expect(tools[0]).toEqual({ + name: "gh", + requested: "latest", + current: "2.100.0", + latest: "2.101.0", + releaseUrl: "https://github.com/cli/cli/releases/tag/v2.101.0", + sourcePath: "/Users/lachlan/.config/mise/config.local.toml", + }); + }); + + it("leaves releaseUrl out when a backend does not report it", () => { + const wrangler = tools.find((t) => t.name === "npm:wrangler"); + expect(wrangler?.latest).toBe("4.131.2"); + expect(wrangler).not.toHaveProperty("releaseUrl"); + }); + + it("leaves sourcePath out for the inactive installs --inactive adds, whose source is unknown", () => { + const [gh, jq] = parseOutdated(inactiveFixture); + expect(gh).toEqual({ + name: "gh", + requested: "2.99.0", + current: "2.99.0", + latest: "2.101.0", + releaseUrl: "https://github.com/cli/cli/releases/tag/v2.101.0", + }); + expect(jq).not.toHaveProperty("sourcePath"); + }); + + it("returns an empty list when nothing is outdated", () => { + expect(parseOutdated({})).toEqual([]); + }); + + it("throws MiseOutputError on a shape mismatch", () => { + expect(() => parseOutdated([])).toThrow(MiseOutputError); + expect(() => parseOutdated({ gh: { name: "gh", current: "2.100.0" } })).toThrow(/gh is malformed/); + expect(() => parseOutdated({ gh: { requested: "latest", current: "1", latest: "2" } })).toThrow(MiseOutputError); + }); +}); + +describe("listOutdated", () => { + const location = { path: "/bin/false", env: { PATH: "/bin", MISE_YES: "1", NO_COLOR: "1" } } as const; + + it("runs mise outdated with --bump and --inactive only when asked", async () => { + const miseJson = vi.spyOn(exec, "miseJson").mockResolvedValue([]); + await listOutdated(location); + expect(miseJson).toHaveBeenLastCalledWith(location, ["outdated"], parseOutdated); + await listOutdated(location, { bump: true, inactive: true }); + expect(miseJson).toHaveBeenLastCalledWith(location, ["outdated", "--bump", "--inactive"], parseOutdated); + await listOutdated(location, { inactive: true }); + expect(miseJson).toHaveBeenLastCalledWith(location, ["outdated", "--inactive"], parseOutdated); + }); +}); + +describe("outdatedSubtitle", () => { + it("reads 'All up to date' when nothing is outdated", () => { + expect(outdatedSubtitle(0)).toBe("All up to date"); + }); + + it("counts outdated tools", () => { + expect(outdatedSubtitle(1)).toBe("1 outdated"); + expect(outdatedSubtitle(3)).toBe("3 outdated"); + }); + + it("derives the count from the parsed list", () => { + expect(outdatedSubtitle(parseOutdated(fixture).length)).toBe("4 outdated"); + }); +}); diff --git a/extensions/mise/src/mise/outdated.ts b/extensions/mise/src/mise/outdated.ts new file mode 100644 index 00000000000..ce0a6e65cec --- /dev/null +++ b/extensions/mise/src/mise/outdated.ts @@ -0,0 +1,43 @@ +import { isRecord, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type OutdatedTool = { + name: string; + requested: string; + current: string; + latest: string; + releaseUrl?: string; + sourcePath?: string; +}; + +export function parseOutdated(raw: unknown): OutdatedTool[] { + if (!isRecord(raw)) { + throw new MiseOutputError("mise outdated: expected an object keyed by tool", JSON.stringify(raw)); + } + return Object.entries(raw).map(([name, entry]) => { + if ( + !isRecord(entry) || + typeof entry.requested !== "string" || + typeof entry.current !== "string" || + typeof entry.latest !== "string" || + !isRecord(entry.source) + ) { + throw new MiseOutputError(`mise outdated: ${name} is malformed`, JSON.stringify(entry)); + } + const tool: OutdatedTool = { name, requested: entry.requested, current: entry.current, latest: entry.latest }; + if (typeof entry.release_url === "string") tool.releaseUrl = entry.release_url; + if (typeof entry.source.path === "string") tool.sourcePath = entry.source.path; + return tool; + }); +} + +export type OutdatedOptions = { bump?: boolean; inactive?: boolean }; + +export function listOutdated(location: MiseLocation, options: OutdatedOptions = {}): Promise { + const flags = [...(options.bump ? ["--bump"] : []), ...(options.inactive ? ["--inactive"] : [])]; + return miseJson(location, ["outdated", ...flags], parseOutdated); +} + +export function outdatedSubtitle(count: number): string { + return count === 0 ? "All up to date" : `${count} outdated`; +} diff --git a/extensions/mise/src/mise/registry.test.ts b/extensions/mise/src/mise/registry.test.ts new file mode 100644 index 00000000000..b7f9a71e001 --- /dev/null +++ b/extensions/mise/src/mise/registry.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { MiseOutputError } from "./exec"; +import { backendKind, filterRegistry, parseRegistry, type RegistryTool } from "./registry"; +import fixture from "./fixtures/registry.json"; + +describe("parseRegistry", () => { + const tools = parseRegistry(fixture); + + it("keeps every entry from mise registry --json", () => { + expect(tools.map((t) => t.short)).toEqual(["jq", "1password", "node", "ripgrep", "ag"]); + }); + + it("normalizes missing aliases and bins to empty arrays", () => { + const jq = tools.find((t) => t.short === "jq"); + expect(jq).toEqual({ + short: "jq", + description: "Command-line JSON processor", + backends: ["aqua:jqlang/jq", "asdf:mise-plugins/asdf-jq"], + bins: ["jq"], + aliases: [], + }); + expect(tools.find((t) => t.short === "ag")?.bins).toEqual([]); + }); + + it("keeps aliases when present", () => { + expect(tools.find((t) => t.short === "1password")?.aliases).toEqual(["1password-cli", "op"]); + }); + + it("throws MiseOutputError carrying the raw text on a shape mismatch", () => { + expect(() => parseRegistry({ not: "an array" })).toThrow(MiseOutputError); + expect(() => parseRegistry([{ short: "x" }])).toThrow(/entry 0/); + try { + parseRegistry([{ short: 42, backends: [] }]); + } catch (error) { + expect((error as MiseOutputError).raw).toBe('{"short":42,"backends":[]}'); + } + }); +}); + +describe("backendKind", () => { + it("takes the scheme of the first backend", () => { + const [jq, , node] = parseRegistry(fixture); + expect(backendKind(jq)).toBe("aqua"); + expect(backendKind(node)).toBe("core"); + }); +}); + +describe("filterRegistry", () => { + const tool = (short: string, extra: Partial = {}): RegistryTool => ({ + short, + description: "", + backends: [`aqua:${short}`], + bins: [], + aliases: [], + ...extra, + }); + const tools = [ + tool("ripgrep", { bins: ["rg"], description: "Recursively search directories for a regex pattern" }), + tool("rg-alias", { aliases: ["rg"] }), + tool("borg", { description: "Deduplicating backup" }), + tool("rgb"), + tool("1password", { aliases: ["op"], bins: ["op"] }), + tool("rg"), + tool("grep-tools", { description: "rg wrappers" }), + ]; + + it("returns every tool for an empty or blank query", () => { + expect(filterRegistry(tools, "", 2)).toBe(tools); + expect(filterRegistry(tools, " ", 2)).toBe(tools); + }); + + it("ranks an exact short first, then short prefixes, then alias and bin prefixes, then substrings", () => { + expect(filterRegistry(tools, "rg", 50).map((t) => t.short)).toEqual([ + "rg", + "rg-alias", + "rgb", + "ripgrep", + "borg", + "grep-tools", + ]); + }); + + it("matches case-insensitively", () => { + expect(filterRegistry(tools, "OP", 50).map((t) => t.short)).toEqual(["1password"]); + expect(filterRegistry(tools, "Backup", 50).map((t) => t.short)).toEqual(["borg"]); + }); + + it("keeps registry order within a rank and caps at the limit", () => { + expect(filterRegistry(tools, "r", 50).map((t) => t.short)).toEqual([ + "ripgrep", + "rg-alias", + "rgb", + "rg", + "borg", + "1password", + "grep-tools", + ]); + expect(filterRegistry(tools, "r", 3).map((t) => t.short)).toEqual(["ripgrep", "rg-alias", "rgb"]); + }); + + it("leaves out tools that match nowhere", () => { + expect(filterRegistry(tools, "zzz", 50)).toEqual([]); + }); +}); diff --git a/extensions/mise/src/mise/registry.ts b/extensions/mise/src/mise/registry.ts new file mode 100644 index 00000000000..674ba899f39 --- /dev/null +++ b/extensions/mise/src/mise/registry.ts @@ -0,0 +1,57 @@ +import { isRecord, isStringArray, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type RegistryTool = { + short: string; + description: string; + backends: string[]; + bins: string[]; + aliases: string[]; +}; + +export function parseRegistry(raw: unknown): RegistryTool[] { + if (!Array.isArray(raw)) throw new MiseOutputError("mise registry: expected an array", JSON.stringify(raw)); + return raw.map((entry, index) => { + if (!isRecord(entry) || typeof entry.short !== "string" || !isStringArray(entry.backends)) { + throw new MiseOutputError(`mise registry: entry ${index} is not a registry tool`, JSON.stringify(entry)); + } + return { + short: entry.short, + description: typeof entry.description === "string" ? entry.description : "", + backends: entry.backends, + bins: isStringArray(entry.bins) ? entry.bins : [], + aliases: isStringArray(entry.aliases) ? entry.aliases : [], + }; + }); +} + +export function listRegistry(location: MiseLocation): Promise { + return miseJson(location, ["registry"], parseRegistry); +} + +export function backendKind(tool: RegistryTool): string { + return tool.backends[0]?.split(":")[0] ?? ""; +} + +export function filterRegistry(tools: RegistryTool[], query: string, limit: number): RegistryTool[] { + const needle = query.trim().toLowerCase(); + if (!needle) return tools; + const ranked: { rank: number; tool: RegistryTool }[] = []; + for (const tool of tools) { + const rank = matchRank(tool, needle); + if (rank !== undefined) ranked.push({ rank, tool }); + } + ranked.sort((a, b) => a.rank - b.rank); + return ranked.slice(0, limit).map((entry) => entry.tool); +} + +function matchRank(tool: RegistryTool, needle: string): number | undefined { + const short = tool.short.toLowerCase(); + if (short === needle) return 0; + if (short.startsWith(needle)) return 1; + const names = [...tool.aliases, ...tool.bins].map((name) => name.toLowerCase()); + if (names.some((name) => name.startsWith(needle))) return 2; + if (short.includes(needle) || names.some((name) => name.includes(needle))) return 3; + if (tool.description.toLowerCase().includes(needle)) return 3; + return undefined; +} diff --git a/extensions/mise/src/mise/remote.test.ts b/extensions/mise/src/mise/remote.test.ts new file mode 100644 index 00000000000..68f2d6bc65f --- /dev/null +++ b/extensions/mise/src/mise/remote.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { MiseOutputError } from "./exec"; +import { parseRemote } from "./remote"; +import fixture from "./fixtures/ls-remote.json"; + +describe("parseRemote", () => { + it("keeps mise's oldest-to-newest order with release dates and URLs", () => { + const versions = parseRemote(fixture); + expect(versions.map((v) => v.version)).toEqual([ + "1.3", + "1.4", + "1.5", + "1.6", + "1.7", + "1.7.1", + "1.8.0", + "1.8.1", + "1.8.2", + ]); + expect(versions[versions.length - 1]).toEqual({ + version: "1.8.2", + createdAt: "2026-06-20T14:11:27.0Z", + releaseUrl: "https://github.com/jqlang/jq/releases/tag/jq-1.8.2", + }); + }); + + it("leaves createdAt and releaseUrl out when a backend does not report them", () => { + expect(parseRemote([{ version: "26.8.2", created_at: "2026-09-09T00:00:00.0Z" }, { version: "1.0" }])).toEqual([ + { version: "26.8.2", createdAt: "2026-09-09T00:00:00.0Z" }, + { version: "1.0" }, + ]); + }); + + it("throws MiseOutputError on a shape mismatch", () => { + expect(() => parseRemote({})).toThrow(MiseOutputError); + expect(() => parseRemote([{ created_at: "x" }])).toThrow(/entry 0 is not a version/); + }); +}); diff --git a/extensions/mise/src/mise/remote.ts b/extensions/mise/src/mise/remote.ts new file mode 100644 index 00000000000..a7c817d052c --- /dev/null +++ b/extensions/mise/src/mise/remote.ts @@ -0,0 +1,25 @@ +import { isRecord, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type RemoteVersion = { version: string; createdAt?: string; releaseUrl?: string }; + +export function parseRemote(raw: unknown): RemoteVersion[] { + if (!Array.isArray(raw)) throw new MiseOutputError("mise ls-remote: expected an array", JSON.stringify(raw)); + return raw.map((entry, index) => { + if (!isRecord(entry) || typeof entry.version !== "string") { + throw new MiseOutputError(`mise ls-remote: entry ${index} is not a version`, JSON.stringify(entry)); + } + const version: RemoteVersion = { version: entry.version }; + if (typeof entry.created_at === "string") version.createdAt = entry.created_at; + if (typeof entry.release_url === "string") version.releaseUrl = entry.release_url; + return version; + }); +} + +export function listRemote( + location: MiseLocation, + tool: string, + options: { signal?: AbortSignal } = {}, +): Promise { + return miseJson(location, ["ls-remote", tool], parseRemote, options); +} diff --git a/extensions/mise/src/mise/tasks.test.ts b/extensions/mise/src/mise/tasks.test.ts new file mode 100644 index 00000000000..eb9a22d66f8 --- /dev/null +++ b/extensions/mise/src/mise/tasks.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { MiseOutputError } from "./exec"; +import fixture from "./fixtures/tasks-ls.json"; +import { parseTasks, taskGroup } from "./tasks"; + +describe("parseTasks", () => { + const tasks = parseTasks(fixture); + + it("keeps every task in mise's order with its description, source and directory", () => { + expect(tasks.map((t) => t.name)).toEqual(["dotfiles:apply", "dotfiles:status", "nvim:check", "nvim:sync"]); + expect(tasks[1]).toEqual({ + name: "dotfiles:status", + description: "Show chezmoi and mise-managed dotfile drift", + aliases: [], + source: "/Users/lachlan/.config/mise/conf.d/tasks.toml", + dir: "/Users/lachlan", + }); + }); + + it("throws MiseOutputError on a shape mismatch", () => { + expect(() => parseTasks({})).toThrow(MiseOutputError); + expect(() => parseTasks([{ name: "x" }])).toThrow(/task is malformed/); + expect(() => parseTasks([{ ...fixture[0], aliases: "b" }])).toThrow(MiseOutputError); + }); +}); + +describe("taskGroup", () => { + it("is the part before the first colon, or nothing for a plain name", () => { + expect(taskGroup("dotfiles:apply")).toBe("dotfiles"); + expect(taskGroup("a:b:c")).toBe("a"); + expect(taskGroup("build")).toBeUndefined(); + expect(taskGroup(":odd")).toBeUndefined(); + }); +}); diff --git a/extensions/mise/src/mise/tasks.ts b/extensions/mise/src/mise/tasks.ts new file mode 100644 index 00000000000..8c0c330ebab --- /dev/null +++ b/extensions/mise/src/mise/tasks.ts @@ -0,0 +1,37 @@ +import { isRecord, isStringArray, miseJson, MiseOutputError } from "./exec"; +import type { MiseLocation } from "./locate"; + +export type Task = { name: string; description: string; aliases: string[]; source: string; dir: string }; + +export function parseTasks(raw: unknown): Task[] { + if (!Array.isArray(raw)) throw new MiseOutputError("mise tasks ls: expected an array", JSON.stringify(raw)); + return raw.map((entry) => { + if ( + !isRecord(entry) || + typeof entry.name !== "string" || + typeof entry.description !== "string" || + !isStringArray(entry.aliases) || + typeof entry.source !== "string" || + typeof entry.dir !== "string" + ) { + throw new MiseOutputError("mise tasks ls: task is malformed", JSON.stringify(entry)); + } + return { + name: entry.name, + description: entry.description, + aliases: entry.aliases, + source: entry.source, + dir: entry.dir, + }; + }); +} + +export function listTasks(location: MiseLocation): Promise { + return miseJson(location, ["tasks", "ls"], parseTasks); +} + +// Tasks are namespaced with a colon (dotfiles:apply); the prefix is the section they belong to. +export function taskGroup(name: string): string | undefined { + const index = name.indexOf(":"); + return index > 0 ? name.slice(0, index) : undefined; +} diff --git a/extensions/mise/src/outdated-tools.test.tsx b/extensions/mise/src/outdated-tools.test.tsx new file mode 100644 index 00000000000..4c77e3e9584 --- /dev/null +++ b/extensions/mise/src/outdated-tools.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { MenuBarExtra, mocks } from "../test/raycast-api"; +import { showFailureToast, useCachedPromiseFixtures } from "../test/raycast-utils"; +import { flush, render } from "../test/render"; +import { listOutdated, parseOutdated } from "./mise/outdated"; +import outdatedFixture from "./mise/fixtures/outdated.json"; +import Command from "./outdated-tools"; + +describe("Tools Menu Bar", () => { + it("shows the count and hands each tool to Show Outdated Tools", async () => { + useCachedPromiseFixtures.set(listOutdated, parseOutdated(outdatedFixture)); + const { root } = await render(); + const menu = root.findByType(MenuBarExtra); + expect(menu.props).toMatchObject({ title: "4", tooltip: "mise: 4 outdated tools" }); + + const gh = menu.findAllByType(MenuBarExtra.Item).find((n) => n.props.title === "gh"); + expect(gh?.props.subtitle).toBe("2.100.0 → 2.101.0"); + await flush(() => gh?.props.onAction()); + expect(mocks.launchCommand).toHaveBeenCalledWith({ + name: "show-outdated", + type: "userInitiated", + context: { upgrade: "gh" }, + }); + }); + + it("shows a failure toast when Show Outdated Tools cannot be launched", async () => { + useCachedPromiseFixtures.set(listOutdated, parseOutdated(outdatedFixture)); + const error = new Error("Command is disabled"); + mocks.launchCommand.mockRejectedValueOnce(error); + const { root } = await render(); + const open = root + .findByType(MenuBarExtra) + .findAllByType(MenuBarExtra.Item) + .find((n) => n.props.title === "Open Outdated Tools"); + await flush(() => open?.props.onAction()); + expect(showFailureToast).toHaveBeenCalledWith(error, { title: "Could not open Show Outdated Tools" }); + }); + + it("shows the failure with a retry when mise outdated fails, not an up-to-date menu", async () => { + useCachedPromiseFixtures.fail(listOutdated, new Error("GitHub rate limit exceeded")); + const { root } = await render(); + const menu = root.findByType(MenuBarExtra); + expect(menu.props).toMatchObject({ title: undefined, tooltip: "mise: could not check outdated tools" }); + const titles = menu.findAllByType(MenuBarExtra.Item).map((n) => n.props.title); + expect(titles).not.toContain("All tools up to date"); + const failure = menu + .findAllByType(MenuBarExtra.Item) + .find((n) => n.props.title === "Couldn't check outdated tools"); + expect(failure?.props.subtitle).toBe("GitHub rate limit exceeded"); + await flush(() => failure?.props.onAction()); + expect(useCachedPromiseFixtures.revalidateOf(listOutdated)).toHaveBeenCalled(); + }); + + it("offers the extension preferences when the configured mise path does not exist", async () => { + mocks.getPreferenceValues.mockReturnValue({ misePath: "/nonexistent/mise" }); + const { root } = await render(); + const item = root.findByType(MenuBarExtra.Item); + expect(item.props.title).toBe("mise not found"); + expect(item.props.onAction).toBe(mocks.openExtensionPreferences); + }); +}); diff --git a/extensions/mise/src/outdated-tools.tsx b/extensions/mise/src/outdated-tools.tsx new file mode 100644 index 00000000000..b2a68a0aa03 --- /dev/null +++ b/extensions/mise/src/outdated-tools.tsx @@ -0,0 +1,78 @@ +import { Icon, launchCommand, LaunchType, MenuBarExtra, openExtensionPreferences } from "@raycast/api"; +import { showFailureToast, useCachedPromise } from "@raycast/utils"; +import { useState } from "react"; +import type { MiseLocation } from "./mise/locate"; +import { listOutdated } from "./mise/outdated"; +import { outdatedOptions, readPreferences } from "./ui/preferences"; +import { loadErrorInView, loadErrorMessage } from "./ui/LoadError"; +import { useMise } from "./ui/useMise"; + +export default function Command() { + const mise = useMise(); + if (mise.status === "loading") return ; + if (mise.status === "missing") { + return ( + + + + ); + } + return ; +} + +// launchCommand rejects when the user has disabled Show Outdated Tools. +async function openOutdatedTools(upgrade?: string) { + try { + await launchCommand({ + name: "show-outdated", + type: LaunchType.UserInitiated, + context: upgrade ? { upgrade } : undefined, + }); + } catch (error) { + await showFailureToast(error, { title: "Could not open Show Outdated Tools" }); + } +} + +function OutdatedTools({ location }: { location: MiseLocation }) { + const [prefs] = useState(readPreferences); + const outdated = useCachedPromise(listOutdated, [location, outdatedOptions(prefs)], loadErrorInView); + const tools = outdated.data ?? []; + const count = tools.length; + const failure = outdated.data === undefined ? outdated.error : undefined; + + return ( + 0 ? String(count) : undefined} + tooltip={failure ? "mise: could not check outdated tools" : `mise: ${count} outdated tools`} + isLoading={outdated.isLoading} + > + + {failure && ( + + )} + {!failure && count === 0 && } + {tools.map((tool) => ( + openOutdatedTools(tool.name)} + /> + ))} + + + {count > 0 && ( + openOutdatedTools("all")} /> + )} + openOutdatedTools()} /> + + + + ); +} diff --git a/extensions/mise/src/prune.ts b/extensions/mise/src/prune.ts new file mode 100644 index 00000000000..691896e5051 --- /dev/null +++ b/extensions/mise/src/prune.ts @@ -0,0 +1,7 @@ +import { prune } from "./mise/operations"; +import { readPreferences } from "./ui/preferences"; +import { runOperationWithoutView } from "./ui/runOperation"; + +export default function Command() { + return runOperationWithoutView(prune(readPreferences().pruneScope)); +} diff --git a/extensions/mise/src/search-tools.test.tsx b/extensions/mise/src/search-tools.test.tsx new file mode 100644 index 00000000000..9af1bf267b8 --- /dev/null +++ b/extensions/mise/src/search-tools.test.tsx @@ -0,0 +1,295 @@ +import { LaunchType, type LaunchProps } from "@raycast/api"; +import type { ReactTestInstance } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { Action, defaultPreferences, Detail, List, mocks } from "../test/raycast-api"; +import { runAppleScript, useCachedPromiseFixtures } from "../test/raycast-utils"; +import { flush, render } from "../test/render"; +import * as exec from "./mise/exec"; +import { listConfigFiles, parseConfigFiles } from "./mise/config"; +import { listInstalled, parseInstalled } from "./mise/installed"; +import { listRegistry, parseRegistry } from "./mise/registry"; +import configFixture from "./mise/fixtures/config-ls.json"; +import lsFixture from "./mise/fixtures/ls.json"; +import npmFixture from "./mise/fixtures/npm-search.json"; +import registryFixture from "./mise/fixtures/registry.json"; +import Command from "./search-tools"; + +const launch: LaunchProps = { arguments: {}, launchType: LaunchType.UserInitiated }; +const TOOLS_TOML = "/Users/lachlan/.config/mise/conf.d/tools.toml"; + +function sections(root: ReactTestInstance) { + return root.findAllByType(List.Section).map((section) => ({ + title: section.props.title as string, + items: section.findAllByType(List.Item), + })); +} + +async function act(root: ReactTestInstance, tool: string, title: string) { + const item = root.findAllByType(List.Item).find((n) => n.props.title === tool); + const panel = await render(item?.props.actions); + const action = panel.root.findAllByType(Action).find((n) => n.props.title === title); + await flush(() => action?.props.onAction()); +} + +const useGlobally = (root: ReactTestInstance, tool: string) => act(root, tool, "Use Globally"); + +async function actionTitles(root: ReactTestInstance, tool: string) { + const item = root.findAllByType(List.Item).find((n) => n.props.title === tool); + const panel = await render(item?.props.actions); + return panel.root.findAllByType(Action).map((n) => n.props.title as string); +} + +describe("Search Tools", () => { + function setup() { + useCachedPromiseFixtures.set(listRegistry, parseRegistry(registryFixture)); + useCachedPromiseFixtures.set(listInstalled, parseInstalled(lsFixture)); + useCachedPromiseFixtures.set(listConfigFiles, parseConfigFiles(configFixture)); + } + + it("puts backend results for a deeplinked npm:prettier query before the installed tools", async () => { + setup(); + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(npmFixture))); + const { root } = await render(); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("registry.npmjs.org"), expect.anything()); + const [backend, installed] = sections(root); + expect(backend.title).toBe("npm results"); + expect(backend.items[0].props.title).toBe("npm:prettier"); + expect(backend.items[0].props.accessories).toEqual([{ tag: "npm" }, { text: "3.9.7" }]); + expect(installed.title).toBe("Installed"); + expect(root.findByType(List).props.searchText).toBe("npm:prettier"); + }); + + it("runs mise use -g, or --path with the dropdown's file, from Use Globally", async () => { + setup(); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const { root } = await render(); + + await useGlobally(root, "jq"); + expect(runMise).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["use", "-g", "jq@latest"], + expect.anything(), + ); + + const dropdown = (await render(root.findByType(List).props.searchBarAccessory)).root.findByType(List.Dropdown); + expect(dropdown.findAllByType(List.Dropdown.Item).map((n) => n.props.value)).toContain(TOOLS_TOML); + await flush(() => dropdown.props.onChange(TOOLS_TOML)); + await useGlobally(root, "jq"); + expect(runMise).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["use", "--path", TOOLS_TOML, "jq@latest"], + expect.anything(), + ); + expect(mocks.showToast).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "jq added → conf.d/tools.toml" }), + ); + }); + + it("leads an installed row with Remove, which uninstalls every version of an unconfigured tool once confirmed", async () => { + setup(); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const { root } = await render(); + expect((await actionTitles(root, "jq"))[0]).toBe("Remove jq…"); + expect(await actionTitles(root, "jq")).toContain("Use Globally"); + expect((await actionTitles(root, "ripgrep"))[0]).toBe("Use Globally"); + + mocks.confirmAlert.mockResolvedValue(false); + await act(root, "jq", "Remove jq…"); + expect(mocks.confirmAlert).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: "Remove jq?", + message: "Deletes 2 installed versions.", + primaryAction: expect.objectContaining({ title: "Remove", style: "destructive" }), + }), + ); + expect(runMise).not.toHaveBeenCalled(); + + mocks.confirmAlert.mockResolvedValue(true); + await act(root, "jq", "Remove jq…"); + expect(runMise.mock.calls.map((call) => call[1])).toEqual([["uninstall", "--all", "jq"]]); + expect(mocks.showToast).toHaveBeenLastCalledWith(expect.objectContaining({ title: "jq removed" })); + expect(useCachedPromiseFixtures.revalidateOf(listInstalled)).toHaveBeenCalledTimes(1); + }); + + it("removes a configured tool with unuse then uninstall --all", async () => { + setup(); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const { root } = await render(); + mocks.confirmAlert.mockResolvedValue(true); + await act(root, "node", "Remove node…"); + expect(mocks.confirmAlert).toHaveBeenLastCalledWith( + expect.objectContaining({ message: "Removes node from your mise config and deletes 8 installed versions." }), + ); + expect(runMise.mock.calls.map((call) => call[1])).toEqual([ + ["unuse", "node"], + ["uninstall", "--all", "node"], + ]); + }); + + it("hands mise use -g to the chosen terminal from Run in Terminal and closes the window", async () => { + setup(); + mocks.getPreferenceValues.mockReturnValue({ + ...defaultPreferences, + terminalApp: { name: "Ghostty", path: "/Applications/Ghostty.app", bundleId: "com.mitchellh.ghostty" }, + }); + const { root } = await render(); + await act(root, "jq", "Run in Terminal"); + expect(runAppleScript).toHaveBeenCalledTimes(1); + const [source] = runAppleScript.mock.calls[0]; + expect(source).toContain('tell application "Ghostty"'); + expect(source).toContain("/bin/sh use -g jq@latest"); + expect(mocks.closeMainWindow).toHaveBeenCalled(); + }); + + it("debounces the backend request so a query typed within 250ms fetches once, for the final text", async () => { + setup(); + vi.useFakeTimers(); + try { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(npmFixture))); + const { root } = await render(); + const list = root.findByType(List); + await flush(() => list.props.onSearchTextChange("npm:pre")); + await flush(() => vi.advanceTimersByTime(100)); + await flush(() => list.props.onSearchTextChange("npm:prettier")); + await flush(() => vi.advanceTimersByTime(249)); + expect(fetch).not.toHaveBeenCalled(); + await flush(() => vi.advanceTimersByTime(1)); + await flush(); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("text=prettier"), expect.anything()); + expect(sections(root)[0].items[0].props.title).toBe("npm:prettier"); + } finally { + vi.useRealTimers(); + } + }); + + it("drops the previous query's packages when the next backend search fails", async () => { + setup(); + vi.useFakeTimers(); + try { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(npmFixture))) + .mockRejectedValueOnce(new Error("offline")); + const { root } = await render(); + const list = root.findByType(List); + await flush(() => list.props.onSearchTextChange("npm:prettier")); + await flush(() => vi.advanceTimersByTime(250)); + await flush(); + expect(sections(root)[0].title).toBe("npm results"); + + await flush(() => list.props.onSearchTextChange("npm:eslint")); + await flush(() => vi.advanceTimersByTime(250)); + await flush(); + const [backend] = sections(root); + expect(backend.title).toBe("npm results"); + expect(backend.items).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it("shows mise default without storing it when the config files cannot be listed", async () => { + setup(); + useCachedPromiseFixtures.fail(listConfigFiles, new Error("mise config ls failed")); + const { root } = await render(); + const dropdown = (await render(root.findByType(List).props.searchBarAccessory)).root.findByType(List.Dropdown); + expect(dropdown.props.storeValue).toBe(false); + expect(dropdown.findAllByType(List.Dropdown.Item).map((item) => item.props.title)).toEqual(["mise default"]); + }); + + it("filters the registry itself, ranking the typed name first and hiding tools that do not match", async () => { + setup(); + const { root } = await render(); + const list = root.findByType(List); + expect(list.props.filtering).toBe(false); + + await flush(() => list.props.onSearchTextChange("rg")); + const [installed, registry] = sections(root); + expect(installed.items).toEqual([]); + expect(registry.items.map((n) => n.props.title)).toEqual(["ripgrep"]); + + await flush(() => list.props.onSearchTextChange("nod")); + expect(sections(root).map((s) => s.items.map((n) => n.props.title))).toEqual([["node"], []]); + }); + + it("shows the first 100 registry tools with an empty query and says how many there are", async () => { + const extra = Array.from({ length: 120 }, (_, i) => ({ + short: `tool-${String(i).padStart(3, "0")}`, + backends: ["aqua:x/y"], + })); + useCachedPromiseFixtures.set(listRegistry, parseRegistry([...registryFixture, ...extra])); + useCachedPromiseFixtures.set(listInstalled, parseInstalled(lsFixture)); + useCachedPromiseFixtures.set(listConfigFiles, parseConfigFiles(configFixture)); + const { root } = await render(); + const [installed, registry] = sections(root); + + expect(installed.items.map((n) => n.props.title)).toEqual(["jq", "node"]); + expect(registry.items).toHaveLength(100); + expect(registry.items[99].props.title).toBe("tool-096"); + expect(root.findAllByType(List.Section)[1].props.subtitle).toBe("100 of 123"); + + await flush(() => root.findByType(List).props.onSearchTextChange("tool-119")); + expect(sections(root)[1].items.map((n) => n.props.title)).toEqual(["tool-119"]); + }); + + it("lists installed registry tools under Installed with their versions, leaving backend specs untagged", async () => { + setup(); + const { root } = await render(); + const [installed, registry] = sections(root); + + expect(installed.title).toBe("Installed"); + expect(installed.items.map((n) => n.props.title)).toEqual(["jq", "node"]); + expect(installed.items[0].props.accessories).toEqual([ + { tag: "aqua" }, + { text: "1.8.1, 1.8.2", icon: "CheckCircle" }, + ]); + expect(registry.title).toBe("Registry"); + expect(root.findAllByType(List.Section)[1].props.subtitle).toBe("3"); + expect(registry.items.map((n) => n.props.title)).toEqual(["1password", "ripgrep", "ag"]); + expect(root.findAllByType(List.Item).filter((n) => /wrangler/.test(n.props.title))).toEqual([]); + }); + + it("opens with the details panel showing when the showDetails preference is on", async () => { + setup(); + const showingDetail = async () => + (await render()).root.findByType(List).props.isShowingDetail; + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, showDetails: false }); + expect(await showingDetail()).toBe(false); + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, showDetails: true }); + expect(await showingDetail()).toBe(true); + }); + + it("shows the last line of mise's stderr with a Retry that refetches when the registry fails", async () => { + setup(); + const error = new exec.MiseExitError( + ["registry"], + 2, + "error: unexpected argument '--bogus'\n\nUsage: mise registry\n\n", + ); + useCachedPromiseFixtures.fail(listRegistry, error); + const { root } = await render(); + + const empty = root.findByType(List.EmptyView); + expect(empty.props).toMatchObject({ title: "Couldn't load from mise", description: "Usage: mise registry" }); + expect(root.findAllByType(List.Item)).toEqual([]); + + const panel = await render(empty.props.actions); + expect(panel.root.findByType(Action.CopyToClipboard).props.content).toBe(error.message); + await flush(() => + panel.root + .findAllByType(Action) + .find((n) => n.props.title === "Retry") + ?.props.onAction(), + ); + expect(useCachedPromiseFixtures.revalidateOf(listRegistry)).toHaveBeenCalledTimes(1); + }); + + it("renders MissingMise when the configured mise path does not exist", async () => { + mocks.getPreferenceValues.mockReturnValue({ misePath: "/nonexistent/mise" }); + const { root } = await render(); + const detail = root.findByType(Detail); + expect(detail.props.markdown).toContain("curl https://mise.run"); + expect(detail.props.markdown).toContain("/nonexistent/mise"); + }); +}); diff --git a/extensions/mise/src/search-tools.tsx b/extensions/mise/src/search-tools.tsx new file mode 100644 index 00000000000..29a8d16ae84 --- /dev/null +++ b/extensions/mise/src/search-tools.tsx @@ -0,0 +1,265 @@ +import { Action, ActionPanel, Alert, confirmAlert, Icon, type LaunchProps, List } from "@raycast/api"; +import { useCachedPromise } from "@raycast/utils"; +import { useRef, useState } from "react"; +import { parseBackendQuery, searchBackend, type Backend, type BackendResult } from "./mise/backends"; +import type { ConfigFile } from "./mise/config"; +import { listInstalled, type InstalledTool } from "./mise/installed"; +import type { MiseLocation } from "./mise/locate"; +import { backendKind, filterRegistry, listRegistry, type RegistryTool } from "./mise/registry"; +import { remove } from "./mise/operations"; +import { listRemote } from "./mise/remote"; +import { miseCommandLine } from "./terminal/script"; +import { docsUrl } from "./ui/docsUrl"; +import { addGloballyTo, InstallTargetDropdown, UseGloballyIn, useInstallTarget } from "./ui/InstallTarget"; +import { LoadError, loadErrorInView } from "./ui/LoadError"; +import { MissingMise } from "./ui/MissingMise"; +import { readPreferences } from "./ui/preferences"; +import { RunInTerminalAction } from "./ui/runInTerminal"; +import { runOperation } from "./ui/runOperation"; +import { useDebounced } from "./ui/useDebounced"; +import { useMise } from "./ui/useMise"; + +const PLACEHOLDER = "Search tools or npm:, cargo:, gem:…"; +const REGISTRY_PREVIEW = 100; +const MATCH_LIMIT = 50; +const BACKEND_DEBOUNCE_MS = 250; + +export default function Command(props: LaunchProps) { + const mise = useMise(); + if (mise.status === "loading") return ; + if (mise.status === "missing") return ; + return ; +} + +function SearchTools({ location, initialSearchText }: { location: MiseLocation; initialSearchText: string }) { + const registry = useCachedPromise(listRegistry, [location], loadErrorInView); + const installed = useCachedPromise(listInstalled, [location]); + const install = useInstallTarget(location); + const [prefs] = useState(() => readPreferences()); + const [showDetail, setShowDetail] = useState(prefs.showDetails); + const [searchText, setSearchText] = useState(initialSearchText); + + const backendQuery = parseBackendQuery(useDebounced(searchText, BACKEND_DEBOUNCE_MS)); + const abortable = useRef(null); + const backend = useCachedPromise( + (kind: Backend, query: string) => + searchBackend(kind, query, { + fetch, + listRemote: (spec, options) => listRemote(location, spec, options), + signal: abortable.current?.signal, + }), + [backendQuery?.backend ?? "npm", backendQuery?.query ?? ""], + { execute: backendQuery !== undefined, keepPreviousData: true, abortable }, + ); + + const installedByName = new Map((installed.data ?? []).map((tool) => [tool.name, tool])); + const tools = registry.data ?? []; + const query = searchText.trim(); + const matches = query ? filterRegistry(tools, query, MATCH_LIMIT) : tools; + const installedTools = matches.filter((tool) => installedByName.has(tool.short)); + const otherMatches = matches.filter((tool) => !installedByName.has(tool.short)); + const otherTools = query ? otherMatches : otherMatches.slice(0, REGISTRY_PREVIEW); + const registrySubtitle = + otherTools.length < otherMatches.length + ? `${otherTools.length} of ${otherMatches.length}` + : String(otherTools.length); + // keepPreviousData holds the last query's packages through a failure; they don't match this one. + const backendResults = backendQuery && !backend.error ? (backend.data ?? []) : []; + const isLoading = registry.isLoading || installed.isLoading || backend.isLoading; + + const useGlobally = (spec: string, configFile: string) => + addGloballyTo(spec, undefined, { configFile, jobs: prefs.jobs }); + const installGlobally = (spec: string, configFile: string) => + runOperation(location, useGlobally(spec, configFile), installed.revalidate); + const removeTool = async (tool: InstalledTool) => { + const count = tool.versions.length; + const configured = tool.versions.some((v) => v.source); + const versions = `${count} installed version${count === 1 ? "" : "s"}`; + const confirmed = await confirmAlert({ + title: `Remove ${tool.name}?`, + message: configured + ? `Removes ${tool.name} from your mise config and deletes ${versions}.` + : `Deletes ${versions}.`, + primaryAction: { title: "Remove", style: Alert.ActionStyle.Destructive }, + }); + if (confirmed) await runOperation(location, remove(tool.name, { configured }), installed.revalidate); + }; + const itemProps = (spec: string) => ({ + installed: installedByName.get(spec), + showDetail, + configFiles: install.files, + onRemove: removeTool, + onUseGlobally: () => installGlobally(spec, install.target), + onUseGloballyIn: (configFile: string) => installGlobally(spec, configFile), + terminalCommand: miseCommandLine(location, useGlobally(spec, install.target)), + onToggleDetail: () => setShowDetail((value) => !value), + }); + + const renderRegistryTool = (tool: RegistryTool) => ( + + ); + + const renderBackendResult = (result: BackendResult) => ( + + ); + + return ( + + ) + } + searchText={searchText} + onSearchTextChange={setSearchText} + filtering={false} + throttle + > + {registry.error && !registry.data ? ( + + ) : ( + !isLoading && ( + + ) + )} + {backendQuery && ( + + {backendResults.map(renderBackendResult)} + + )} + + {installedTools.map(renderRegistryTool)} + + + {otherTools.map(renderRegistryTool)} + + + ); +} + +function ToolItem({ + title, + description, + tag, + version, + link, + markdown, + installed, + showDetail, + configFiles, + onRemove, + onUseGlobally, + onUseGloballyIn, + terminalCommand, + onToggleDetail, +}: { + title: string; + description: string; + tag: string; + version?: string; + link: { title: string; url: string } | undefined; + markdown: string; + installed: InstalledTool | undefined; + showDetail: boolean; + configFiles: ConfigFile[]; + onRemove: (tool: InstalledTool) => void; + onUseGlobally: () => void; + onUseGloballyIn: (configFile: string) => void; + terminalCommand: string; + onToggleDetail: () => void; +}) { + const accessories: List.Item.Accessory[] = [{ tag }]; + if (version) accessories.push({ text: version }); + if (installed) + accessories.push({ text: installed.versions.map((v) => v.version).join(", "), icon: Icon.CheckCircle }); + + return ( + } + actions={ + + {installed && ( + onRemove(installed)} + /> + )} + + + + {link && } + + + + } + /> + ); +} + +function registryMarkdown(tool: RegistryTool): string { + const list = (items: string[]) => (items.length ? items.map((item) => `\`${item}\``).join(", ") : "—"); + return [ + `# ${tool.short}`, + "", + tool.description, + "", + `**Bins:** ${list(tool.bins)}`, + "", + `**Backends:** ${list(tool.backends)}`, + "", + `**Aliases:** ${list(tool.aliases)}`, + ].join("\n"); +} + +function backendMarkdown(result: BackendResult): string { + return [ + `# ${result.name}`, + "", + result.description || "—", + "", + `**Backend:** \`${result.backend}\``, + "", + `**Version:** ${result.version ?? "—"}`, + ].join("\n"); +} + +function installedMarkdown(installed: InstalledTool | undefined): string { + const versions = installed + ? installed.versions.map((v) => `- ${v.version}${v.active ? " (active)" : ""}`).join("\n") + : "Not installed"; + return ["## Installed", "", versions].join("\n"); +} diff --git a/extensions/mise/src/show-outdated.test.tsx b/extensions/mise/src/show-outdated.test.tsx new file mode 100644 index 00000000000..9b1b492108f --- /dev/null +++ b/extensions/mise/src/show-outdated.test.tsx @@ -0,0 +1,76 @@ +import { LaunchType, type LaunchProps } from "@raycast/api"; +import { describe, expect, it } from "vitest"; +import { Action, Detail, List, mocks } from "../test/raycast-api"; +import { useCachedPromiseFixtures } from "../test/raycast-utils"; +import { flush, render } from "../test/render"; +import { listOutdated, parseOutdated } from "./mise/outdated"; +import outdatedFixture from "./mise/fixtures/outdated.json"; +import inactiveFixture from "./mise/fixtures/outdated-inactive.json"; +import Command from "./show-outdated"; + +const launch: LaunchProps = { arguments: {}, launchType: LaunchType.UserInitiated }; + +describe("Show Outdated Tools", () => { + it("lists each outdated tool as current → latest and writes the count as the command subtitle", async () => { + useCachedPromiseFixtures.set(listOutdated, parseOutdated(outdatedFixture)); + const { root } = await render(); + const items = root.findAllByType(List.Item); + + expect(items.map((n) => [n.props.title, n.props.subtitle])).toEqual([ + ["gh", "2.100.0 → 2.101.0"], + ["npm:wrangler", "4.131.1 → 4.131.2"], + ["opencode", "1.18.30 → 1.18.31"], + ["usage", "6.9.0 → 6.9.1"], + ]); + expect(items[0].props.accessories).toEqual([{ tag: "config.local.toml" }]); + expect(mocks.updateCommandMetadata).toHaveBeenCalledWith({ subtitle: "4 outdated" }); + }); + + it("reports All up to date when nothing is outdated", async () => { + useCachedPromiseFixtures.set(listOutdated, []); + const { root } = await render(); + expect(root.findAllByType(List.Item)).toEqual([]); + expect(root.findByType(List.EmptyView).props.title).toBe("All tools up to date"); + expect(mocks.updateCommandMetadata).toHaveBeenCalledWith({ subtitle: "All up to date" }); + }); + + it("shows the requested version only when it is pinned, and nothing for an inactive install", async () => { + const [gh] = parseOutdated(outdatedFixture); + const [inactive] = parseOutdated(inactiveFixture); + useCachedPromiseFixtures.set(listOutdated, [gh, { ...gh, name: "node", requested: "lts" }, inactive]); + const { root } = await render(); + const [latest, pinned, unconfigured] = root.findAllByType(List.Item); + + expect(latest.props.accessories).toEqual([{ tag: "config.local.toml" }]); + expect(pinned.props.accessories).toEqual([ + { text: "lts", tooltip: "Requested version" }, + { tag: "config.local.toml" }, + ]); + expect(unconfigured.props).toMatchObject({ subtitle: "2.99.0 → 2.101.0", accessories: [] }); + }); + + it("shows the error with a Retry that refetches when mise outdated fails", async () => { + useCachedPromiseFixtures.fail(listOutdated, new Error("boom")); + const { root } = await render(); + + const empty = root.findByType(List.EmptyView); + expect(empty.props).toMatchObject({ title: "Couldn't load from mise", description: "boom" }); + expect(root.findAllByType(List.Item)).toEqual([]); + + const panel = await render(empty.props.actions); + await flush(() => + panel.root + .findAllByType(Action) + .find((n) => n.props.title === "Retry") + ?.props.onAction(), + ); + expect(useCachedPromiseFixtures.revalidateOf(listOutdated)).toHaveBeenCalledTimes(1); + }); + + it("renders MissingMise and a mise not found subtitle when the configured path does not exist", async () => { + mocks.getPreferenceValues.mockReturnValue({ misePath: "/nonexistent/mise" }); + const { root } = await render(); + expect(root.findByType(Detail).props.markdown).toContain("curl https://mise.run"); + expect(mocks.updateCommandMetadata).toHaveBeenCalledWith({ subtitle: "mise not found" }); + }); +}); diff --git a/extensions/mise/src/show-outdated.tsx b/extensions/mise/src/show-outdated.tsx new file mode 100644 index 00000000000..bf0ee090834 --- /dev/null +++ b/extensions/mise/src/show-outdated.tsx @@ -0,0 +1,122 @@ +import { Action, ActionPanel, Icon, Keyboard, launchCommand, type LaunchProps, LaunchType, List } from "@raycast/api"; +import { useCachedPromise } from "@raycast/utils"; +import { basename } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MiseLocation } from "./mise/locate"; +import { upgrade } from "./mise/operations"; +import { listOutdated, outdatedSubtitle, type OutdatedTool } from "./mise/outdated"; +import { miseCommandLine } from "./terminal/script"; +import { LoadError, loadErrorInView } from "./ui/LoadError"; +import { MissingMise } from "./ui/MissingMise"; +import { outdatedOptions, readPreferences, upgradeOptions, type MisePreferences } from "./ui/preferences"; +import { RunInTerminalAction } from "./ui/runInTerminal"; +import { runOperation } from "./ui/runOperation"; +import { useCommandSubtitle } from "./ui/useCommandSubtitle"; +import { useMise } from "./ui/useMise"; + +type LaunchContext = { upgrade?: string }; + +export default function Command(props: LaunchProps<{ launchContext?: LaunchContext }>) { + const mise = useMise(); + useCommandSubtitle(mise.status === "missing" ? "mise not found" : undefined); + if (mise.status === "loading") return ; + if (mise.status === "missing") return ; + return ; +} + +function OutdatedTools({ location, upgradeOnLaunch }: { location: MiseLocation; upgradeOnLaunch?: string }) { + const [prefs] = useState(readPreferences); + const outdated = useCachedPromise(listOutdated, [location, outdatedOptions(prefs)], loadErrorInView); + const launchHandled = useRef(false); + useCommandSubtitle(outdated.data && outdatedSubtitle(outdated.data.length)); + const refresh = useCallback(() => { + outdated.revalidate(); + launchCommand({ name: "outdated-tools", type: LaunchType.Background }).catch(() => undefined); + }, [outdated.revalidate]); + + useEffect(() => { + if (!upgradeOnLaunch || launchHandled.current) return; + launchHandled.current = true; + const op = upgrade(upgradeOnLaunch === "all" ? undefined : upgradeOnLaunch, upgradeOptions(prefs)); + runOperation(location, op, refresh); + }, [location, upgradeOnLaunch, refresh, prefs]); + + const tools = outdated.data ?? []; + + return ( + + {outdated.error && !outdated.data ? ( + + ) : ( + !outdated.isLoading && ( + + ) + )} + {tools.map((tool) => ( + + ))} + + ); +} + +function ToolItem({ + tool, + location, + prefs, + refresh, + revalidate, +}: { + tool: OutdatedTool; + location: MiseLocation; + prefs: MisePreferences; + refresh: () => void; + revalidate: () => void; +}) { + const upgradeOne = upgrade(tool.name, upgradeOptions(prefs)); + const upgradeAll = upgrade(undefined, upgradeOptions(prefs)); + const accessories: List.Item.Accessory[] = []; + if (tool.sourcePath) { + if (tool.requested !== "latest") accessories.push({ text: tool.requested, tooltip: "Requested version" }); + accessories.push({ tag: basename(tool.sourcePath) }); + } + + return ( + + runOperation(location, upgradeOne, refresh)} /> + runOperation(location, upgradeAll, refresh)} + /> + + + {tool.releaseUrl && } + + + + } + /> + ); +} diff --git a/extensions/mise/src/tasks.test.tsx b/extensions/mise/src/tasks.test.tsx new file mode 100644 index 00000000000..5dab4acaf0f --- /dev/null +++ b/extensions/mise/src/tasks.test.tsx @@ -0,0 +1,102 @@ +import type { ReactTestInstance } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { Action, Detail, List, mocks } from "../test/raycast-api"; +import { runAppleScript, useCachedPromiseFixtures } from "../test/raycast-utils"; +import { flush, render } from "../test/render"; +import * as exec from "./mise/exec"; +import tasksFixture from "./mise/fixtures/tasks-ls.json"; +import { listTasks, parseTasks } from "./mise/tasks"; +import Command from "./tasks"; + +async function actions(root: ReactTestInstance, task: string) { + const item = root.findAllByType(List.Item).find((n) => n.props.title === task); + return (await render(item?.props.actions)).root; +} + +describe("Manage Tasks", () => { + it("groups tasks by their namespace with description, aliases and the config file", async () => { + const [apply, status, check] = parseTasks(tasksFixture); + useCachedPromiseFixtures.set(listTasks, [apply, status, { ...check, name: "lint", aliases: ["l"] }]); + const { root } = await render(); + const sections = root.findAllByType(List.Section); + + expect(sections.map((s) => [s.props.title, s.props.subtitle])).toEqual([ + ["dotfiles", "2"], + ["Tasks", "1"], + ]); + expect(sections[0].findAllByType(List.Item).map((n) => n.props.title)).toEqual([ + "dotfiles:apply", + "dotfiles:status", + ]); + expect(sections[1].findAllByType(List.Item)[0].props).toMatchObject({ + title: "lint", + subtitle: "Verify that the Neovim configuration starts cleanly", + keywords: ["l"], + accessories: [{ tag: "tasks.toml" }], + }); + }); + + it("offers Run in Terminal first, then the capture, the task file and the copyable command", async () => { + useCachedPromiseFixtures.set(listTasks, parseTasks(tasksFixture)); + const { root } = await render(); + const panel = await actions(root, "nvim:check"); + + expect(panel.findAllByType(Action).map((n) => n.props.title)).toEqual(["Run in Terminal", "Run and Show Output"]); + expect(panel.findByType(Action.Open).props.target).toBe("/Users/lachlan/.config/mise/conf.d/tasks.toml"); + expect(panel.findByType(Action.CopyToClipboard).props.content).toBe("mise run nvim:check"); + + await flush(() => panel.findAllByType(Action)[0].props.onAction()); + expect(runAppleScript).toHaveBeenCalledWith(expect.stringContaining("/bin/sh run nvim:check")); + }); + + it("runs the task behind a toast and pushes its captured output from Run and Show Output", async () => { + useCachedPromiseFixtures.set(listTasks, parseTasks(tasksFixture)); + const runMise = vi + .spyOn(exec, "runMise") + .mockResolvedValue({ code: 0, stdout: "Neovim OK\n", stderr: "[nvim:check] $ nvim --headless\n" }); + const { root } = await render(); + const panel = await actions(root, "nvim:check"); + + await flush(() => panel.findAllByType(Action)[1].props.onAction()); + expect(runMise).toHaveBeenCalledWith( + expect.objectContaining({ path: "/bin/sh" }), + ["run", "nvim:check"], + expect.anything(), + ); + expect(mocks.showToast).toHaveBeenLastCalledWith(expect.objectContaining({ title: "nvim:check finished" })); + const detail = (await render(mocks.push.mock.calls[0][0])).root.findByType(Detail); + expect(detail.props.navigationTitle).toBe("nvim:check"); + expect(detail.props.markdown).toBe("```\nNeovim OK\n[nvim:check] $ nvim --headless\n\n```"); + }); + + it("shows the empty view when there are no global tasks", async () => { + useCachedPromiseFixtures.set(listTasks, []); + const { root } = await render(); + expect(root.findAllByType(List.Item)).toEqual([]); + expect(root.findByType(List.EmptyView).props.title).toBe("No global tasks"); + }); + + it("shows the error with a Retry that refetches when mise tasks ls fails", async () => { + useCachedPromiseFixtures.fail(listTasks, new Error("boom")); + const { root } = await render(); + + const empty = root.findByType(List.EmptyView); + expect(empty.props).toMatchObject({ title: "Couldn't load from mise", description: "boom" }); + expect(root.findAllByType(List.Item)).toEqual([]); + + const panel = await render(empty.props.actions); + await flush(() => + panel.root + .findAllByType(Action) + .find((n) => n.props.title === "Retry") + ?.props.onAction(), + ); + expect(useCachedPromiseFixtures.revalidateOf(listTasks)).toHaveBeenCalledTimes(1); + }); + + it("renders MissingMise when the configured mise path does not exist", async () => { + mocks.getPreferenceValues.mockReturnValue({ misePath: "/nonexistent/mise" }); + const { root } = await render(); + expect(root.findByType(Detail).props.markdown).toContain("curl https://mise.run"); + }); +}); diff --git a/extensions/mise/src/tasks.tsx b/extensions/mise/src/tasks.tsx new file mode 100644 index 00000000000..ef77d1fce86 --- /dev/null +++ b/extensions/mise/src/tasks.tsx @@ -0,0 +1,93 @@ +import { Action, ActionPanel, Detail, Icon, List, useNavigation } from "@raycast/api"; +import { useCachedPromise } from "@raycast/utils"; +import { basename } from "node:path"; +import type { MiseLocation } from "./mise/locate"; +import { runTask } from "./mise/operations"; +import { listTasks, taskGroup, type Task } from "./mise/tasks"; +import { miseCommandLine } from "./terminal/script"; +import { LoadError, loadErrorInView } from "./ui/LoadError"; +import { MissingMise } from "./ui/MissingMise"; +import { runInTerminal } from "./ui/runInTerminal"; +import { runOperation } from "./ui/runOperation"; +import { useMise } from "./ui/useMise"; + +const PLACEHOLDER = "Search global tasks"; +const UNGROUPED = "Tasks"; + +export default function Command() { + const mise = useMise(); + if (mise.status === "loading") return ; + if (mise.status === "missing") return ; + return ; +} + +function Tasks({ location }: { location: MiseLocation }) { + const tasks = useCachedPromise(listTasks, [location], loadErrorInView); + const { push } = useNavigation(); + + const runAndShow = async (task: Task) => { + const result = await runOperation(location, runTask(task.name)); + if (result) push(); + }; + + const sections = new Map(); + for (const task of tasks.data ?? []) { + const group = taskGroup(task.name) ?? UNGROUPED; + sections.set(group, [...(sections.get(group) ?? []), task]); + } + + return ( + + {tasks.error && !tasks.data ? ( + + ) : ( + !tasks.isLoading && ( + + ) + )} + {[...sections].map(([group, items]) => ( + + {items.map((task) => ( + + runInTerminal(miseCommandLine(location, runTask(task.name)))} + /> + runAndShow(task)} /> + + + + } + /> + ))} + + ))} + + ); +} + +function TaskOutput({ name, output }: { name: string; output: string }) { + return ( + + + + } + /> + ); +} diff --git a/extensions/mise/src/terminal/script.test.ts b/extensions/mise/src/terminal/script.test.ts new file mode 100644 index 00000000000..b970b1f0e7e --- /dev/null +++ b/extensions/mise/src/terminal/script.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { addGlobally, upgrade } from "../mise/operations"; +import { miseCommandLine, shellCommandFor, shellQuote, terminalLaunches } from "./script"; + +const ZSH = "/bin/zsh"; +const LOCATION = { path: "/Users/lachlan/.local/bin/mise" }; + +describe("shellQuote", () => { + it("leaves bare words alone and single-quotes everything else", () => { + expect(shellQuote("jq@latest")).toBe("jq@latest"); + expect(shellQuote("/Users/me/.config/mise/conf.d/tools.toml")).toBe("/Users/me/.config/mise/conf.d/tools.toml"); + expect(shellQuote("a b")).toBe("'a b'"); + expect(shellQuote("$HOME")).toBe("'$HOME'"); + expect(shellQuote("")).toBe("''"); + }); + + it("escapes a single quote by closing, escaping and reopening", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); +}); + +describe("miseCommandLine", () => { + it("joins the mise path and the operation's args, quoting only what needs it", () => { + expect(miseCommandLine(LOCATION, addGlobally("jq"))).toBe("/Users/lachlan/.local/bin/mise use -g jq@latest"); + expect(miseCommandLine(LOCATION, upgrade("jq", { jobs: 4 }))).toBe( + "/Users/lachlan/.local/bin/mise upgrade -j 4 jq", + ); + expect(miseCommandLine({ path: "/Users/me/my mise/mise" }, addGlobally("jq"))).toBe( + "'/Users/me/my mise/mise' use -g jq@latest", + ); + }); +}); + +describe("shellCommandFor", () => { + it("runs the command in a login shell and keeps the window open with another one", () => { + expect(shellCommandFor("mise run build", ZSH)).toBe("/bin/zsh -il -c 'mise run build; exec /bin/zsh -l'"); + }); + + it("escapes single quotes inside the command", () => { + expect(shellCommandFor("echo 'hi there'", ZSH)).toBe("/bin/zsh -il -c 'echo '\\''hi there'\\''; exec /bin/zsh -l'"); + }); +}); + +describe("terminalLaunches", () => { + const command = "/Users/lachlan/.local/bin/mise use -g jq@latest"; + + it("opens a Ghostty window through AppleScript, with open -na as the fallback", () => { + expect(terminalLaunches("com.mitchellh.ghostty", command, ZSH)).toEqual([ + { + kind: "applescript", + source: [ + 'tell application "Ghostty"', + ` new window with configuration {command:"/bin/zsh -il -c '${command}; exec /bin/zsh -l'"}`, + " activate", + "end tell", + ].join("\n"), + }, + { + kind: "open", + args: ["-na", "Ghostty.app", "--args", "-e", ZSH, "-il", "-c", `${command}; exec /bin/zsh -l`], + }, + ]); + }); + + it("creates an iTerm window running the login-shell wrapper", () => { + expect(terminalLaunches("com.googlecode.iterm2", command, ZSH)).toEqual([ + { + kind: "applescript", + source: [ + 'tell application "iTerm"', + ` create window with default profile command "/bin/zsh -il -c '${command}; exec /bin/zsh -l'"`, + " activate", + "end tell", + ].join("\n"), + }, + ]); + }); + + it("hands Terminal.app the bare command, since do script already runs the user's shell", () => { + const expected = [ + { + kind: "applescript", + source: ['tell application "Terminal"', ` do script "${command}"`, " activate", "end tell"].join("\n"), + }, + ]; + expect(terminalLaunches("com.apple.Terminal", command, ZSH)).toEqual(expected); + expect(terminalLaunches(undefined, command, ZSH)).toEqual(expected); + expect(terminalLaunches("com.example.unknown", command, ZSH)).toEqual(expected); + }); + + it("escapes backslashes and double quotes for AppleScript after shell-quoting", () => { + const [ghostty] = terminalLaunches("com.mitchellh.ghostty", `echo "it's" \\n`, ZSH); + expect(ghostty).toMatchObject({ + source: expect.stringContaining(`{command:"/bin/zsh -il -c 'echo \\"it'\\\\''s\\" \\\\n; exec /bin/zsh -l'"}`), + }); + const [terminal] = terminalLaunches("com.apple.Terminal", `echo "a\\b"`, ZSH); + expect(terminal).toMatchObject({ source: expect.stringContaining(`do script "echo \\"a\\\\b\\""`) }); + }); +}); diff --git a/extensions/mise/src/terminal/script.ts b/extensions/mise/src/terminal/script.ts new file mode 100644 index 00000000000..b04f373ffe5 --- /dev/null +++ b/extensions/mise/src/terminal/script.ts @@ -0,0 +1,75 @@ +import type { MiseLocation } from "../mise/locate"; +import type { MiseOperation } from "../mise/operations"; + +export type TerminalLaunch = { kind: "applescript"; source: string } | { kind: "open"; args: string[] }; + +export const GHOSTTY = "com.mitchellh.ghostty"; +export const ITERM = "com.googlecode.iterm2"; +export const TERMINAL = "com.apple.Terminal"; + +const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/; + +export function shellQuote(arg: string): string { + return BARE_WORD.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`; +} + +export function miseCommandLine(location: Pick, op: Pick): string { + return [location.path, ...op.args].map(shellQuote).join(" "); +} + +// An interactive login shell: -l alone skips .zshrc, where PATH additions, mise activate and +// exports such as CARGO_HOME usually live. A second one keeps the window open afterwards. +function keepOpen(command: string, shell: string): string { + return `${command}; exec ${shellQuote(shell)} -l`; +} + +export function shellCommandFor(command: string, shell: string): string { + return `${shellQuote(shell)} -il -c ${shellQuote(keepOpen(command, shell))}`; +} + +export function appleScriptString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function terminalLaunches(bundleId: string | undefined, command: string, shell: string): TerminalLaunch[] { + const wrapped = shellCommandFor(command, shell); + switch (bundleId) { + case GHOSTTY: + return [ + { + kind: "applescript", + source: [ + 'tell application "Ghostty"', + ` new window with configuration {command:${appleScriptString(wrapped)}}`, + " activate", + "end tell", + ].join("\n"), + }, + { kind: "open", args: ["-na", "Ghostty.app", "--args", "-e", shell, "-il", "-c", keepOpen(command, shell)] }, + ]; + case ITERM: + return [ + { + kind: "applescript", + source: [ + 'tell application "iTerm"', + ` create window with default profile command ${appleScriptString(wrapped)}`, + " activate", + "end tell", + ].join("\n"), + }, + ]; + default: + return [ + { + kind: "applescript", + source: [ + 'tell application "Terminal"', + ` do script ${appleScriptString(command)}`, + " activate", + "end tell", + ].join("\n"), + }, + ]; + } +} diff --git a/extensions/mise/src/ui/InstallTarget.tsx b/extensions/mise/src/ui/InstallTarget.tsx new file mode 100644 index 00000000000..6462a0eb1b7 --- /dev/null +++ b/extensions/mise/src/ui/InstallTarget.tsx @@ -0,0 +1,71 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { useCachedPromise } from "@raycast/utils"; +import { useState } from "react"; +import { configFileLabel, listConfigFiles, type ConfigFile } from "../mise/config"; +import type { MiseLocation } from "../mise/locate"; +import { addGlobally, type MiseOperation } from "../mise/operations"; + +export type InstallTarget = { + files: ConfigFile[]; + isLoading: boolean; + failed: boolean; + target: string; + setTarget: (target: string) => void; +}; + +export function useInstallTarget(location: MiseLocation): InstallTarget { + const files = useCachedPromise(listConfigFiles, [location]); + const [target, setTarget] = useState(""); + const failed = files.data === undefined && files.error !== undefined; + return { files: files.data ?? [], isLoading: files.data === undefined && !failed, failed, target, setTarget }; +} + +export function addGloballyTo( + tool: string, + version: string | undefined, + { configFile, jobs }: { configFile: string; jobs?: number }, +): MiseOperation { + const op = addGlobally(tool, version, { configFile: configFile || undefined, jobs }); + return configFile ? { ...op, successTitle: `${op.successTitle} → ${configFileLabel(configFile)}` } : op; +} + +// The dropdown is held back until the config files load because a stored target missing from the +// items would be replaced by the first one. When listing fails it shows mise default without +// touching the stored target, so the next launch can restore it. +export function InstallTargetDropdown({ + files, + failed, + onChange, +}: { + files: ConfigFile[]; + failed: boolean; + onChange: (target: string) => void; +}) { + return ( + + + {files.map((file) => ( + + ))} + + ); +} + +export function UseGloballyIn({ files, onSelect }: { files: ConfigFile[]; onSelect: (configFile: string) => void }) { + return ( + + {files.map((file) => ( + onSelect(file.path)} + /> + ))} + + ); +} diff --git a/extensions/mise/src/ui/LoadError.tsx b/extensions/mise/src/ui/LoadError.tsx new file mode 100644 index 00000000000..54dcebd019b --- /dev/null +++ b/extensions/mise/src/ui/LoadError.tsx @@ -0,0 +1,32 @@ +import { Action, ActionPanel, Icon, Keyboard, List } from "@raycast/api"; +import { MiseExitError } from "../mise/exec"; + +// The view renders LoadError in place of its list, so the hook's default failure toast is off. +export const loadErrorInView = { onError: () => undefined }; + +export function LoadError({ error, retry }: { error: Error; retry: () => void }) { + return ( + + + + + } + /> + ); +} + +export function loadErrorMessage(error: Error): string { + if (!(error instanceof MiseExitError)) return error.message; + const lines = error.stderr.split("\n").map((line) => line.trim()); + return lines.findLast((line) => line !== "") ?? error.message; +} diff --git a/extensions/mise/src/ui/MissingMise.tsx b/extensions/mise/src/ui/MissingMise.tsx new file mode 100644 index 00000000000..f8b2cfc69b3 --- /dev/null +++ b/extensions/mise/src/ui/MissingMise.tsx @@ -0,0 +1,35 @@ +import { Action, ActionPanel, Detail, Icon, openExtensionPreferences } from "@raycast/api"; + +const INSTALL_COMMAND = "curl https://mise.run | sh"; +const DOCS_URL = "https://mise.jdx.dev/getting-started.html"; + +export function MissingMise({ searched }: { searched: string[] }) { + const markdown = [ + "# mise was not found", + "", + "Raycast could not find the `mise` binary. Searched:", + "", + ...searched.map((path) => `- \`${path}\``), + "", + "Install it with:", + "", + "```sh", + INSTALL_COMMAND, + "```", + "", + "If mise is installed somewhere else, set its path in the extension preferences.", + ].join("\n"); + + return ( + + + + + + } + /> + ); +} diff --git a/extensions/mise/src/ui/docsUrl.ts b/extensions/mise/src/ui/docsUrl.ts new file mode 100644 index 00000000000..4573bb19d39 --- /dev/null +++ b/extensions/mise/src/ui/docsUrl.ts @@ -0,0 +1,6 @@ +import type { RegistryTool } from "../mise/registry"; + +export function docsUrl(tool: RegistryTool): string { + const isCore = tool.backends.some((backend) => backend.startsWith("core:")); + return isCore ? `https://mise.jdx.dev/lang/${tool.short}.html` : "https://mise.jdx.dev/registry.html"; +} diff --git a/extensions/mise/src/ui/preferences.test.ts b/extensions/mise/src/ui/preferences.test.ts new file mode 100644 index 00000000000..49f109a037a --- /dev/null +++ b/extensions/mise/src/ui/preferences.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { defaultPreferences, mocks } from "../../test/raycast-api"; +import { outdatedOptions, parseJobs, readPreferences, upgradeOptions } from "./preferences"; + +describe("parseJobs", () => { + it("accepts a positive integer and nothing else", () => { + expect(parseJobs("4")).toBe(4); + expect(parseJobs(" 8 ")).toBe(8); + expect(parseJobs("")).toBeUndefined(); + expect(parseJobs(undefined)).toBeUndefined(); + expect(parseJobs("0")).toBeUndefined(); + expect(parseJobs("-2")).toBeUndefined(); + expect(parseJobs("2.5")).toBeUndefined(); + expect(parseJobs("eight")).toBeUndefined(); + }); +}); + +describe("readPreferences", () => { + it("parses jobs to a number and passes the checkboxes through", () => { + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, jobs: "4", upgradeBump: true }); + const prefs = readPreferences(); + expect(prefs.jobs).toBe(4); + expect(prefs.upgradeBump).toBe(true); + expect(prefs.includeInactive).toBe(false); + expect(prefs.misePath).toBe("/bin/sh"); + }); + + it("maps the upgrade and outdated preferences onto mise flags", () => { + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, includeInactive: true, jobs: "2" }); + const prefs = readPreferences(); + expect(upgradeOptions(prefs)).toEqual({ bump: false, inactive: true, jobs: 2 }); + expect(outdatedOptions(prefs)).toEqual({ bump: false, inactive: true }); + }); +}); diff --git a/extensions/mise/src/ui/preferences.ts b/extensions/mise/src/ui/preferences.ts new file mode 100644 index 00000000000..11b8875692e --- /dev/null +++ b/extensions/mise/src/ui/preferences.ts @@ -0,0 +1,23 @@ +import { getPreferenceValues } from "@raycast/api"; +import type { UpgradeOptions } from "../mise/operations"; +import type { OutdatedOptions } from "../mise/outdated"; + +export type MisePreferences = Omit & { jobs: number | undefined }; + +export function readPreferences(): MisePreferences { + const { jobs, ...rest } = getPreferenceValues(); + return { ...rest, jobs: parseJobs(jobs) }; +} + +export function parseJobs(raw: string | undefined): number | undefined { + const jobs = Number(raw?.trim()); + return Number.isInteger(jobs) && jobs > 0 ? jobs : undefined; +} + +export function upgradeOptions(prefs: MisePreferences): UpgradeOptions { + return { bump: prefs.upgradeBump, inactive: prefs.includeInactive, jobs: prefs.jobs }; +} + +export function outdatedOptions(prefs: MisePreferences): OutdatedOptions { + return { bump: prefs.upgradeBump, inactive: prefs.includeInactive }; +} diff --git a/extensions/mise/src/ui/runInTerminal.tsx b/extensions/mise/src/ui/runInTerminal.tsx new file mode 100644 index 00000000000..8db914704ab --- /dev/null +++ b/extensions/mise/src/ui/runInTerminal.tsx @@ -0,0 +1,48 @@ +import { Action, closeMainWindow, Icon, type Keyboard } from "@raycast/api"; +import { runAppleScript, showFailureToast } from "@raycast/utils"; +import { execFile } from "node:child_process"; +import { terminalLaunches, type TerminalLaunch } from "../terminal/script"; +import { readPreferences } from "./preferences"; + +export function RunInTerminalAction({ + command, + title = "Run in Terminal", + shortcut = { modifiers: ["cmd"], key: "t" }, +}: { + command: string; + title?: string; + shortcut?: Keyboard.Shortcut; +}) { + return runInTerminal(command)} />; +} + +export async function runInTerminal(command: string): Promise { + const { terminalApp } = readPreferences(); + const shell = process.env.SHELL || "/bin/zsh"; + try { + await launchFirstThatWorks(terminalLaunches(terminalApp?.bundleId, command, shell)); + await closeMainWindow(); + } catch (error) { + await showFailureToast(error, { title: `Could not open ${terminalApp?.name ?? "Terminal"}` }); + } +} + +async function launchFirstThatWorks(launches: TerminalLaunch[]): Promise { + let failure: unknown; + for (const launch of launches) { + try { + await run(launch); + return; + } catch (error) { + failure = error; + } + } + throw failure; +} + +function run(launch: TerminalLaunch): Promise { + if (launch.kind === "applescript") return runAppleScript(launch.source).then(() => undefined); + return new Promise((resolve, reject) => { + execFile("open", launch.args, (error) => (error ? reject(error) : resolve())); + }); +} diff --git a/extensions/mise/src/ui/runOperation.test.ts b/extensions/mise/src/ui/runOperation.test.ts new file mode 100644 index 00000000000..497807a6b28 --- /dev/null +++ b/extensions/mise/src/ui/runOperation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { defaultPreferences, mocks } from "../../test/raycast-api"; +import * as exec from "../mise/exec"; +import { addGlobally, remove, upgrade } from "../mise/operations"; +import { runOperation } from "./runOperation"; + +const location = { path: "/bin/sh", env: { PATH: "/bin", MISE_YES: "1", NO_COLOR: "1" } } as const; + +describe("runOperation", () => { + it("reports through toasts and leaves the window alone by default", async () => { + vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const onSuccess = vi.fn(); + expect(await runOperation(location, upgrade("jq"), onSuccess)).toEqual({ code: 0, stdout: "", stderr: "" }); + expect(mocks.closeMainWindow).not.toHaveBeenCalled(); + expect(mocks.showHUD).not.toHaveBeenCalled(); + expect(mocks.showToast).toHaveBeenLastCalledWith( + expect.objectContaining({ style: "success", title: "jq upgraded" }), + ); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("closes the window first and ends with a HUD when closeAfterAction is on", async () => { + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, closeAfterAction: true }); + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + expect(await runOperation(location, upgrade("jq"))).toMatchObject({ code: 0 }); + expect(mocks.closeMainWindow).toHaveBeenCalledWith({ clearRootSearch: false }); + expect(mocks.closeMainWindow.mock.invocationCallOrder[0]).toBeLessThan(runMise.mock.invocationCallOrder[0]); + expect(mocks.showHUD).toHaveBeenCalledWith("jq upgraded"); + expect(mocks.showToast).toHaveBeenCalledOnce(); + expect(mocks.showToast).toHaveBeenCalledWith(expect.objectContaining({ style: "animated" })); + + vi.mocked(runMise).mockResolvedValue({ code: 1, stdout: "", stderr: "mise ERROR no such tool" }); + expect(await runOperation(location, upgrade("jq"))).toMatchObject({ code: 1 }); + expect(mocks.showHUD).toHaveBeenLastCalledWith("Upgrading jq failed"); + }); + + it("runs a multi-step operation in order and reports success once the last step exits 0", async () => { + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + const onSuccess = vi.fn(); + expect(await runOperation(location, remove("jq", { configured: true }), onSuccess)).toMatchObject({ code: 0 }); + expect(runMise.mock.calls.map((call) => call[1])).toEqual([ + ["unuse", "jq"], + ["uninstall", "--all", "jq"], + ]); + expect(mocks.showToast).toHaveBeenLastCalledWith( + expect.objectContaining({ style: "success", title: "jq removed" }), + ); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("stops a multi-step operation at the first non-zero exit and reports that step's stderr", async () => { + const runMise = vi + .spyOn(exec, "runMise") + .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "mise ERROR jq is not in any config" }); + const onSuccess = vi.fn(); + expect(await runOperation(location, remove("jq", { configured: true }), onSuccess)).toMatchObject({ code: 1 }); + expect(runMise).toHaveBeenCalledOnce(); + expect(mocks.showToast).toHaveBeenLastCalledWith( + expect.objectContaining({ + style: "failure", + title: "Removing jq failed", + message: "jq is not in any config", + }), + ); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("keeps the failure toast with Copy Logs when the window stays open", async () => { + vi.spyOn(exec, "runMise").mockResolvedValue({ code: 1, stdout: "", stderr: "first line\nmise ERROR no such tool" }); + expect(await runOperation(location, upgrade("jq"))).toMatchObject({ + code: 1, + stderr: expect.stringContaining("no such tool"), + }); + expect(mocks.showToast).toHaveBeenLastCalledWith( + expect.objectContaining({ + style: "failure", + title: "Upgrading jq failed", + message: "no such tool", + primaryAction: expect.objectContaining({ title: "Copy Logs" }), + }), + ); + }); + + it("puts the operation's retry first on the failure toast and runs it", async () => { + const stderr = "mise ERROR Failed\n help: set `allow_low_downloads = true` on `npm:agent-peek`"; + const runMise = vi.spyOn(exec, "runMise").mockResolvedValue({ code: 1, stdout: "", stderr }); + await runOperation(location, addGlobally("npm:agent-peek")); + const toast = mocks.showToast.mock.lastCall?.[0] as { primaryAction: { onAction: () => Promise } }; + expect(toast).toMatchObject({ + style: "failure", + primaryAction: expect.objectContaining({ title: "Allow Low Downloads and Retry" }), + secondaryAction: expect.objectContaining({ title: "Copy Logs" }), + }); + runMise.mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + await toast.primaryAction.onAction(); + await vi.waitFor(() => + expect(runMise).toHaveBeenLastCalledWith( + location, + ["use", "-g", "--tool-option", "allow_low_downloads=true", "npm:agent-peek@latest"], + expect.anything(), + ), + ); + }); +}); diff --git a/extensions/mise/src/ui/runOperation.ts b/extensions/mise/src/ui/runOperation.ts new file mode 100644 index 00000000000..f5f2c13e0ca --- /dev/null +++ b/extensions/mise/src/ui/runOperation.ts @@ -0,0 +1,99 @@ +import { Clipboard, closeMainWindow, showHUD, showToast, Toast } from "@raycast/api"; +import { setTimeout } from "node:timers/promises"; +import { runMise, type RunResult } from "../mise/exec"; +import type { MiseLocation } from "../mise/locate"; +import { failureMessage, parseProgressLine, type MiseOperation, type Retry } from "../mise/operations"; +import { readPreferences } from "./preferences"; +import { resolveMiseFromRaycast } from "./useMise"; + +let running = false; + +type Outcome = { style: Toast.Style; title: string; message?: string; logs?: string }; + +// Resolves with mise's output once it has run, or undefined when it never started. +export async function runOperation( + location: MiseLocation, + op: MiseOperation, + onSuccess?: () => void, +): Promise { + if (running) { + await showToast({ + style: Toast.Style.Failure, + title: "Already running", + message: "Wait for the current mise command", + }); + return undefined; + } + running = true; + const { closeAfterAction } = readPreferences(); + if (closeAfterAction) await closeMainWindow({ clearRootSearch: false }); + const controller = new AbortController(); + const toast = await showToast({ + style: Toast.Style.Animated, + title: op.title, + primaryAction: { title: "Cancel", onAction: () => controller.abort() }, + }); + let summary: string | undefined; + let outcome: Outcome; + let result: RunResult | undefined; + const runStep = (args: string[]) => + runMise(location, args, { + signal: controller.signal, + onLine: (line) => { + const progress = parseProgressLine(line); + if (!progress) return; + if (progress.kind === "summary") summary = progress.message; + toast.message = progress.message; + }, + }); + try { + result = await runStep(op.args); + for (const args of op.andThen ?? []) { + if (controller.signal.aborted || result.code !== 0) break; + result = await runStep(args); + } + if (controller.signal.aborted) outcome = { style: Toast.Style.Failure, title: "Cancelled", message: op.title }; + else if (result.code === 0) outcome = { style: Toast.Style.Success, title: op.successTitle, message: summary }; + else outcome = failure(op, result.stderr); + } catch (error) { + outcome = failure(op, error instanceof Error ? error.message : String(error)); + } finally { + running = false; + } + await toast.hide(); + if (closeAfterAction) await showHUD(outcome.title); + else { + const retry = outcome.logs === undefined ? undefined : op.retry?.(outcome.logs); + await showToast({ + ...outcome, + primaryAction: retry ? retryAction(location, retry, onSuccess) : copyLogs(outcome.logs), + secondaryAction: retry ? copyLogs(outcome.logs) : undefined, + }); + } + if (outcome.style === Toast.Style.Success) onSuccess?.(); + return result; +} + +function failure(op: MiseOperation, logs: string): Outcome { + return { style: Toast.Style.Failure, title: op.failureTitle, message: failureMessage(logs), logs }; +} + +function retryAction(location: MiseLocation, retry: Retry, onSuccess?: () => void): Toast.ActionOptions { + return { title: retry.title, onAction: () => void runOperation(location, retry.op, onSuccess) }; +} + +function copyLogs(logs: string | undefined): Toast.ActionOptions | undefined { + return logs === undefined ? undefined : { title: "Copy Logs", onAction: () => Clipboard.copy(logs) }; +} + +// A no-view command exits as soon as this returns, taking its toast with it; the Brew extension's +// clean-up holds a failure toast for 3s so its Copy Logs action can be used, and so does this. +export async function runOperationWithoutView(op: MiseOperation, onSuccess?: () => void): Promise { + const mise = await resolveMiseFromRaycast(); + if (!("path" in mise)) { + await showHUD("mise not found — set Mise Path in preferences"); + return; + } + const result = await runOperation(mise, op, onSuccess); + if (result?.code !== 0) await setTimeout(3000); +} diff --git a/extensions/mise/src/ui/useCommandSubtitle.ts b/extensions/mise/src/ui/useCommandSubtitle.ts new file mode 100644 index 00000000000..6b637cbfcd9 --- /dev/null +++ b/extensions/mise/src/ui/useCommandSubtitle.ts @@ -0,0 +1,8 @@ +import { updateCommandMetadata } from "@raycast/api"; +import { useEffect } from "react"; + +export function useCommandSubtitle(subtitle: string | undefined) { + useEffect(() => { + if (subtitle !== undefined) updateCommandMetadata({ subtitle }).catch(() => undefined); + }, [subtitle]); +} diff --git a/extensions/mise/src/ui/useDebounced.ts b/extensions/mise/src/ui/useDebounced.ts new file mode 100644 index 00000000000..0820ca62549 --- /dev/null +++ b/extensions/mise/src/ui/useDebounced.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return debounced; +} diff --git a/extensions/mise/src/ui/useMise.test.ts b/extensions/mise/src/ui/useMise.test.ts new file mode 100644 index 00000000000..18b23b48274 --- /dev/null +++ b/extensions/mise/src/ui/useMise.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultPreferences, mocks } from "../../test/raycast-api"; +import { observeCommands, runMise } from "../mise/exec"; +import { resolveMiseFromRaycast } from "./useMise"; + +describe("resolveMiseFromRaycast", () => { + afterEach(() => observeCommands(undefined)); + + it("logs every mise command line, exit code and duration only while debugLogging is on", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences, debugLogging: true }); + const mise = await resolveMiseFromRaycast(); + if (!("path" in mise)) throw new Error("expected /bin/sh to resolve"); + await runMise(mise, ["-c", "exit 4"]); + expect(log).toHaveBeenCalledWith(expect.stringMatching(/^mise -c exit 4 → exit 4 in \d+ms$/)); + + mocks.getPreferenceValues.mockReturnValue({ ...defaultPreferences }); + await resolveMiseFromRaycast(); + await runMise(mise, ["-c", "exit 0"]); + expect(log).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/mise/src/ui/useMise.ts b/extensions/mise/src/ui/useMise.ts new file mode 100644 index 00000000000..0ac7aa1f566 --- /dev/null +++ b/extensions/mise/src/ui/useMise.ts @@ -0,0 +1,41 @@ +import { Cache } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { observeCommands, type CommandEvent } from "../mise/exec"; +import { resolveMise, type MiseLocation, type MiseNotFound } from "../mise/locate"; +import { readPreferences } from "./preferences"; + +export type MiseFound = { status: "found"; location: MiseLocation }; +export type MiseMissing = { status: "missing"; searched: string[] }; +export type MiseState = { status: "loading" } | MiseFound | MiseMissing; + +const cache = new Cache({ namespace: "mise" }); +const storage = { get: (key: string) => cache.get(key), set: (key: string, value: string) => cache.set(key, value) }; + +function logCommand({ args, code, ms }: CommandEvent) { + console.log(`mise ${args.join(" ")} → exit ${code ?? "signal"} in ${ms}ms`); +} + +export function resolveMiseFromRaycast(): Promise { + const { misePath, debugLogging } = readPreferences(); + observeCommands(debugLogging ? logCommand : undefined); + return resolveMise({ preferredPath: misePath, storage }); +} + +export function useMise(): MiseState { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + resolveMiseFromRaycast().then((result) => { + if (cancelled) return; + setState( + "path" in result ? { status: "found", location: result } : { status: "missing", searched: result.searched }, + ); + }); + return () => { + cancelled = true; + }; + }, []); + + return state; +} diff --git a/extensions/mise/src/upgrade-all.ts b/extensions/mise/src/upgrade-all.ts new file mode 100644 index 00000000000..98145af6eb6 --- /dev/null +++ b/extensions/mise/src/upgrade-all.ts @@ -0,0 +1,12 @@ +import { launchCommand, LaunchType } from "@raycast/api"; +import { upgrade } from "./mise/operations"; +import { readPreferences, upgradeOptions } from "./ui/preferences"; +import { runOperationWithoutView } from "./ui/runOperation"; + +export default function Command() { + const prefs = readPreferences(); + const op = upgrade(undefined, { ...upgradeOptions(prefs), prune: prefs.pruneReplaced }); + return runOperationWithoutView(op, () => { + launchCommand({ name: "outdated-tools", type: LaunchType.Background }).catch(() => undefined); + }); +} diff --git a/extensions/mise/test/raycast-api.tsx b/extensions/mise/test/raycast-api.tsx new file mode 100644 index 00000000000..b50e4cd5d0f --- /dev/null +++ b/extensions/mise/test/raycast-api.tsx @@ -0,0 +1,96 @@ +import { createElement, type ReactNode } from "react"; +import { vi } from "vitest"; + +type Props = Record & { children?: ReactNode }; + +function host(tag: string) { + const Component = ({ children, ...props }: Props) => createElement(tag, props, children); + Component.displayName = tag; + return Component; +} + +export const List = Object.assign(host("list"), { + Item: Object.assign(host("list-item"), { Detail: host("list-item-detail") }), + Section: host("list-section"), + EmptyView: host("list-empty-view"), + Dropdown: Object.assign(host("list-dropdown"), { Item: host("list-dropdown-item") }), +}); + +export const Detail = host("detail"); + +export const ActionPanel = Object.assign(host("action-panel"), { + Section: host("action-panel-section"), + Submenu: host("action-panel-submenu"), +}); + +export const Action = Object.assign(host("action"), { + Push: host("action-push"), + Open: host("action-open"), + OpenInBrowser: host("action-open-in-browser"), + CopyToClipboard: host("action-copy-to-clipboard"), + ShowInFinder: host("action-show-in-finder"), + Style: { Regular: "regular", Destructive: "destructive" }, +}); + +export const MenuBarExtra = Object.assign(host("menu-bar-extra"), { + Item: host("menu-bar-extra-item"), + Section: host("menu-bar-extra-section"), +}); + +export const Icon = new Proxy({} as Record, { get: (_, key) => String(key) }); +export const Toast = { Style: { Animated: "animated", Success: "success", Failure: "failure" } }; +export const Alert = { ActionStyle: { Default: "default", Cancel: "cancel", Destructive: "destructive" } }; +export const LaunchType = { UserInitiated: "userInitiated", Background: "background" }; +export const Keyboard = { Shortcut: { Common: { Refresh: { modifiers: ["cmd"], key: "r" } } } }; + +// resolveMise reads the login shell's environment from this cache; a fresh record keeps every +// component test from spawning a real shell. +const seededCache = { "locate.env": JSON.stringify({ capturedAt: Date.now(), env: { PATH: "/bin" } }) }; + +export class Cache { + private readonly data = new Map(Object.entries(seededCache)); + constructor(readonly options?: { namespace?: string }) {} + get(key: string): string | undefined { + return this.data.get(key); + } + set(key: string, value: string): void { + this.data.set(key, value); + } +} + +// The manifest defaults, with misePath pointing at a binary that exists so resolveMise never probes. +export const defaultPreferences: Record = { + misePath: "/bin/sh", + upgradeBump: false, + includeInactive: false, + uninstallRemovesConfig: false, + jobs: "", + showInactive: true, + closeAfterAction: false, + debugLogging: false, +}; + +export const mocks = { + getPreferenceValues: vi.fn(() => ({ ...defaultPreferences })), + openExtensionPreferences: vi.fn(async () => {}), + showToast: vi.fn(async (options: Record) => ({ ...options, hide: vi.fn(async () => {}) })), + showHUD: vi.fn(async () => {}), + closeMainWindow: vi.fn(async () => {}), + confirmAlert: vi.fn(async () => false), + launchCommand: vi.fn(async () => {}), + updateCommandMetadata: vi.fn(async () => {}), + copy: vi.fn(async () => {}), + push: vi.fn(), + pop: vi.fn(), +}; + +export const getPreferenceValues = mocks.getPreferenceValues; +export const openExtensionPreferences = mocks.openExtensionPreferences; +export const showToast = mocks.showToast; +export const showHUD = mocks.showHUD; +export const closeMainWindow = mocks.closeMainWindow; +export const confirmAlert = mocks.confirmAlert; +export const launchCommand = mocks.launchCommand; +export const updateCommandMetadata = mocks.updateCommandMetadata; +export const Clipboard = { copy: mocks.copy }; +export const useNavigation = () => ({ push: mocks.push, pop: mocks.pop }); diff --git a/extensions/mise/test/raycast-utils.ts b/extensions/mise/test/raycast-utils.ts new file mode 100644 index 00000000000..70fa7d2c688 --- /dev/null +++ b/extensions/mise/test/raycast-utils.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { vi, type Mock } from "vitest"; + +type AnyFn = (...args: never[]) => unknown; +type Fixture = Awaited>; + +const fixtures = new Map(); +const revalidates = new Map void>>(); + +export const runAppleScript = vi.fn<(script: string) => Promise>(async () => ""); + +export const showFailureToast = vi.fn<(error: unknown, options?: { title?: string }) => Promise>(async () => {}); + +export const useCachedPromiseFixtures = { + set(fn: F, data: Fixture) { + fixtures.set(fn, { data }); + }, + fail(fn: AnyFn, error: Error) { + fixtures.set(fn, { error }); + }, + revalidateOf(fn: AnyFn): Mock<() => void> { + let revalidate = revalidates.get(fn); + if (!revalidate) revalidates.set(fn, (revalidate = vi.fn())); + return revalidate; + }, + clear() { + fixtures.clear(); + revalidates.clear(); + }, +}; + +// A function without a registered fixture (the inline backend search in Search Tools) is called for +// real once per argument set, which is how a test reaches the fetch call it has mocked. +export function useCachedPromise( + fn: F, + args: Parameters = [] as unknown as Parameters, + options: { execute?: boolean; keepPreviousData?: boolean } = {}, +) { + const key = JSON.stringify(args); + const fixture = fixtures.get(fn); + const skip = options.execute === false || fixture !== undefined; + const [settled, setSettled] = useState<{ key: string; data?: unknown; error?: Error }>(); + + useEffect(() => { + if (skip) return; + let cancelled = false; + Promise.resolve() + .then(() => fn(...args)) + .then( + (data) => !cancelled && setSettled({ key, data }), + // Like useCachedPromise, a failure keeps the previous data only under keepPreviousData. + (error: Error) => + !cancelled && setSettled((prev) => ({ key, error, data: options.keepPreviousData ? prev?.data : undefined })), + ); + return () => { + cancelled = true; + }; + }, [key, skip]); + + let data: Fixture | undefined; + let error: Error | undefined; + if (options.execute === false) data = undefined; + else if (fixture && "error" in fixture) error = fixture.error; + else if (fixture) data = fixture.data as Fixture; + else if (settled?.key === key) ({ data, error } = settled as { data?: Fixture; error?: Error }); + else if (options.keepPreviousData) data = settled?.data as Fixture | undefined; + + return { data, error, isLoading: false, revalidate: useCachedPromiseFixtures.revalidateOf(fn) }; +} diff --git a/extensions/mise/test/render.tsx b/extensions/mise/test/render.tsx new file mode 100644 index 00000000000..5cb18be41df --- /dev/null +++ b/extensions/mise/test/render.tsx @@ -0,0 +1,18 @@ +import type { ReactElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +export function flush(run?: () => unknown): Promise { + return act(async () => { + await run?.(); + }); +} + +export async function render(element: ReactElement): Promise { + let renderer: ReactTestRenderer | undefined; + await flush(() => { + renderer = create(element); + }); + await flush(); + if (!renderer) throw new Error("render produced no tree"); + return renderer; +} diff --git a/extensions/mise/test/setup.ts b/extensions/mise/test/setup.ts new file mode 100644 index 00000000000..a2d5437c5c2 --- /dev/null +++ b/extensions/mise/test/setup.ts @@ -0,0 +1,13 @@ +import { beforeEach, vi } from "vitest"; +import { useCachedPromiseFixtures } from "./raycast-utils"; + +vi.mock("@raycast/api", () => import("./raycast-api")); +vi.mock("@raycast/utils", () => import("./raycast-utils")); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + useCachedPromiseFixtures.clear(); +}); diff --git a/extensions/mise/tsconfig.json b/extensions/mise/tsconfig.json new file mode 100644 index 00000000000..9a9540ac801 --- /dev/null +++ b/extensions/mise/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src/**/*", "test/**/*", "raycast-env.d.ts"], + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2023", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/extensions/mise/vitest.config.mts b/extensions/mise/vitest.config.mts new file mode 100644 index 00000000000..eff142d1096 --- /dev/null +++ b/extensions/mise/vitest.config.mts @@ -0,0 +1,24 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: "unit", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["**/node_modules/**", "src/**/*.integration.test.ts"], + setupFiles: ["test/setup.ts"], + }, + }, + { + test: { + name: "integration", + include: ["src/**/*.integration.test.ts"], + testTimeout: 90_000, + hookTimeout: 90_000, + }, + }, + ], + }, +});