diff --git a/extensions/goose-2fa/.gitignore b/extensions/goose-2fa/.gitignore new file mode 100644 index 00000000000..381e24ac3ff --- /dev/null +++ b/extensions/goose-2fa/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +raycast-env.d.ts +# 由 scripts/build-helper.mjs 用 swiftc 生成的产物,源码在 swift/SyncHelper.swift +assets/goose-2fa-helper +.DS_Store diff --git a/extensions/goose-2fa/.prettierrc b/extensions/goose-2fa/.prettierrc new file mode 100644 index 00000000000..fc0f5030683 --- /dev/null +++ b/extensions/goose-2fa/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "singleQuote": false +} diff --git a/extensions/goose-2fa/CHANGELOG.md b/extensions/goose-2fa/CHANGELOG.md new file mode 100644 index 00000000000..fd74f730f82 --- /dev/null +++ b/extensions/goose-2fa/CHANGELOG.md @@ -0,0 +1,7 @@ +# Goose 2FA Changelog + +## [Initial Release] - {PR_MERGE_DATE} + +- Search two-factor accounts in a list or grid and copy or paste codes. +- Import and export accounts with a preview; create a shared JSON data source without overwriting existing files. +- Manage accounts, groups, trash, and QR-code scanning from a single command. diff --git a/extensions/goose-2fa/LICENSE b/extensions/goose-2fa/LICENSE new file mode 100644 index 00000000000..d7a12eb958f --- /dev/null +++ b/extensions/goose-2fa/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 eachann_ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/goose-2fa/README.md b/extensions/goose-2fa/README.md new file mode 100644 index 00000000000..d4b068de246 --- /dev/null +++ b/extensions/goose-2fa/README.md @@ -0,0 +1,19 @@ +# Goose 2FA + +A standalone Raycast extension for managing local TOTP and HOTP accounts. Open **Codes** to search accounts, copy codes, or paste them into the previous app. Use the command's action panel to manage accounts, scan QR codes, and import or export a JSON backup. + +Choose a list or grid and configure Return behavior in the extension preferences. The interface is in English; account names may contain Chinese text. + +## Optional shared data source + +Accounts are stored locally in Raycast by default. To use a shared data source, select an existing JSON file in the **Data Source File** preference, or open **Codes → Settings & Data** to create one in a folder you choose. You can select the same iCloud Drive file in Goose 2FA on another Mac. iCloud file synchronization is not instantaneous or a substitute for backups; avoid editing the file simultaneously on multiple devices. If the file is unavailable or has changed unexpectedly, resolve the issue before writing again. + +**The shared file and exported JSON backups contain plaintext two-factor secrets.** Store them only in a location you trust, restrict access to them, and never attach them to a public issue or pull request. Backups are created as new files and refuse to overwrite an existing destination. + +## QR scanner helper + +The QR scanner uses a small Swift helper built locally from `swift/SyncHelper.swift` by `npm run build` on macOS; no additional download is required. + +## Optional file-format compatibility + +The shared JSON format can also be used with the Goose 2FA uTools plugin. diff --git a/extensions/goose-2fa/assets/icon.png b/extensions/goose-2fa/assets/icon.png new file mode 100644 index 00000000000..f0b88979e97 Binary files /dev/null and b/extensions/goose-2fa/assets/icon.png differ diff --git a/extensions/goose-2fa/eslint.config.js b/extensions/goose-2fa/eslint.config.js new file mode 100644 index 00000000000..543274e97ec --- /dev/null +++ b/extensions/goose-2fa/eslint.config.js @@ -0,0 +1,4 @@ +const { defineConfig } = require("eslint/config"); +const raycastConfig = require("@raycast/eslint-config"); + +module.exports = defineConfig([...raycastConfig]); diff --git a/extensions/goose-2fa/metadata/goose-2fa-1.png b/extensions/goose-2fa/metadata/goose-2fa-1.png new file mode 100644 index 00000000000..71a700ba57f Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-1.png differ diff --git a/extensions/goose-2fa/metadata/goose-2fa-2.png b/extensions/goose-2fa/metadata/goose-2fa-2.png new file mode 100644 index 00000000000..c94e7fd5b42 Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-2.png differ diff --git a/extensions/goose-2fa/metadata/goose-2fa-3.png b/extensions/goose-2fa/metadata/goose-2fa-3.png new file mode 100644 index 00000000000..fddeee3bdb2 Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-3.png differ diff --git a/extensions/goose-2fa/metadata/goose-2fa-4.png b/extensions/goose-2fa/metadata/goose-2fa-4.png new file mode 100644 index 00000000000..d589860c98f Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-4.png differ diff --git a/extensions/goose-2fa/metadata/goose-2fa-5.png b/extensions/goose-2fa/metadata/goose-2fa-5.png new file mode 100644 index 00000000000..8458aeaed1c Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-5.png differ diff --git a/extensions/goose-2fa/metadata/goose-2fa-6.png b/extensions/goose-2fa/metadata/goose-2fa-6.png new file mode 100644 index 00000000000..f14dfcb74af Binary files /dev/null and b/extensions/goose-2fa/metadata/goose-2fa-6.png differ diff --git a/extensions/goose-2fa/package-lock.json b/extensions/goose-2fa/package-lock.json new file mode 100644 index 00000000000..995b0f656bb --- /dev/null +++ b/extensions/goose-2fa/package-lock.json @@ -0,0 +1,2888 @@ +{ + "name": "goose-2fa", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "goose-2fa", + "license": "MIT", + "dependencies": { + "@raycast/api": "^2.4.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@types/node": "^22.19.17", + "@types/react": "^19.0.10", + "eslint": "^9.39.5", + "prettier": "^3.9.9", + "typescript": "^5.9.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", + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "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", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@oclif/core": { + "version": "4.14.0", + "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", + "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", + "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", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@oclif/core": "^4.13.3", + "ansis": "^3.17.0", + "fast-levenshtein": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@raycast/api": { + "version": "2.4.1", + "license": "MIT", + "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/api/node_modules/@types/node": { + "version": "22.19.17", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@raycast/api/node_modules/@types/react": { + "version": "19.0.10", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@raycast/eslint-config": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-config/-/eslint-config-2.2.0.tgz", + "integrity": "sha512-uS/UDvM+3HfABgYhLYnIv0y+IVu+rGiZZIMwQsO5EGrcf2/TX/yioTzfezAQqhdszW4ViCuHbv8BddIagDKZvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/js": "^9.39.4", + "@raycast/eslint-plugin": "^2.2.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.7.0", + "typescript-eslint": "^8.62.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "prettier": ">=2", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@raycast/eslint-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@raycast/eslint-plugin/-/eslint-plugin-2.2.0.tgz", + "integrity": "sha512-ixO3PoRAEAZZcSQeRPvvDS/vs09D47tXdyXrs7hctoxxPtweXgJ6I24kjCD5GuuuQS5hGTiuL+Uy6hIg6a/a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.62.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@types/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.20.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.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.71.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.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.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.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "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", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "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", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "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", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "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", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "6.0.1", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "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", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "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": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.1.7", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "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.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "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", + "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", + "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", + "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", + "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/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "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/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "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", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/optionator/node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "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.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "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", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "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", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.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", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "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", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "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", + "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", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/extensions/goose-2fa/package.json b/extensions/goose-2fa/package.json new file mode 100644 index 00000000000..d39754d138f --- /dev/null +++ b/extensions/goose-2fa/package.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "goose-2fa", + "title": "Goose 2FA", + "description": "Local TOTP and HOTP accounts with QR screenshot scanning, copy/paste, JSON backups, and an optional shared data file", + "icon": "icon.png", + "author": "eachann_", + "categories": [ + "Security" + ], + "license": "MIT", + "private": false, + "platforms": [ + "macOS" + ], + "commands": [ + { + "name": "codes", + "title": "Codes", + "subtitle": "Goose 2FA", + "description": "Search accounts; press Return to copy or paste codes according to preferences", + "mode": "view", + "keywords": [ + "2fa", + "otp", + "totp", + "hotp", + "goose" + ], + "arguments": [ + { + "name": "query", + "placeholder": "Account name", + "type": "text", + "required": false + } + ] + } + ], + "preferences": [ + { + "name": "viewMode", + "title": "Main View Layout", + "description": "Choose a list or grid for the main view.", + "type": "dropdown", + "required": false, + "default": "list", + "data": [ + { + "title": "List (arrow-key navigation)", + "value": "list" + }, + { + "title": "Grid", + "value": "grid" + } + ] + }, + { + "name": "enterAction", + "title": "Return Action", + "description": "Choose whether Return copies the code or pastes it into the previous field.", + "type": "dropdown", + "required": false, + "default": "copy", + "data": [ + { + "title": "Copy Code", + "value": "copy" + }, + { + "title": "Paste into Previous Field", + "value": "paste" + } + ] + }, + { + "name": "closeAfterCopy", + "title": "After Copy", + "description": "Close Raycast and return to the previous app after copying a code.", + "label": "Close Raycast and return to desktop", + "type": "checkbox", + "required": false, + "default": true + }, + { + "name": "dataFile", + "title": "Data Source File", + "description": "Choose an existing JSON file here, or save current data as a new sync file in Settings & Data. Select the same iCloud file on both clients to sync. The file contains plaintext 2FA secrets.", + "type": "file", + "required": false + } + ], + "scripts": { + "build": "node scripts/build-helper.mjs && ray build -e dist", + "dev": "node scripts/build-helper.mjs && ray develop", + "lint": "ray lint", + "test": "bun test", + "fix-lint": "ray lint --fix" + }, + "dependencies": { + "@raycast/api": "^2.4.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^2.2.0", + "@types/node": "^22.19.17", + "@types/react": "^19.0.10", + "eslint": "^9.39.5", + "prettier": "^3.9.9", + "typescript": "^5.9.0" + } +} diff --git a/extensions/goose-2fa/scripts/build-helper.mjs b/extensions/goose-2fa/scripts/build-helper.mjs new file mode 100644 index 00000000000..1063f023a15 --- /dev/null +++ b/extensions/goose-2fa/scripts/build-helper.mjs @@ -0,0 +1,26 @@ +// 把 raycast/swift/SyncHelper.swift 编译成 assets/goose-2fa-helper。 +// 放在 `ray build` 之前跑:helper 必须已经存在,ray 才会把它打进扩展的 assets 目录。 +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const extensionRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = path.join(extensionRoot, "swift", "SyncHelper.swift"); +const output = path.join(extensionRoot, "assets", "goose-2fa-helper"); + +if (process.platform !== "darwin") { + console.error("Swift helper 只在 macOS 编译(当前平台:" + process.platform + ")"); + process.exit(1); +} + +mkdirSync(path.dirname(output), { recursive: true }); +execFileSync( + "swiftc", + ["-O", "-o", output, source, "-framework", "Vision", "-framework", "AppKit"], + { stdio: "inherit" }, +); +chmodSync(output, 0o755); + +const size = statSync(output).size; +console.log(`✓ Swift helper → ${path.relative(extensionRoot, output)} (${size} bytes)`); diff --git a/extensions/goose-2fa/src/codes.tsx b/extensions/goose-2fa/src/codes.tsx new file mode 100644 index 00000000000..44587fbac5a --- /dev/null +++ b/extensions/goose-2fa/src/codes.tsx @@ -0,0 +1,258 @@ +import { Action, ActionPanel, Grid, Icon, List, Toast, getPreferenceValues, showToast } from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import type { LaunchProps } from "@raycast/api"; +import { buildGroupTallies, filterByGroup, UNGROUPED_KEY } from "../vendor/lib/groups"; +import { formatCode } from "../vendor/lib/otp"; +import { filterAccounts } from "../vendor/lib/search"; +import type { AccountData, VaultGroup } from "../vendor/lib/types"; +import { commit } from "./lib/commit"; +import { deliverCode } from "./lib/deliver"; +import { useOtpCodes } from "./lib/use-otp"; +import { moveToTrash } from "./lib/vault-ops"; +import { clearSyncLock, refreshVault, useVault } from "./lib/vault-store"; +import ManageData from "./manage-data"; + +const ALL_GROUPS = "__all__"; + +export default function Codes(props: LaunchProps<{ arguments: { query?: string } }>) { + const preferences = getPreferenceValues(); + const vault = useVault(); + const argQuery = props.arguments?.query?.trim() ?? ""; + const [query, setQuery] = useState(argQuery); + const [quickDone, setQuickDone] = useState(false); + const [group, setGroup] = useState(ALL_GROUPS); + const codes = useOtpCodes(vault.accounts); + const tallies = useMemo(() => buildGroupTallies(vault.groups, vault.accounts), [vault.groups, vault.accounts]); + const visible = useMemo(() => { + const byGroup = filterByGroup(vault.accounts, group === ALL_GROUPS ? null : group); + return filterAccounts(byGroup, query); + }, [vault.accounts, group, query]); + + useEffect(() => { + if (quickDone || !argQuery || vault.status === "loading") return; + if (visible.length !== 1) return; + const account = visible[0]; + if (!account) return; + const code = codes[account.id]?.code; + if (!code || !/^\d+$/.test(code)) return; + setQuickDone(true); + void deliverCode(account, code, preferences.enterAction ?? "copy", preferences.closeAfterCopy ?? true); + }, [argQuery, vault.status, visible, codes, quickDone, preferences.enterAction, preferences.closeAfterCopy]); + + const issueActions = ( + + void refreshVault()} /> + {vault.lockHeld !== null && ( + { + const removed = await clearSyncLock(); + await showToast( + removed + ? { style: Toast.Style.Success, title: "Stale lock removed", message: "Please retry your last change." } + : { + style: Toast.Style.Failure, + title: "Lock file not found", + message: "It may have been released by its owner.", + }, + ); + }} + /> + )} + } /> + + ); + + if (preferences.viewMode === "grid") { + return ( + + + {tallies.map((tally) => ( + + ))} + + } + > + + } /> + + } + /> + {vault.message && ( + + + + )} + + {visible.map((account) => { + const code = codes[account.id]?.code ?? "------"; + return ( + + } + /> + ); + })} + + + ); + } + + return ( + + + {tallies.map((tally) => ( + + ))} + + } + > + + } /> + + } + /> + {vault.message ? ( + + + + ) : null} + + {visible.map((account) => ( + + ))} + + + ); +} + +function CodeItem({ + account, + groups, + code, + remaining, + enterAction, + closeAfterCopy, +}: { + account: AccountData; + groups: VaultGroup[]; + code: string; + remaining: number; + enterAction: "copy" | "paste"; + closeAfterCopy: boolean; +}) { + const groupName = account.groupId ? groups.find((candidate) => candidate.id === account.groupId)?.name : undefined; + const subtitle = [account.issuer, groupName, account.remark].filter(Boolean).join(" · "); + const accessories = [ + { text: /^\d+$/.test(code) ? formatCode(code) : "…" }, + account.type === "totp" ? { text: remaining >= 0 ? `${remaining}s` : "" } : { text: `HOTP #${account.counter}` }, + ]; + + return ( + } + /> + ); +} + +function CodeActions({ + account, + code, + enterAction, + closeAfterCopy, +}: { + account: AccountData; + code: string; + enterAction: "copy" | "paste"; + closeAfterCopy: boolean; +}) { + return ( + + void deliverCode(account, code, enterAction, closeAfterCopy)} + /> + void deliverCode(account, code, enterAction === "copy" ? "paste" : "copy", closeAfterCopy)} + /> + void deliverCode(account, code, "type")} + /> + + void commit((snapshot) => moveToTrash(snapshot, account.id), "Moved to Trash")} + /> + } /> + + + ); +} diff --git a/extensions/goose-2fa/src/lib/commit.ts b/extensions/goose-2fa/src/lib/commit.ts new file mode 100644 index 00000000000..358aa41fcf6 --- /dev/null +++ b/extensions/goose-2fa/src/lib/commit.ts @@ -0,0 +1,21 @@ +import { Toast, showToast } from "@raycast/api"; +import type { SyncSnapshot } from "../../vendor/lib/data-transfer"; +import { getVaultState, updateVault } from "./vault-store"; + +/** 统一提交入口:写盘成功才提示成功,失败时把数据源文件的真实原因报给用户。 */ +export async function commit( + updater: (snapshot: SyncSnapshot) => SyncSnapshot, + successMessage: string, +): Promise { + const ok = await updateVault(updater); + if (ok) { + await showToast({ style: Toast.Style.Success, title: successMessage }); + return true; + } + await showToast({ + style: Toast.Style.Failure, + title: "Not Saved", + message: getVaultState().message ?? "Check the data source file and retry.", + }); + return false; +} diff --git a/extensions/goose-2fa/src/lib/deliver.ts b/extensions/goose-2fa/src/lib/deliver.ts new file mode 100644 index 00000000000..f0d281bf753 --- /dev/null +++ b/extensions/goose-2fa/src/lib/deliver.ts @@ -0,0 +1,87 @@ +import { Clipboard, PopToRootType, Toast, closeMainWindow, showHUD, showToast } from "@raycast/api"; +import type { AccountData } from "../../vendor/lib/types"; +import { typeText } from "./helper"; +import { consumeHotp, getVaultState } from "./vault-store"; + +const deliveringHotp = new Set(); + +export type DeliveryMode = "copy" | "paste" | "type"; + +/** 复制 / 粘贴 / 真实输入。HOTP 必须先落盘递增,成功才允许把这个码交出去。 */ +export async function deliverCode( + account: AccountData, + code: string, + mode: DeliveryMode, + closeAfterCopy = true, +): Promise { + if (!/^\d+$/.test(code)) { + await showToast({ style: Toast.Style.Failure, title: "Code Unavailable", message: "Please try again shortly." }); + return false; + } + if (account.type === "hotp") { + const current = getVaultState().accounts.find((item) => item.id === account.id); + if ( + !current || + current.type !== "hotp" || + current.counter !== account.counter || + current.secret !== account.secret || + deliveringHotp.has(account.id) + ) { + await showToast({ style: Toast.Style.Failure, title: "Code Changed", message: "Refresh and try again." }); + return false; + } + deliveringHotp.add(account.id); + try { + if (!(await consumeHotp(account.id))) { + await showToast({ + style: Toast.Style.Failure, + title: "Could Not Save HOTP Counter", + message: "Delivery cancelled; please retry.", + }); + return false; + } + } finally { + deliveringHotp.delete(account.id); + } + } + if (mode === "type") { + const result = await typeText(code); + if (!result.ok) { + await showToast({ style: Toast.Style.Failure, title: "Typing Failed", message: result.message }); + return false; + } + await closeMainWindow(); + return true; + } + if (mode === "paste") { + try { + await Clipboard.paste(code); + } catch (error) { + await showToast({ style: Toast.Style.Failure, title: "Paste Failed", message: String(error) }); + return false; + } + try { + await closeMainWindow({ popToRootType: PopToRootType.Immediate }); + } catch { + await showToast({ style: Toast.Style.Failure, title: "Pasted, but could not close Raycast automatically" }); + } + return true; + } + try { + await Clipboard.copy(code, { concealed: true }); + } catch (error) { + await showToast({ style: Toast.Style.Failure, title: "Copy Failed", message: String(error) }); + return false; + } + const label = account.note || account.issuer || account.name; + if (closeAfterCopy) { + try { + await showHUD(`Copied ${label} code`, { popToRootType: PopToRootType.Immediate }); + } catch { + await showToast({ style: Toast.Style.Failure, title: "Copied, but could not close Raycast automatically" }); + } + } else { + await showToast({ style: Toast.Style.Success, title: `Copied ${label} code` }); + } + return true; +} diff --git a/extensions/goose-2fa/src/lib/helper.ts b/extensions/goose-2fa/src/lib/helper.ts new file mode 100644 index 00000000000..ae2b7d7d8bc --- /dev/null +++ b/extensions/goose-2fa/src/lib/helper.ts @@ -0,0 +1,45 @@ +import { environment } from "@raycast/api"; +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const HELPER_NAME = "goose-2fa-helper"; + +export function helperPath(): string { + return path.join(environment.assetsPath, HELPER_NAME); +} + +/** 真实键盘输入到最前台应用;缺少辅助功能权限时 helper 会返回明确原因。 */ +export async function typeText(text: string): Promise<{ ok: true } | { ok: false; message: string }> { + if (!text) return { ok: false, message: "No code available to type." }; + try { + await execFileAsync(helperPath(), ["type", text]); + return { ok: true }; + } catch (error) { + const stderr = (error as { stderr?: string }).stderr?.trim(); + return { ok: false, message: stderr || "Typing failed: grant Raycast Accessibility permission." }; + } +} + +/** 用 Vision 识别图片里的二维码/条码,逐行返回 payload。 */ +export async function detectBarcodes(imagePath: string): Promise { + const { stdout } = await execFileAsync(helperPath(), ["qr", imagePath], { maxBuffer: 1024 * 1024 }); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +/** 交互式截屏到临时文件;用户按 ESC 取消时返回 null。 */ +export async function captureScreenToTempFile(): Promise { + const target = path.join(tmpdir(), `goose-2fa-scan-${process.pid}-${Date.now()}.png`); + try { + await execFileAsync("/usr/sbin/screencapture", ["-x", "-i", target]); + } catch { + return null; + } + return existsSync(target) ? target : null; +} diff --git a/extensions/goose-2fa/src/lib/local-vault.ts b/extensions/goose-2fa/src/lib/local-vault.ts new file mode 100644 index 00000000000..240f9dd3df5 --- /dev/null +++ b/extensions/goose-2fa/src/lib/local-vault.ts @@ -0,0 +1,41 @@ +import { LocalStorage } from "@raycast/api"; +import type { SyncSnapshot } from "../../vendor/lib/data-transfer"; +import { snapshotFromLocal } from "./vault-ops"; + +// 未配置数据源文件时使用的 Raycast 本地库(加密 LocalStorage),键名与 uTools 端一致。 +const KEYS = { + accounts: "goose-2fa-accounts", + groups: "goose-2fa-groups", + trash: "goose-2fa-trash", +} as const; + +export type LocalVaultRead = { status: "ok"; snapshot: SyncSnapshot } | { status: "broken"; keys: string[] }; + +/** 读本地库。坏值不当成空库:原值原样保留,由用户显式重建或改用数据源文件。 */ +export async function readLocalVault(): Promise { + const broken: string[] = []; + const values: { accounts: unknown; groups: unknown; trash: unknown } = { accounts: [], groups: [], trash: [] }; + for (const field of ["accounts", "groups", "trash"] as const) { + const key = KEYS[field]; + const raw = await LocalStorage.getItem(key); + if (raw === undefined || raw === "") { + values[field] = []; + continue; + } + try { + values[field] = JSON.parse(raw) as unknown; + } catch { + broken.push(key); + } + } + if (broken.length > 0) return { status: "broken", keys: broken }; + return { status: "ok", snapshot: snapshotFromLocal(values) }; +} + +export async function writeLocalVault(snapshot: SyncSnapshot): Promise { + await Promise.all([ + LocalStorage.setItem(KEYS.accounts, JSON.stringify(snapshot.accounts)), + LocalStorage.setItem(KEYS.groups, JSON.stringify(snapshot.groups)), + LocalStorage.setItem(KEYS.trash, JSON.stringify(snapshot.trash)), + ]); +} diff --git a/extensions/goose-2fa/src/lib/use-otp.ts b/extensions/goose-2fa/src/lib/use-otp.ts new file mode 100644 index 00000000000..3538e625f59 --- /dev/null +++ b/extensions/goose-2fa/src/lib/use-otp.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { generateHOTP, generateTOTP } from "../../vendor/lib/otp"; +import type { AccountData } from "../../vendor/lib/types"; + +export interface OtpCode { + code: string; + /** TOTP 剩余秒数;HOTP 为 -1(计数器驱动)。 */ + remaining: number; +} + +function useClockSeconds(): number { + const [seconds, setSeconds] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const timer = setInterval(() => setSeconds(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(timer); + }, []); + return seconds; +} + +/** 为列表里所有账户计算动态码;TOTP 每秒跟随时间重算,HOTP 跟随计数器。 */ +export function useOtpCodes(accounts: AccountData[]): Record { + const now = useClockSeconds(); + const [codes, setCodes] = useState>({}); + const signature = accounts + .map((account) => + [account.id, account.type, account.counter, account.period, account.digits, account.algorithm].join(":"), + ) + .join("|"); + + useEffect(() => { + let active = true; + void (async () => { + const next: Record = {}; + for (const account of accounts) { + try { + if (account.type === "hotp") { + next[account.id] = { + code: await generateHOTP(account.secret, account.counter, account.digits, account.algorithm), + remaining: -1, + }; + continue; + } + const period = account.period || 30; + const result = await generateTOTP(account.secret, period, account.digits, account.algorithm); + next[account.id] = { code: result.code, remaining: result.remaining }; + } catch { + next[account.id] = { code: "ERROR", remaining: -1 }; + } + } + if (active) setCodes(next); + })(); + return () => { + active = false; + }; + // signature 覆盖账户集合与计数器变化,now 负责 TOTP 的时间推进 + }, [signature, now]); + + return codes; +} diff --git a/extensions/goose-2fa/src/lib/vault-file.ts b/extensions/goose-2fa/src/lib/vault-file.ts new file mode 100644 index 00000000000..7ed0a8317df --- /dev/null +++ b/extensions/goose-2fa/src/lib/vault-file.ts @@ -0,0 +1,197 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + fsyncSync, + linkSync, + openSync, + readFileSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { parseSyncSnapshot, type SyncSnapshot } from "../../vendor/lib/data-transfer"; +import { + SYNC_LOCK_WAIT_MS, + SYNC_MAX_BYTES, + isSameSyncContent, + normalizeSyncPath, + syncLockPath, +} from "../../vendor/shared/sync-protocol"; + +export type VaultFileRead = + | { status: "ok"; content: string; snapshot: SyncSnapshot; mtimeMs: number; size: number } + | { status: "missing" | "empty" | "invalid" | "unreadable" | "too-large" }; + +export type VaultFileWrite = + | { status: "ok"; mtimeMs: number; size: number } + | { status: "conflict" | "error" } + | { status: "locked"; lockContent: string | null }; + +/** 纯词法规范化(展开 `~`、折叠 `.`/`..`),非数据源文件路径也用它。 */ +export function normalizePath(input: string): string { + return normalizeSyncPath(input, homedir()); +} + +/** 本地敏感备份只新建 0600 文件;路径已存在时拒绝覆盖。 */ +export function writeBackupFile(filePath: string, content: string): void { + writeFileSync(filePath, content, { flag: "wx", mode: 0o600 }); +} + +/** + * 数据源文件路径必须解析到真实文件/真实父目录:同一文件经符号链接别名访问时, + * 不解析就会出现 `/tmp/x.json` 与 `/private/tmp/x.json` 两把锁、两个写入端。 + * 文件不存在时按「真实父目录 + 文件名」解析;父目录也不存在则退回词法路径(写入会因 io 失败)。 + */ +export function resolveVaultPath(input: string): string { + const normalized = normalizePath(input); + if (!normalized) return ""; + try { + if (statSync(normalized).isFile()) return realpathSync.native(normalized); + } catch { + /* 文件不存在或读不到:继续按父目录解析 */ + } + try { + return path.join(realpathSync.native(path.dirname(normalized)), path.basename(normalized)); + } catch { + return normalized; + } +} + +export function lockPathFor(filePath: string): string { + return syncLockPath(resolveVaultPath(filePath)); +} + +/** 读取锁文件原始内容;没有锁文件返回 null。仅用于向用户报告,不用于自动清理。 */ +export function readVaultLock(filePath: string): string | null { + try { + return readFileSync(lockPathFor(filePath), "utf8"); + } catch { + return null; + } +} + +/** 用户显式清理残留锁(确认没有任何一端在写入时才可用)。 */ +export function clearVaultLock(filePath: string): boolean { + try { + unlinkSync(lockPathFor(filePath)); + return true; + } catch { + return false; + } +} + +/** 读取并严格校验数据源文件:读不到、空文件、解析失败都不当作空库。 */ +export function readVaultFile(filePath: string): VaultFileRead { + const target = resolveVaultPath(filePath); + let stats; + try { + stats = statSync(target); + } catch (error) { + return { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" }; + } + if (!stats.isFile()) return { status: "unreadable" }; + if (stats.size > SYNC_MAX_BYTES) return { status: "too-large" }; + let content: string; + try { + content = readFileSync(target, "utf8"); + } catch { + return { status: "unreadable" }; + } + if (!content.trim()) return { status: "empty" }; + const snapshot = parseSyncSnapshot(content); + if (!snapshot) return { status: "invalid" }; + return { status: "ok", content, snapshot, mtimeMs: stats.mtimeMs, size: stats.size }; +} + +/** + * 拿锁:只等 SYNC_LOCK_WAIT_MS,超时即失败。 + * 绝不按 mtime 判「过期」删除锁文件——删掉活锁会让两个写入端同时写同一个文件。 + */ +async function acquireLock(lockPath: string): Promise { + const deadline = Date.now() + SYNC_LOCK_WAIT_MS; + for (;;) { + try { + writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`, { flag: "wx", mode: 0o600 }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") return false; + } + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +/** + * 原子写入数据源文件:覆盖走 temp → fsync → rename;新建走 temp → fsync → link(不覆盖),同一把锁。 + * expectedContent 非 null 时在锁内逐字节比对当前文件内容,不一致即 conflict(mtime/size 不是版本依据)。 + */ +export async function writeVaultFile( + filePath: string, + content: string, + expectedContent: string | null, + createOnly = false, +): Promise { + if (Buffer.byteLength(content) > SYNC_MAX_BYTES) return { status: "error" }; + const target = resolveVaultPath(filePath); + const lockPath = lockPathFor(target); + if (!(await acquireLock(lockPath))) return { status: "locked", lockContent: readVaultLock(target) }; + try { + if (createOnly) { + try { + statSync(target); + return { status: "conflict" }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return { status: "error" }; + } + } + if (expectedContent !== null) { + let current: string | null = null; + try { + current = readFileSync(target, "utf8"); + } catch { + current = null; + } + if (current === null || !isSameSyncContent(current, expectedContent)) return { status: "conflict" }; + } + const directory = path.dirname(target); + const tempPath = path.join(directory, `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`); + try { + const fd = openSync(tempPath, "wx", 0o600); + try { + writeFileSync(fd, content, { encoding: "utf-8" }); + fsyncSync(fd); + } finally { + closeSync(fd); + } + if (createOnly) { + linkSync(tempPath, target); + unlinkSync(tempPath); + } else { + renameSync(tempPath, target); + } + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + /* 临时文件可能未创建成功 */ + } + if (createOnly && (error as NodeJS.ErrnoException).code === "EEXIST") return { status: "conflict" }; + throw error; + } + const written = statSync(target); + return { status: "ok", mtimeMs: written.mtimeMs, size: written.size }; + } catch (error) { + console.error("[goose-2fa] write vault file failed:", error); + return { status: "error" }; + } finally { + try { + unlinkSync(lockPath); + } catch { + /* 锁已被用户显式清理 */ + } + } +} diff --git a/extensions/goose-2fa/src/lib/vault-ops.ts b/extensions/goose-2fa/src/lib/vault-ops.ts new file mode 100644 index 00000000000..7e1d22c66a8 --- /dev/null +++ b/extensions/goose-2fa/src/lib/vault-ops.ts @@ -0,0 +1,176 @@ +import { + deduplicateImports, + exportAsSyncJson, + parseImportBundle, + type SyncSnapshot, +} from "../../vendor/lib/data-transfer"; +import { normalizeStoredAccounts } from "../../vendor/lib/account-validation"; +import { nextGroupOrder, normalizeGroupName } from "../../vendor/lib/groups"; +import type { AccountData, NewAccountInput, VaultGroup } from "../../vendor/lib/types"; +import { keepHotpFloor, pruneExpiredTrash } from "../../vendor/shared/vault-ops"; + +export function normalizeGroups(value: unknown): VaultGroup[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const groups: VaultGroup[] = []; + for (const [index, item] of value.entries()) { + if (!item || typeof item !== "object") continue; + const raw = item as Record; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + const name = typeof raw.name === "string" ? normalizeGroupName(raw.name) : ""; + if (!id || !name || seen.has(id)) continue; + seen.add(id); + groups.push({ + id, + name, + order: Number.isFinite(raw.order) ? Number(raw.order) : index, + createdAt: Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : Date.now() + index, + }); + } + return groups; +} + +/** 从外部快照进入内存:HOTP 只增不减,回收站按 30 天规则清理,需要时回写纠正值。 */ +export function mergeExternalSnapshot( + incoming: SyncSnapshot, + current: AccountData[], +): { snapshot: SyncSnapshot; corrected: boolean } { + const { accounts, raised } = keepHotpFloor(incoming.accounts, current); + const trash = pruneExpiredTrash(incoming.trash); + return { + snapshot: { accounts, groups: incoming.groups, trash }, + corrected: raised || trash.length !== incoming.trash.length, + }; +} + +export function snapshotFromLocal(raw: { accounts: unknown; groups: unknown; trash: unknown }): SyncSnapshot { + const groups = normalizeGroups(raw.groups); + const validGroupIds = new Set(groups.map((group) => group.id)); + return { + accounts: normalizeStoredAccounts(raw.accounts, validGroupIds), + groups, + trash: pruneExpiredTrash(normalizeStoredAccounts(raw.trash, validGroupIds)), + }; +} + +export function createAccount(input: NewAccountInput, groups: VaultGroup[], groupId: string | null): AccountData { + return { + ...input, + id: crypto.randomUUID(), + createdAt: Date.now(), + groupId: groupId && groups.some((group) => group.id === groupId) ? groupId : null, + }; +} + +export function buildGroup(name: string, groups: VaultGroup[]): VaultGroup | null { + const normalized = normalizeGroupName(name); + if (!normalized) return null; + return { + id: crypto.randomUUID(), + name: normalized, + order: nextGroupOrder(groups), + createdAt: Date.now(), + }; +} + +/** 预览/执行导入 otpauth URI、Google 迁移码或 goose-2fa JSON 备份;重复账户只统计不写入。 */ +export function previewImport( + text: string, + current: SyncSnapshot, +): { snapshot: SyncSnapshot; added: number; dupeCount: number } | null { + const bundle = parseImportBundle(text); + if (!bundle) return null; + const groups = [...current.groups]; + for (const group of bundle.groups) { + if (!groups.some((candidate) => candidate.id === group.id)) groups.push(group); + } + const { newAccounts, dupeCount } = deduplicateImports(bundle.accounts, current.accounts); + const created = newAccounts.map((input) => createAccount(input, groups, input.groupId ?? null)); + return { + snapshot: { accounts: [...current.accounts, ...created], groups, trash: current.trash }, + added: created.length, + dupeCount, + }; +} + +export function toBackupJson(snapshot: SyncSnapshot): string { + return exportAsSyncJson(snapshot.accounts, snapshot.groups, snapshot.trash); +} + +export function upsertAccount(accounts: AccountData[], id: string, patch: Partial): AccountData[] { + return accounts.map((account) => (account.id === id ? { ...account, ...patch } : account)); +} + +// ---- 纯变换:命令层只负责把它们交给 updateVault ---- + +export function moveToTrash(snapshot: SyncSnapshot, id: string): SyncSnapshot { + const account = snapshot.accounts.find((candidate) => candidate.id === id); + if (!account) return snapshot; + return { + accounts: snapshot.accounts.filter((candidate) => candidate.id !== id), + groups: snapshot.groups, + trash: [{ ...account, deletedAt: Date.now() }, ...snapshot.trash.filter((candidate) => candidate.id !== id)], + }; +} + +export function restoreFromTrash(snapshot: SyncSnapshot, id: string): SyncSnapshot { + const account = snapshot.trash.find((candidate) => candidate.id === id); + if (!account) return snapshot; + const restored: AccountData = { ...account }; + delete restored.deletedAt; + return { + accounts: [...snapshot.accounts, restored], + groups: snapshot.groups, + trash: snapshot.trash.filter((candidate) => candidate.id !== id), + }; +} + +export function deleteForever(snapshot: SyncSnapshot, id: string): SyncSnapshot { + return { ...snapshot, trash: snapshot.trash.filter((candidate) => candidate.id !== id) }; +} + +export function emptyTrash(snapshot: SyncSnapshot): SyncSnapshot { + return { ...snapshot, trash: [] }; +} + +export function setAccountGroup(snapshot: SyncSnapshot, id: string, groupId: string | null): SyncSnapshot { + const valid = groupId && snapshot.groups.some((group) => group.id === groupId) ? groupId : null; + return { ...snapshot, accounts: upsertAccount(snapshot.accounts, id, { groupId: valid }) }; +} + +export function setAccountText( + snapshot: SyncSnapshot, + id: string, + patch: Pick, "name" | "issuer" | "note" | "remark">, +): SyncSnapshot { + return { ...snapshot, accounts: upsertAccount(snapshot.accounts, id, patch) }; +} + +export function addGroup(snapshot: SyncSnapshot, name: string): { snapshot: SyncSnapshot; group: VaultGroup | null } { + const group = buildGroup(name, snapshot.groups); + if (!group) return { snapshot, group: null }; + return { snapshot: { ...snapshot, groups: [...snapshot.groups, group] }, group }; +} + +export function renameGroup(snapshot: SyncSnapshot, id: string, name: string): SyncSnapshot { + const normalized = normalizeGroupName(name); + if (!normalized) return snapshot; + return { + ...snapshot, + groups: snapshot.groups.map((group) => (group.id === id ? { ...group, name: normalized } : group)), + }; +} + +/** 删除分组:组内账户回到未分组,不连带删除账户。 */ +export function removeGroup(snapshot: SyncSnapshot, id: string): SyncSnapshot { + return { + accounts: snapshot.accounts.map((account) => (account.groupId === id ? { ...account, groupId: null } : account)), + groups: snapshot.groups.filter((group) => group.id !== id), + trash: snapshot.trash, + }; +} + +export function addAccounts(snapshot: SyncSnapshot, inputs: NewAccountInput[], groupId: string | null): SyncSnapshot { + const created = inputs.map((input) => createAccount(input, snapshot.groups, groupId)); + return { ...snapshot, accounts: [...snapshot.accounts, ...created] }; +} diff --git a/extensions/goose-2fa/src/lib/vault-store.ts b/extensions/goose-2fa/src/lib/vault-store.ts new file mode 100644 index 00000000000..039aeca3170 --- /dev/null +++ b/extensions/goose-2fa/src/lib/vault-store.ts @@ -0,0 +1,504 @@ +import { getPreferenceValues, LocalStorage } from "@raycast/api"; +import path from "node:path"; +import { useEffect, useState } from "react"; +import { exportAsSyncJson, type SyncSnapshot } from "../../vendor/lib/data-transfer"; +import type { AccountData, VaultGroup } from "../../vendor/lib/types"; +import { + isSameSyncContent, + isSameSyncStat, + parseSyncLockInfo, + type SyncFileStat, + type SyncLockInfo, +} from "../../vendor/shared/sync-protocol"; +import { readLocalVault, writeLocalVault } from "./local-vault"; +import { + clearVaultLock, + normalizePath, + readVaultFile, + resolveVaultPath, + writeVaultFile, + type VaultFileRead, +} from "./vault-file"; +import { mergeExternalSnapshot } from "./vault-ops"; + +export type VaultSyncStatus = "idle" | "loading" | "writing" | "success" | "error" | "conflict"; + +export interface VaultConflict { + snapshot: SyncSnapshot; + content: string; + stat: SyncFileStat; +} + +const SOURCE_OVERRIDE_KEY = "goose-2fa-data-source-override"; + +/** 界面新建文件时保存路径;用户后来修改扩展文件偏好时让新偏好优先。 */ +export async function selectCreatedSource(filePath: string): Promise { + const preference = getPreferenceValues().dataFile || ""; + await LocalStorage.setItem(SOURCE_OVERRIDE_KEY, JSON.stringify({ filePath, preference })); + await loadVault(); +} + +export async function clearCreatedSource(): Promise { + await LocalStorage.removeItem(SOURCE_OVERRIDE_KEY); + await loadVault(); +} + +export interface VaultState { + accounts: AccountData[]; + groups: VaultGroup[]; + trash: AccountData[]; + /** "" = 未配置数据源文件,此时只用 Raycast 本地库。 */ + filePath: string; + source: "file" | "local"; + status: "loading" | "ready"; + syncStatus: VaultSyncStatus; + /** 需要用户处理的错误(文件缺失、损坏、写入被拒等)。 */ + message: string | null; + notice: string | null; + needsCreate: boolean; + /** 写盘时被锁挡住:锁文件没有按时间自动清理,需用户确认后显式清理。 */ + lockHeld: SyncLockInfo | null; + /** Raycast 本地库有无法解析的值:保留原值、停止写入,等用户显式重建或改用数据源文件。 */ + localBroken: boolean; + conflict: VaultConflict | null; +} + +const fileMissingMessage = () => + "Data source file is missing or empty. No changes were saved to prevent data loss. You can create a new data source file."; +const WATCH_INTERVAL = 3000; + +let state: VaultState = { + accounts: [], + groups: [], + trash: [], + filePath: "", + source: "local", + status: "loading", + syncStatus: "idle", + message: null, + notice: null, + needsCreate: false, + lockHeld: null, + localBroken: false, + conflict: null, +}; + +/** 最近一次已确认的磁盘内容;写前必须与它比对,不同则说明外部改过文件。 */ +let baseline: { content: string; stat: SyncFileStat } | null = null; + +const listeners = new Set<() => void>(); + +function setState(patch: Partial): void { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); +} + +export function getVaultState(): VaultState { + return state; +} + +function messageForRead(read: Exclude): string { + switch (read.status) { + case "missing": + case "empty": + return fileMissingMessage(); + case "invalid": + return "File is not a goose-2fa data source. Current data was preserved and writes are disabled. Check the file path."; + case "too-large": + return "Data source exceeds the 5 MB limit. Reading and writing are disabled."; + default: + return "Cannot read data source. Writes are disabled. Check path permissions."; + } +} + +function useVaultSnapshot(): VaultState { + const [snapshot, setSnapshot] = useState(state); + useEffect(() => { + const listener = () => setSnapshot(state); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, []); + return snapshot; +} + +/** 读取数据源文件并以它为准;未配置路径时使用 Raycast 本地库。 */ +export async function loadVault(): Promise { + const preferences = getPreferenceValues(); + let selected = preferences.dataFile?.trim() || ""; + try { + const override = JSON.parse((await LocalStorage.getItem(SOURCE_OVERRIDE_KEY)) || "null") as { + filePath?: unknown; + preference?: unknown; + } | null; + if (override && override.preference === (preferences.dataFile || "") && typeof override.filePath === "string") + selected = override.filePath; + } catch { + /* 无效的路径偏好不会覆盖已选文件 */ + } + if (selected && (!path.isAbsolute(normalizePath(selected)) || path.extname(selected).toLowerCase() !== ".json")) { + setState({ + status: "ready", + syncStatus: "error", + message: "Data source must be an absolute path to a .json file; existing data was not written.", + needsCreate: false, + }); + return; + } + const filePath = selected ? resolveVaultPath(selected) : ""; + if (!filePath) { + const read = await readLocalVault(); + baseline = null; + if (read.status === "broken") { + setState({ + accounts: state.accounts, + groups: state.groups, + trash: state.trash, + filePath: "", + source: "local", + status: "ready", + syncStatus: "error", + message: + "Raycast Local Vault contains invalid data. Original values were preserved and writes are disabled. Choose a data source file in preferences or explicitly rebuild the vault.", + notice: null, + needsCreate: false, + localBroken: true, + conflict: null, + }); + return; + } + setState({ + ...read.snapshot, + filePath: "", + source: "local", + status: "ready", + syncStatus: "success", + message: null, + notice: "No data source file configured; using Raycast Local Vault.", + needsCreate: false, + localBroken: false, + conflict: null, + }); + return; + } + + const read = readVaultFile(filePath); + if (read.status === "ok") { + const { snapshot, corrected } = mergeExternalSnapshot(read.snapshot, state.accounts); + baseline = { content: read.content, stat: { mtimeMs: read.mtimeMs, size: read.size } }; + setState({ + ...snapshot, + filePath, + source: "file", + status: "ready", + syncStatus: "success", + message: null, + notice: corrected ? "Data source corrected using the 30-day trash rule and HOTP counter floor." : null, + needsCreate: false, + conflict: null, + }); + if (corrected) void persist(snapshot); + return; + } + + // 读不到/解析不了都不当成空库:保留当前数据(或本地库)并停止写入,等用户处理。 + const local = await readLocalVault(); + const fallback = + state.accounts.length > 0 + ? { accounts: state.accounts, groups: state.groups, trash: state.trash } + : local.status === "ok" + ? local.snapshot + : { accounts: [], groups: [], trash: [] }; + const missing = read.status === "missing" || read.status === "empty"; + baseline = null; + setState({ + ...fallback, + filePath, + source: "file", + status: "ready", + syncStatus: "error", + message: messageForRead(read), + notice: null, + needsCreate: missing, + localBroken: local.status === "broken", + conflict: null, + }); +} + +/** 手动重新读取数据源文件(以文件为准)。 */ +export async function refreshVault(): Promise { + if (!state.filePath) { + await loadVault(); + return; + } + const read = readVaultFile(state.filePath); + if (read.status !== "ok") { + setState({ syncStatus: "error", message: messageForRead(read), conflict: null }); + return; + } + const { snapshot, corrected } = mergeExternalSnapshot(read.snapshot, state.accounts); + baseline = { content: read.content, stat: { mtimeMs: read.mtimeMs, size: read.size } }; + setState({ + ...snapshot, + status: "ready", + syncStatus: "success", + message: null, + notice: corrected + ? "Data source corrected using trash rules and the HOTP counter floor." + : "Reloaded from data source file.", + needsCreate: false, + conflict: null, + }); + if (corrected) void persist(snapshot); +} + +/** 轮询数据源文件,自动载入外部改动;自身写入不回环。 */ +async function pollExternal(): Promise { + const path = state.filePath; + if (!path || state.syncStatus === "writing" || state.syncStatus === "conflict") return; + const read = readVaultFile(path); + if (read.status !== "ok") { + if (read.status === "missing" || read.status === "empty") { + const missing = read.status; + if (!state.needsCreate) + setState({ syncStatus: "error", message: messageForRead({ status: missing }), needsCreate: true }); + } + return; + } + if (baseline && isSameSyncStat({ mtimeMs: read.mtimeMs, size: read.size }, baseline.stat)) return; + if (baseline && isSameSyncContent(read.content, baseline.content)) { + baseline = { content: read.content, stat: { mtimeMs: read.mtimeMs, size: read.size } }; + return; + } + const { snapshot, corrected } = mergeExternalSnapshot(read.snapshot, state.accounts); + baseline = { content: read.content, stat: { mtimeMs: read.mtimeMs, size: read.size } }; + setState({ + ...snapshot, + status: "ready", + syncStatus: "success", + message: null, + notice: "Loaded external changes to the data source file.", + needsCreate: false, + conflict: null, + }); + if (corrected) void persist(snapshot); +} + +async function persist( + snapshot: SyncSnapshot, + options: { allowCreate?: boolean; overwrite?: boolean; createOnly?: boolean } = {}, +): Promise { + if (state.syncStatus === "writing") return false; + if (!state.filePath) { + if (state.localBroken && !options.allowCreate) { + setState({ + syncStatus: "error", + message: + "Local Vault contains invalid data; writes are disabled. Explicitly rebuild it or use a data source file.", + notice: null, + }); + return false; + } + setState({ syncStatus: "writing" }); + try { + await writeLocalVault(snapshot); + setState({ ...snapshot, syncStatus: "success", message: null, notice: null, conflict: null, localBroken: false }); + return true; + } catch { + setState({ syncStatus: "error", message: "Could not save the local vault." }); + return false; + } + } + + setState({ syncStatus: "writing", lockHeld: null }); + const fresh = readVaultFile(state.filePath); + if (fresh.status !== "ok") { + const missing = fresh.status === "missing" || fresh.status === "empty"; + if (!missing || !options.allowCreate) { + setState({ + syncStatus: "error", + message: messageForRead(fresh), + notice: null, + needsCreate: missing, + conflict: null, + }); + return false; + } + } else if (!options.overwrite) { + if (!baseline) { + setState({ + syncStatus: "conflict", + message: "Data source has not been verified; writes are disabled. Reload it first.", + notice: null, + conflict: { + snapshot: fresh.snapshot, + content: fresh.content, + stat: { mtimeMs: fresh.mtimeMs, size: fresh.size }, + }, + }); + return false; + } + if (!isSameSyncContent(fresh.content, baseline.content)) { + setState({ + syncStatus: "conflict", + message: "Another app changed the data source. Your changes were not saved. Choose which version to keep.", + notice: null, + conflict: { + snapshot: fresh.snapshot, + content: fresh.content, + stat: { mtimeMs: fresh.mtimeMs, size: fresh.size }, + }, + }); + return false; + } + } + + const serialized = exportAsSyncJson(snapshot.accounts, snapshot.groups, snapshot.trash); + // 版本依据是逐字节内容,不是 mtime/size:锁内比对,外部一改就拒绝。 + const expectedContent = !options.overwrite && baseline ? baseline.content : null; + const result = await writeVaultFile(state.filePath, serialized, expectedContent, options.createOnly); + if (result.status === "ok") { + baseline = { content: serialized, stat: { mtimeMs: result.mtimeMs, size: result.size } }; + setState({ + ...snapshot, + syncStatus: "success", + message: null, + notice: null, + needsCreate: false, + lockHeld: null, + conflict: null, + }); + return true; + } + if (result.status === "conflict") { + const reread = readVaultFile(state.filePath); + if (reread.status === "ok") { + setState({ + syncStatus: "conflict", + message: + "Another app changed the data source before saving. Your changes were not saved. Choose which version to keep.", + conflict: { + snapshot: reread.snapshot, + content: reread.content, + stat: { mtimeMs: reread.mtimeMs, size: reread.size }, + }, + }); + return false; + } + setState({ syncStatus: "error", message: messageForRead(reread), notice: null, conflict: null }); + return false; + } + const lock = result.status === "locked" ? parseSyncLockInfo(result.lockContent) : null; + setState({ + syncStatus: "error", + message: + result.status === "locked" + ? lockMessage(lock) + : "Could not write data source; existing file was not overwritten.", + notice: null, + lockHeld: lock, + conflict: null, + }); + return false; +} + +/** 锁不会按时间自动清理,只能由用户确认后显式清理。 */ +function lockMessage(lock: SyncLockInfo | null): string { + const holder = lock?.pid ? `process ${lock.pid}` : "unknown owner"; + const created = lock?.createdAt ? `, created ${new Date(lock.createdAt).toLocaleString("en-US")}` : ""; + return `Another app is writing the data source (${holder}${created}). Your changes were not saved. Stale locks are never removed automatically: confirm no client is writing before removing the lock and retrying.`; +} + +/** 所有改动的唯一入口:只有写盘成功才更新内存,避免出现没保存的“影子数据”。 */ +export async function updateVault(updater: (snapshot: SyncSnapshot) => SyncSnapshot): Promise { + const next = updater({ accounts: state.accounts, groups: state.groups, trash: state.trash }); + return persist(next); +} + +/** 用户显式确认:在缺失/空路径上新建数据源文件(写入当前数据)。 */ +export async function createDataSource(): Promise { + if (!state.filePath || !state.needsCreate) return false; + return persist( + { accounts: state.accounts, groups: state.groups, trash: state.trash }, + { allowCreate: true, overwrite: true, createOnly: true }, + ); +} + +/** 用户显式清理残留锁文件;不会自动重试写入,避免在用户不知情时覆盖外部改动。 */ +export async function clearSyncLock(): Promise { + if (!state.filePath) return false; + const removed = clearVaultLock(state.filePath); + setState({ lockHeld: null, syncStatus: "idle", ...(removed ? { message: null } : {}) }); + return removed; +} + +/** 用户显式确认:用当前内存数据重建 Raycast 本地库,覆盖无法解析的旧值。 */ +export async function resetLocalVault(): Promise { + if (!state.localBroken || state.filePath) return false; + return persist( + { accounts: state.accounts, groups: state.groups, trash: state.trash }, + { allowCreate: true, overwrite: true }, + ); +} + +/** HOTP:先落盘递增,成功才允许输出该验证码(失败即不发送)。 */ +export async function consumeHotp(id: string): Promise { + const account = state.accounts.find((candidate) => candidate.id === id); + if (!account || account.type !== "hotp") return false; + const accounts = state.accounts.map((candidate) => + candidate.id === id ? { ...candidate, counter: candidate.counter + 1 } : candidate, + ); + return persist({ accounts, groups: state.groups, trash: state.trash }); +} + +/** 冲突处置:file = 以文件为准;local = 以本地为准(仍按文件抬高 HOTP 下限)。 */ +export async function resolveConflict(choice: "file" | "local"): Promise { + const conflict = state.conflict; + if (!conflict) return; + if (choice === "file") { + const { snapshot, corrected } = mergeExternalSnapshot(conflict.snapshot, state.accounts); + baseline = { content: conflict.content, stat: conflict.stat }; + setState({ + ...snapshot, + syncStatus: "success", + message: null, + notice: "Local data replaced with file contents.", + needsCreate: false, + conflict: null, + }); + if (corrected) void persist(snapshot); + return; + } + const merged = { + accounts: mergeExternalSnapshot( + { accounts: conflict.snapshot.accounts, groups: state.groups, trash: state.trash }, + state.accounts, + ).snapshot.accounts, + groups: state.groups, + trash: state.trash, + }; + baseline = { content: conflict.content, stat: conflict.stat }; + setState({ conflict: null, message: null, notice: "Local data will replace the data source file." }); + await persist(merged); +} + +/** 视图存活期间轮询外部改动;命令被卸载即停止。 */ +export function useVaultWatch(): void { + useEffect(() => { + const timer = setInterval(() => { + void pollExternal(); + }, WATCH_INTERVAL); + return () => clearInterval(timer); + }, []); +} + +/** 命令入口统一用它:启动即重读数据源文件,视图存活期间持续观察外部改动。 */ +export function useVault(): VaultState { + const snapshot = useVaultSnapshot(); + useEffect(() => { + void loadVault(); + }, []); + useVaultWatch(); + return snapshot; +} diff --git a/extensions/goose-2fa/src/manage-data.tsx b/extensions/goose-2fa/src/manage-data.tsx new file mode 100644 index 00000000000..3d53f4c9812 --- /dev/null +++ b/extensions/goose-2fa/src/manage-data.tsx @@ -0,0 +1,699 @@ +import { + Action, + ActionPanel, + Clipboard, + Form, + Icon, + List, + Toast, + openExtensionPreferences, + showToast, + useNavigation, +} from "@raycast/api"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; +import { useState } from "react"; +import { normalizeNewAccountInput } from "../vendor/lib/account-validation"; +import { exportAsSyncJson } from "../vendor/lib/data-transfer"; +import type { AccountData, NewAccountInput, VaultGroup } from "../vendor/lib/types"; +import { commit } from "./lib/commit"; +import { normalizePath, resolveVaultPath, writeBackupFile, writeVaultFile } from "./lib/vault-file"; +import { + addAccounts, + addGroup, + deleteForever, + emptyTrash, + moveToTrash, + previewImport, + removeGroup, + renameGroup, + restoreFromTrash, + setAccountGroup, + setAccountText, +} from "./lib/vault-ops"; +import { + clearCreatedSource, + createDataSource, + clearSyncLock, + getVaultState, + refreshVault, + resetLocalVault, + resolveConflict, + selectCreatedSource, + useVault, +} from "./lib/vault-store"; +import ScanQr from "./scan-qr"; + +const statusLabel = { + idle: "Idle", + loading: "Loading", + writing: "Saving", + success: "Up to date", + error: "Error", + conflict: "Conflict", +}; + +export default function ManageData() { + const vault = useVault(); + const { push } = useNavigation(); + + return ( + + + + + + } + /> + + + + } /> + + } + /> + + void refreshVault()} /> + + } + /> + void clearCreatedSource()} + /> + {vault.source === "file" && vault.needsCreate && ( + void createDataSource()} + /> + )} + {vault.localBroken && ( + + void openExtensionPreferences()} + /> + { + const ok = await resetLocalVault(); + await showToast( + ok + ? { style: Toast.Style.Success, title: "Local vault rebuilt" } + : { + style: Toast.Style.Failure, + title: "Local vault not rebuilt", + message: getVaultState().message ?? "Please try again.", + }, + ); + }} + /> + + )} + {vault.lockHeld !== null && ( + + { + const removed = await clearSyncLock(); + await showToast( + removed + ? { + style: Toast.Style.Success, + title: "Stale lock removed", + message: "Please retry your last change.", + } + : { + style: Toast.Style.Failure, + title: "Lock file not found", + message: "It may have been released by its owner.", + }, + ); + }} + /> + + )} + {vault.conflict && ( + + void resolveConflict("file")} + /> + void resolveConflict("local")} + /> + + )} + + push()} /> + + + } + /> + + + + {vault.accounts.map((account) => ( + + ))} + + + + + push()} /> + + } + /> + {vault.groups.map((group) => ( + account.groupId === group.id).length} accounts`} + actions={ + + push()} + /> + void commit((snapshot) => removeGroup(snapshot, group.id), "Group deleted")} + /> + + } + /> + ))} + + + + + void commit((snapshot) => emptyTrash(snapshot), "Trash emptied")} + /> + + } + /> + {vault.trash.map((account) => ( + + void commit((snapshot) => restoreFromTrash(snapshot, account.id), "Account restored")} + /> + void commit((snapshot) => deleteForever(snapshot, account.id), "Permanently deleted")} + /> + + } + /> + ))} + + + + + } /> + + } + /> + + { + const text = await Clipboard.readText(); + if (!text) { + await showToast({ style: Toast.Style.Failure, title: "Clipboard contains no text" }); + return; + } + if (!previewImport(text, getVaultState())) { + await showToast({ style: Toast.Style.Failure, title: "Could not parse clipboard content" }); + return; + } + push(); + }} + /> + + } + /> + + push()} /> + + } + /> + + push()} /> + + } + /> + + + ); +} + +function AccountItem({ + account, + groups, + push, +}: { + account: AccountData; + groups: VaultGroup[]; + push: ReturnType["push"]; +}) { + const groupName = account.groupId ? groups.find((group) => group.id === account.groupId)?.name : undefined; + return ( + + push()} + /> + + void commit((snapshot) => setAccountGroup(snapshot, account.id, null), "Account moved")} + /> + {groups.map((group) => ( + + void commit((snapshot) => setAccountGroup(snapshot, account.id, group.id), "Account moved") + } + /> + ))} + + + void commit((snapshot) => moveToTrash(snapshot, account.id), "Moved to Trash")} + /> + + + } + /> + ); +} + +function AccountForm({ mode, account }: { mode: "create" | "edit"; account?: AccountData }) { + const { pop } = useNavigation(); + return ( +
+ { + if (mode === "create") { + const input = normalizeNewAccountInput({ + name: values.name, + issuer: values.issuer, + secret: values.secret, + type: values.type, + digits: Number(values.digits), + period: Number(values.period || 30), + algorithm: values.algorithm, + note: values.note, + remark: values.remark, + }); + if (!input) { + await showToast({ + style: Toast.Style.Failure, + title: "Invalid account details", + message: "Check the Base32 secret, digits and period.", + }); + return; + } + const groupId = values.group === UNGROUPED ? null : values.group; + const ok = await commit((snapshot) => addAccounts(snapshot, [input], groupId ?? null), "Account added"); + if (ok) pop(); + return; + } + if (!account) return; + const ok = await commit( + (snapshot) => + setAccountText(snapshot, account.id, { + name: values.name || account.name, + issuer: values.issuer ?? account.issuer, + note: values.note, + remark: values.remark, + }), + "Account saved", + ); + if (ok) pop(); + }} + /> + + } + > + + + {mode === "create" && } + {mode === "create" && ( + + + + + )} + {mode === "create" && ( + + + + + )} + {mode === "create" && } + {mode === "create" && ( + + + + + + )} + + + + ); +} + +function GroupForm({ mode, group }: { mode: "create" | "rename"; group?: VaultGroup }) { + const { pop } = useNavigation(); + return ( +
+ { + const name = values.name ?? ""; + const ok = + mode === "create" + ? await commit((snapshot) => addGroup(snapshot, name).snapshot, "Group created") + : await commit((snapshot) => renameGroup(snapshot, group?.id ?? "", name), "Group renamed"); + if (ok) pop(); + }} + /> + + } + > + + + ); +} + +function ImportForm() { + const { push } = useNavigation(); + return ( +
+ { + const target = normalizePath(values.path ?? ""); + if (!target) return; + let text: string; + try { + if (statSync(target).size > 5 * 1024 * 1024) throw new Error("Backup exceeds 5 MB"); + text = readFileSync(target, "utf8"); + } catch { + await showToast({ + style: Toast.Style.Failure, + title: "Could Not Read File", + message: "Check the path, permissions and 5 MB limit.", + }); + return; + } + if (!previewImport(text, getVaultState())) { + await showToast({ style: Toast.Style.Failure, title: "Could not parse backup file" }); + return; + } + push(); + }} + /> + + } + > + + + ); +} + +function ImportPreview({ text }: { text: string }) { + const { pop } = useNavigation(); + const preview = previewImport(text, getVaultState()); + if (!preview) return ; + const current = getVaultState(); + const imported = preview.snapshot.accounts.slice(current.accounts.length); + const groupCount = preview.snapshot.groups.length - current.groups.length; + return ( + + + {imported.map((account: NewAccountInput, index) => ( + + ))} + + + {preview.snapshot.groups.slice(current.groups.length).map((group) => ( + + ))} + + + { + const currentPreview = previewImport(text, getVaultState()); + if (!currentPreview) { + await showToast({ style: Toast.Style.Failure, title: "Could Not Parse Backup" }); + return; + } + const ok = await commit(() => currentPreview.snapshot, `Imported ${currentPreview.added} accounts`); + if (ok) pop(); + }} + /> + + } + /> + + ); +} + +function ExportForm() { + const { pop } = useNavigation(); + const [path, setPath] = useState(`~/Downloads/goose-2fa-backup-${new Date().toISOString().slice(0, 10)}.json`); + return ( +
+ { + const target = normalizePath(path); + if (!target) return; + const snapshot = getVaultState(); + try { + writeBackupFile(target, exportAsSyncJson(snapshot.accounts, snapshot.groups, snapshot.trash)); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Export Failed", + message: error instanceof Error ? error.message : String(error), + }); + return; + } + await showToast({ style: Toast.Style.Success, title: "Full backup exported", message: target }); + pop(); + }} + /> + + } + > + + + + ); +} + +const UNGROUPED = "__ungrouped__"; + +function CreateSourceForm() { + const { pop } = useNavigation(); + return ( +
+ { + const directory = values.directory?.[0]; + const name = values.fileName?.trim() || "goose-2fa.json"; + if (!directory || !path.isAbsolute(directory) || !/^[^/\\\0]+\.json$/i.test(name)) { + await showToast({ style: Toast.Style.Failure, title: "Choose a folder and enter a .json filename" }); + return; + } + try { + if (!statSync(directory).isDirectory()) throw new Error("Folder does not exist"); + const target = resolveVaultPath(path.join(realpathSync.native(directory), name)); + if (getVaultState().localBroken) + throw new Error("Local vault is corrupted; incomplete data cannot be written to a new file"); + const current = getVaultState(); + const result = await writeVaultFile( + target, + exportAsSyncJson(current.accounts, current.groups, current.trash), + null, + true, + ); + if (result.status !== "ok") + throw new Error( + result.status === "conflict" + ? "File already exists; select it in extension preferences" + : "Could not create file; check folder permissions or write lock", + ); + await selectCreatedSource(target); + await showToast({ style: Toast.Style.Success, title: "Data source created", message: target }); + pop(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Creation Failed", + message: error instanceof Error ? error.message : String(error), + }); + } + }} + /> + + } + > + + + + + ); +} + +interface FormValues { + name?: string; + issuer?: string; + secret?: string; + type?: "totp" | "hotp"; + digits?: string; + period?: string; + algorithm?: "SHA-1" | "SHA-256" | "SHA-512"; + note?: string; + remark?: string; + group?: string; + path?: string; +} diff --git a/extensions/goose-2fa/src/scan-qr.tsx b/extensions/goose-2fa/src/scan-qr.tsx new file mode 100644 index 00000000000..3a488172cb2 --- /dev/null +++ b/extensions/goose-2fa/src/scan-qr.tsx @@ -0,0 +1,132 @@ +import { Action, ActionPanel, Icon, List, Toast, popToRoot, showToast } from "@raycast/api"; +import { unlinkSync } from "node:fs"; +import { useEffect, useState } from "react"; +import { deduplicateImports, parseImportBundle } from "../vendor/lib/data-transfer"; +import type { NewAccountInput } from "../vendor/lib/types"; +import { commit } from "./lib/commit"; +import { captureScreenToTempFile, detectBarcodes } from "./lib/helper"; +import { addAccounts } from "./lib/vault-ops"; +import { getVaultState, useVault } from "./lib/vault-store"; + +interface ScannedEntry { + input: NewAccountInput; + payload: string; +} + +export default function ScanQr() { + const vault = useVault(); + const [entries, setEntries] = useState([]); + const [status, setStatus] = useState<"scanning" | "ready" | "empty">("scanning"); + const newInputs = new Set( + deduplicateImports( + entries.map((entry) => entry.input), + vault.accounts, + ).newAccounts, + ); + + useEffect(() => { + let active = true; + void (async () => { + const imagePath = await captureScreenToTempFile(); + if (!active) return; + if (!imagePath) { + await showToast({ style: Toast.Style.Failure, title: "Screenshot cancelled" }); + await popToRoot(); + return; + } + try { + const payloads = await detectBarcodes(imagePath); + const found: ScannedEntry[] = []; + for (const payload of payloads) { + const bundle = parseImportBundle(payload); + for (const input of bundle?.accounts ?? []) found.push({ input, payload }); + } + if (!active) return; + setEntries(found); + setStatus(found.length > 0 ? "ready" : "empty"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Scan Failed", + message: error instanceof Error ? error.message : String(error), + }); + setStatus("empty"); + } finally { + try { + unlinkSync(imagePath); + } catch { + /* 临时文件可能已被系统清理 */ + } + } + })(); + return () => { + active = false; + }; + }, []); + + const importEntries = async (chosen: ScannedEntry[]) => { + if (chosen.length === 0) return; + const inputs = chosen.map((entry) => entry.input); + const preview = deduplicateImports(inputs, getVaultState().accounts); + if (!preview.newAccounts.length) { + await showToast({ + style: Toast.Style.Success, + title: "Accounts Already Exist", + message: "No duplicate accounts were imported.", + }); + return; + } + const ok = await commit( + (snapshot) => addAccounts(snapshot, deduplicateImports(inputs, snapshot.accounts).newAccounts, null), + `Imported ${preview.newAccounts.length} accounts (${preview.dupeCount} skipped)`, + ); + if (ok) await popToRoot(); + }; + + return ( + + {status === "empty" ? ( + + void popToRoot()} /> + + } + /> + ) : null} + {entries.map((entry, index) => ( + + void importEntries([entry])} /> + void importEntries(entries)} /> + + + } + /> + ))} + {status === "ready" ? ( + + void importEntries(entries)} /> + + } + /> + ) : null} + + ); +} diff --git a/extensions/goose-2fa/swift/SyncHelper.swift b/extensions/goose-2fa/swift/SyncHelper.swift new file mode 100644 index 00000000000..c8079cf40e9 --- /dev/null +++ b/extensions/goose-2fa/swift/SyncHelper.swift @@ -0,0 +1,78 @@ +// Goose 2FA 的最小原生 helper:真实键盘输入 + 截图条码识别。 +// 由 raycast/scripts/build-helper.mjs 在 build 阶段用 swiftc 编译, +// 产物是 assets/goose-2fa-helper(单文件二进制),运行时由 Node 侧 execFile 调用。 +// +// 用法: +// goose-2fa-helper type 把文本作为真实按键输入到最前台应用(需要辅助功能权限) +// goose-2fa-helper qr 识别图片里的 QR/条码,逐行输出 payload + +import AppKit +import ApplicationServices +import CoreGraphics +import Foundation +import Vision + +func fail(_ code: Int32, _ message: String) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(code) +} + +/// 用 CGEvent 逐字符输入:与剪贴板无关,不污染剪贴板历史。 +func typeText(_ text: String) { + guard AXIsProcessTrusted() else { + fail(3, "Accessibility permission required: allow Raycast in System Settings → Privacy & Security → Accessibility.") + } + guard let source = CGEventSource(stateID: .combinedSessionState) else { + fail(4, "Could not create keyboard event source.") + } + for character in Array(text.utf16) { + var unit = character + for isDown in [true, false] { + guard let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: isDown) else { + fail(4, "Could not create keyboard event.") + } + event.keyboardSetUnicodeString(stringLength: 1, unicodeString: &unit) + event.post(tap: .cghidEventTap) + } + usleep(1200) + } +} + +/// 用 Vision 识别图片里的条码,逐行输出 payload(otpauth:// 或二维码内容)。 +func detectBarcodes(_ imagePath: String) { + let url = URL(fileURLWithPath: imagePath) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + fail(5, "Could not read image: \(imagePath)") + } + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr, .aztec, .code128] + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + do { + try handler.perform([request]) + } catch { + fail(6, "Barcode scan failed: \(error.localizedDescription)") + } + let observations = request.results ?? [] + for observation in observations { + if let payload = observation.payloadStringValue, !payload.isEmpty { + print(payload) + } + } +} + +let arguments = CommandLine.arguments +guard arguments.count >= 2 else { + fail(2, "Usage: goose-2fa-helper type | qr ") +} + +switch arguments[1] { +case "type": + guard arguments.count >= 3 else { fail(2, "Missing text to type.") } + typeText(arguments[2]) +case "qr": + guard arguments.count >= 3 else { fail(2, "Missing image path.") } + detectBarcodes(arguments[2]) +default: + fail(2, "Unknown subcommand: \(arguments[1])") +} diff --git a/extensions/goose-2fa/tests/vault-file.test.ts b/extensions/goose-2fa/tests/vault-file.test.ts new file mode 100644 index 00000000000..94f5d1e48e9 --- /dev/null +++ b/extensions/goose-2fa/tests/vault-file.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { normalizeSyncPath, syncLockPath } from "../vendor/shared/sync-protocol"; +import { exportAsSyncJson } from "../vendor/lib/data-transfer"; +import { + clearVaultLock, + lockPathFor, + normalizePath, + readVaultFile, + readVaultLock, + resolveVaultPath, + writeBackupFile, + writeVaultFile, +} from "../src/lib/vault-file"; + +let directory: string; +let target: string; + +/** 真实目录(macOS 下 /var 是 /private/var 的符号链接,必须解析后再比较锁名)。 */ +function makeRealDir(prefix: string): string { + return realpathSync.native(mkdtempSync(path.join(tmpdir(), prefix))); +} + +beforeEach(() => { + directory = makeRealDir("goose-2fa-raycast-"); + target = path.join(directory, "sync.json"); +}); + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }); +}); + +test("敏感备份以 0600 新建且拒绝覆盖已有文件", () => { + writeBackupFile(target, "first"); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(() => writeBackupFile(target, "second")).toThrow(); + expect(readFileSync(target, "utf8")).toBe("first"); +}); + +describe("数据源文件读写(与 uTools 端同一把锁)", () => { + test("锁名与共享协议一致,且解析到真实文件路径", () => { + expect(lockPathFor(target)).toBe(`${target}.lock`); + expect(lockPathFor(target)).toBe(syncLockPath(normalizeSyncPath(target))); + expect(normalizePath("~/vault.json")).toBe(path.join(homedir(), "vault.json")); + expect(resolveVaultPath(target)).toBe(target); + }); + + test("符号链接别名解析到同一真实文件与同一把锁", () => { + writeFileSync(target, "old"); + const alias = path.join(directory, "alias.json"); + symlinkSync(target, alias); + + expect(resolveVaultPath(alias)).toBe(target); + expect(lockPathFor(alias)).toBe(lockPathFor(target)); + + const bridge = path.join(directory, "missing.json"); + expect(resolveVaultPath(bridge)).toBe(bridge); + }); + + test("同一文件经 /var 与 /private/var 访问得到同一把锁", () => { + if (!target.startsWith("/private/")) return; // 非 macOS 布局下没有这层别名 + const aliasPath = target.replace(/^\/private/, ""); + expect(aliasPath).not.toBe(target); + expect(lockPathFor(aliasPath)).toBe(lockPathFor(target)); + }); + + test("区分缺失、空文件与损坏文件,不把读不到当空库", () => { + expect(readVaultFile(target).status).toBe("missing"); + + writeFileSync(target, " "); + expect(readVaultFile(target).status).toBe("empty"); + + writeFileSync(target, "{ 不是 JSON"); + expect(readVaultFile(target).status).toBe("invalid"); + + writeFileSync(target, JSON.stringify({ app: "other", accounts: [] })); + expect(readVaultFile(target).status).toBe("invalid"); + }); + + test("正常读取返回快照与 stat", () => { + const content = exportAsSyncJson([], [], []); + writeFileSync(target, content); + const read = readVaultFile(target); + expect(read.status).toBe("ok"); + if (read.status !== "ok") return; + expect(read.content).toBe(content); + expect(read.size).toBe(Buffer.byteLength(content)); + expect(read.snapshot.accounts).toEqual([]); + }); + + test("原子写入:0600、不留临时文件与锁文件", async () => { + const content = exportAsSyncJson([], [], []); + const result = await writeVaultFile(target, content, null); + expect(result.status).toBe("ok"); + expect(readFileSync(target, "utf8")).toBe(content); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(readdirSync(directory)).toEqual(["sync.json"]); + }); + + test("新建数据源只允许空路径,不能覆盖已有内容", async () => { + const content = exportAsSyncJson([], [], []); + expect((await writeVaultFile(target, content, null, true)).status).toBe("ok"); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect((await writeVaultFile(target, "changed", null, true)).status).toBe("conflict"); + expect(readFileSync(target, "utf8")).toBe(content); + expect(readdirSync(directory)).toEqual(["sync.json"]); + }); + + test("超出数据源上限时不创建文件", async () => { + expect((await writeVaultFile(target, "x".repeat(5 * 1024 * 1024 + 1), null, true)).status).toBe("error"); + expect(readVaultFile(target).status).toBe("missing"); + }); + + test("另一个进程持锁时拒绝写入,且绝不按时间抢锁", async () => { + writeFileSync(target, "old"); + const lockPath = `${target}.lock`; + writeFileSync(lockPath, "4242\n1700000000000\n"); + + const blocked = await writeVaultFile(target, "new", null); + expect(blocked).toEqual({ status: "locked", lockContent: "4242\n1700000000000\n" }); + expect(readFileSync(target, "utf8")).toBe("old"); + expect(readVaultLock(target)).toBe("4242\n1700000000000\n"); + + // 看起来「很久」的锁同样不能被抢占:删掉活锁会让两个写入端同时写同一个文件 + const stale = new Date(Date.now() - 24 * 60 * 60 * 1000); + utimesSync(lockPath, stale, stale); + expect((await writeVaultFile(target, "stolen", null)).status).toBe("locked"); + expect(readFileSync(target, "utf8")).toBe("old"); + expect(readVaultLock(target)).not.toBeNull(); + + // 只有用户显式清理后才允许写入 + expect(clearVaultLock(target)).toBe(true); + expect(clearVaultLock(target)).toBe(false); + expect((await writeVaultFile(target, "after-clear", null)).status).toBe("ok"); + expect(readFileSync(target, "utf8")).toBe("after-clear"); + expect(readdirSync(directory)).toEqual(["sync.json"]); + }); + + test("锁内按 expectedContent 逐字节比对:mtime 相同也不算同一版本", async () => { + writeFileSync(target, "old"); + const mtime = statSync(target).mtimeMs; + // 外部改成同长度内容并刻意保留 mtime:stat 看不出来,内容比对必须拦住 + writeFileSync(target, "ext"); + utimesSync(target, new Date(mtime), new Date(mtime)); + + expect(await writeVaultFile(target, "new", "old")).toEqual({ status: "conflict" }); + expect(readFileSync(target, "utf8")).toBe("ext"); + + expect((await writeVaultFile(target, "new", "ext")).status).toBe("ok"); + expect(readFileSync(target, "utf8")).toBe("new"); + + // 基线存在但文件消失:同样算冲突,不能把缺失当空内容 + const gone = path.join(directory, "gone.json"); + expect(await writeVaultFile(gone, "new", "old")).toEqual({ status: "conflict" }); + expect(readdirSync(directory).sort()).toEqual(["sync.json"]); + }); + + test("相同的 HOTP 增量也不能用旧版本重复提交", async () => { + writeFileSync(target, "counter=3"); + expect((await writeVaultFile(target, "counter=4", "counter=3")).status).toBe("ok"); + expect((await writeVaultFile(target, "counter=4", "counter=3")).status).toBe("conflict"); + expect(readFileSync(target, "utf8")).toBe("counter=4"); + }); +}); diff --git a/extensions/goose-2fa/tests/vault-ops.test.ts b/extensions/goose-2fa/tests/vault-ops.test.ts new file mode 100644 index 00000000000..bbb5c938cbd --- /dev/null +++ b/extensions/goose-2fa/tests/vault-ops.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { exportAsSyncJson } from "../vendor/lib/data-transfer"; +import type { AccountData, NewAccountInput } from "../vendor/lib/types"; +import { + addGroup, + emptyTrash, + mergeExternalSnapshot, + moveToTrash, + previewImport, + removeGroup, + renameGroup, + restoreFromTrash, + setAccountGroup, + snapshotFromLocal, +} from "../src/lib/vault-ops"; + +const base: NewAccountInput = { + name: "alice@example.com", + issuer: "GitHub", + secret: "JBSWY3DPEHPK3PXP", + type: "totp", + digits: 6, + period: 30, + counter: 0, + algorithm: "SHA-1", +}; + +function account(id: string, overrides: Partial = {}): AccountData { + return { ...base, id, createdAt: 1, ...overrides }; +} + +describe("保险柜语义(与 uTools 端共用 )", () => { + test("载入外部快照时 HOTP 计数器只增不减", () => { + const current = [account("h1", { type: "hotp", counter: 9 })]; + const incoming = { accounts: [account("h1", { type: "hotp", counter: 2 })], groups: [], trash: [] }; + const merged = mergeExternalSnapshot(incoming, current); + expect(merged.snapshot.accounts[0]?.counter).toBe(9); + expect(merged.corrected).toBe(true); + }); + + test("过期回收站条目在载入时清理", () => { + const expired = account("t1", { deletedAt: Date.now() - 31 * 24 * 60 * 60 * 1000 }); + const fresh = account("t2", { deletedAt: Date.now() }); + const merged = mergeExternalSnapshot({ accounts: [], groups: [], trash: [expired, fresh] }, []); + expect(merged.snapshot.trash.map((item) => item.id)).toEqual(["t2"]); + expect(merged.corrected).toBe(true); + }); + + test("回收站往返与清空", () => { + const snapshot = exportAsSyncSnapshot([account("a1")]); + const trashed = moveToTrash(snapshot, "a1"); + expect(trashed.accounts).toHaveLength(0); + expect(trashed.trash[0]?.id).toBe("a1"); + expect(typeof trashed.trash[0]?.deletedAt).toBe("number"); + + const restored = restoreFromTrash(trashed, "a1"); + expect(restored.accounts[0]?.id).toBe("a1"); + expect(restored.accounts[0]?.deletedAt).toBeUndefined(); + expect(emptyTrash(restored).trash).toEqual([]); + }); + + test("分组增删改:删除分组只解绑账户", () => { + const withGroup = addGroup(exportAsSyncSnapshot([account("a1")]), "工作"); + expect(withGroup.group).not.toBeNull(); + const groupId = withGroup.group?.id ?? ""; + const assigned = setAccountGroup(withGroup.snapshot, "a1", groupId); + expect(assigned.accounts[0]?.groupId).toBe(groupId); + + const renamed = renameGroup(assigned, groupId, " 工作 账号 "); + expect(renamed.groups[0]?.name).toBe("工作 账号"); + + const removed = removeGroup(renamed, groupId); + expect(removed.groups).toEqual([]); + expect(removed.accounts[0]?.groupId).toBeNull(); + }); + + test("未知分组不会被写进账户", () => { + const snapshot = setAccountGroup(exportAsSyncSnapshot([account("a1")]), "a1", "missing"); + expect(snapshot.accounts[0]?.groupId).toBeNull(); + }); + + test("导入 otpauth URI 并跳过重复账户", () => { + const snapshot = exportAsSyncSnapshot([]); + const uri = "otpauth://totp/GitHub:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"; + const first = previewImport(uri, snapshot); + expect(first?.added).toBe(1); + expect(first?.dupeCount).toBe(0); + expect(first?.snapshot.accounts).toHaveLength(1); + expect(first?.snapshot.accounts[0]?.id).toBeTruthy(); + + const again = previewImport(uri, first?.snapshot ?? snapshot); + expect(again?.added).toBe(0); + expect(again?.dupeCount).toBe(1); + + expect(previewImport("这不是可解析的内容", snapshot)).toBeNull(); + }); + + test("本地库快照解析保留 id/createdAt 并按 30 天清理回收站", () => { + const snapshot = snapshotFromLocal({ + accounts: [account("a1", { note: "工作" })], + groups: [{ id: "g1", name: "工作", order: 0, createdAt: 5 }], + trash: [account("t1", { deletedAt: 1 })], + }); + expect(snapshot.accounts[0]).toMatchObject({ id: "a1", createdAt: 1, note: "工作" }); + expect(snapshot.groups[0]?.id).toBe("g1"); + expect(snapshot.trash).toEqual([]); + }); +}); + +function exportAsSyncSnapshot(accounts: AccountData[]) { + return { accounts, groups: [], trash: [] }; +} + +test("导出的备份可以被自己解析(格式闭环)", () => { + const text = exportAsSyncJson([account("a1")], [], [account("t1", { deletedAt: Date.now() })]); + const parsed = JSON.parse(text) as { app: string; accounts: unknown[]; trash: unknown[] }; + expect(parsed.app).toBe("goose-2fa"); + expect(parsed.accounts).toHaveLength(1); + expect(parsed.trash).toHaveLength(1); +}); diff --git a/extensions/goose-2fa/tsconfig.json b/extensions/goose-2fa/tsconfig.json new file mode 100644 index 00000000000..a25d0c0e0bc --- /dev/null +++ b/extensions/goose-2fa/tsconfig.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": [ + "src", + "raycast-env.d.ts", + "vendor" + ], + "compilerOptions": { + "lib": [ + "ES2022" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "types": [ + "node" + ], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/extensions/goose-2fa/vendor/lib/account-validation.ts b/extensions/goose-2fa/vendor/lib/account-validation.ts new file mode 100644 index 00000000000..3e00924755c --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/account-validation.ts @@ -0,0 +1,91 @@ +import type { AccountData, NewAccountInput } from "./types"; + +const BASE32_RE = /^[A-Z2-7]+$/; +const ALGORITHMS = new Set(["SHA-1", "SHA-256", "SHA-512"]); + +export function normalizeBase32Secret(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase(); + if (normalized.length < 8 || normalized.length > 256 || !BASE32_RE.test(normalized)) { + return null; + } + return normalized; +} + +function optionalText(value: unknown, maxLength = 2000): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return text ? text.slice(0, maxLength) : undefined; +} + +function finiteInteger(value: unknown, fallback: number): number { + if (value === undefined || value === null || value === "") return fallback; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isSafeInteger(parsed) ? parsed : Number.NaN; +} + +export function normalizeNewAccountInput(value: unknown): NewAccountInput | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const secret = normalizeBase32Secret(raw.secret); + if (!secret) return null; + + const typeRaw = typeof raw.type === "string" ? raw.type.toLowerCase() : "totp"; + if (typeRaw !== "totp" && typeRaw !== "hotp") return null; + const type = typeRaw; + const digits = finiteInteger(raw.digits, 6); + const period = finiteInteger(raw.period, 30); + const counter = finiteInteger(raw.counter, 0); + if ((digits !== 6 && digits !== 8) || period < 1 || period > 300 || counter < 0) { + return null; + } + + const algorithmRaw = typeof raw.algorithm === "string" + ? raw.algorithm.toUpperCase().replace(/^SHA(?=\d)/, "SHA-") + : "SHA-1"; + if (!ALGORITHMS.has(algorithmRaw as AccountData["algorithm"])) return null; + + const issuer = typeof raw.issuer === "string" ? raw.issuer.trim().slice(0, 200) : ""; + const rawName = typeof raw.name === "string" ? raw.name.trim() : ""; + const name = (rawName || issuer || "Unknown").slice(0, 300); + + return { + name, + issuer, + secret, + type, + digits, + period, + counter, + algorithm: algorithmRaw as AccountData["algorithm"], + originalName: optionalText(raw.originalName, 500), + note: optionalText(raw.note, 500), + remark: optionalText(raw.remark), + groupId: typeof raw.groupId === "string" && raw.groupId ? raw.groupId : null, + }; +} + +export function normalizeStoredAccount(value: unknown, validGroupIds?: Set): AccountData | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const input = normalizeNewAccountInput(raw); + if (!input) return null; + const id = typeof raw.id === "string" && raw.id ? raw.id : crypto.randomUUID(); + const createdAt = typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt) + ? raw.createdAt + : Date.now(); + const groupId = input.groupId && (!validGroupIds || validGroupIds.has(input.groupId)) + ? input.groupId + : null; + const deletedAt = typeof raw.deletedAt === "number" && Number.isFinite(raw.deletedAt) + ? raw.deletedAt + : undefined; + return { ...input, id, createdAt, groupId, deletedAt }; +} + +export function normalizeStoredAccounts(values: unknown, validGroupIds?: Set): AccountData[] { + if (!Array.isArray(values)) return []; + return values + .map((value) => normalizeStoredAccount(value, validGroupIds)) + .filter((account): account is AccountData => account !== null); +} diff --git a/extensions/goose-2fa/vendor/lib/data-transfer.ts b/extensions/goose-2fa/vendor/lib/data-transfer.ts new file mode 100644 index 00000000000..66bb09a1483 --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/data-transfer.ts @@ -0,0 +1,519 @@ +import { parseOtpAuthUri } from "./otp"; +import { parseGoogleAuthMigration } from "./google-auth-migration"; +import { normalizeBase32Secret, normalizeNewAccountInput, normalizeStoredAccounts } from "./account-validation"; +import type { AccountData, NewAccountInput, VaultGroup } from "./types"; + +export interface ExportPayload { + version: 2; + app: "goose-2fa"; + exported_at: string; + groups: VaultGroup[]; + accounts: NewAccountInput[]; +} + +export interface SyncExportPayload { + version: 2; + app: "goose-2fa"; + exported_at: string; + groups: VaultGroup[]; + accounts: AccountData[]; + trash: AccountData[]; +} + +// ---- Export ---- + +export function exportAsJson(accounts: AccountData[], groups: VaultGroup[] = []): string { + const payload: ExportPayload = { + version: 2, + app: "goose-2fa", + exported_at: new Date().toISOString(), + groups: groups.map(({ id, name, order, createdAt }) => ({ id, name, order, createdAt })), + accounts: accounts.map(({ id: _id, createdAt: _createdAt, meta: _meta, deletedAt: _deletedAt, ...rest }) => rest), + }; + return JSON.stringify(payload, null, 2); +} + +/** 自动同步备份保留稳定 id、时间戳和回收站,仍可被手动导入读取。 */ +export function exportAsSyncJson( + accounts: AccountData[], + groups: VaultGroup[] = [], + trash: AccountData[] = [], +): string { + const payload: SyncExportPayload = { + version: 2, + app: "goose-2fa", + exported_at: new Date().toISOString(), + groups: groups.map((group) => ({ ...group })), + accounts: accounts.map((account) => ({ ...account })), + trash: trash.map((account) => ({ ...account })), + }; + return JSON.stringify(payload, null, 2); +} + +export interface SyncSnapshot { + accounts: AccountData[]; + groups: VaultGroup[]; + trash: AccountData[]; +} + +function hasStableId(item: unknown): boolean { + if (!item || typeof item !== "object") return false; + const id = (item as Record).id; + return typeof id === "string" && id !== ""; +} + +/** + * 严格解析自动同步文件(数据源):保留 id/createdAt/deletedAt 与回收站。 + * 头部不符、任一条目缺少稳定 id 或无法解析时返回 null —— 调用方必须按「文件无效」处理, + * 不能当成空库,也不能把解析结果写回文件。 + */ +export function parseSyncSnapshot(text: string): SyncSnapshot | null { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return null; + } + if (!json || typeof json !== "object" || Array.isArray(json)) return null; + const raw = json as Record; + if (raw.app !== "goose-2fa" || !Array.isArray(raw.accounts)) return null; + const accountsRaw = raw.accounts as unknown[]; + const trashRaw = Array.isArray(raw.trash) ? (raw.trash as unknown[]) : []; + if (!accountsRaw.every(hasStableId) || !trashRaw.every(hasStableId)) return null; + // id 同时是界面 key 与更新/删除主键,重复 id 会让一次编辑命中多条记录。 + const ids = [...accountsRaw, ...trashRaw].map((item) => (item as { id: string }).id); + if (new Set(ids).size !== ids.length) return null; + const groups = normalizeGroups(raw.groups); + const validGroupIds = new Set(groups.map((group) => group.id)); + const accounts = normalizeStoredAccounts(accountsRaw, validGroupIds); + const trash = normalizeStoredAccounts(trashRaw, validGroupIds); + if (accounts.length !== accountsRaw.length || trash.length !== trashRaw.length) return null; + return { accounts, groups, trash }; +} + +export function exportAsUris(accounts: AccountData[]): string { + return accounts + .map((a) => { + const label = a.issuer + ? `${encodeURIComponent(a.issuer)}:${encodeURIComponent(a.name)}` + : encodeURIComponent(a.name); + const params = new URLSearchParams(); + params.set("secret", a.secret); + if (a.issuer) params.set("issuer", a.issuer); + params.set("algorithm", a.algorithm.replace("-", "")); + params.set("digits", String(a.digits)); + if (a.type === "totp") params.set("period", String(a.period)); + if (a.type === "hotp") params.set("counter", String(a.counter)); + return `otpauth://${a.type}/${label}?${params.toString()}`; + }) + .join("\n"); +} + +// ---- Algorithm normalization ---- + +const ALGORITHM_MAP: Record = { + SHA1: "SHA-1", + "SHA-1": "SHA-1", + "sha-1": "SHA-1", + sha1: "SHA-1", + SHA256: "SHA-256", + "SHA-256": "SHA-256", + sha256: "SHA-256", + "sha-256": "SHA-256", + SHA512: "SHA-512", + "SHA-512": "SHA-512", + sha512: "SHA-512", + "sha-512": "SHA-512", +}; + +function normalizeAlgorithm(alg: string | undefined | null): "SHA-1" | "SHA-256" | "SHA-512" { + if (!alg) return "SHA-1"; + return ALGORITHM_MAP[alg.trim()] ?? "SHA-1"; +} + +function normalizeType(t: string | undefined | null): "totp" | "hotp" { + if (!t) return "totp"; + return t.toLowerCase() === "hotp" ? "hotp" : "totp"; +} + +// ---- Base32 detection ---- + +export function isBase32Secret(text: string): boolean { + return normalizeBase32Secret(text) !== null; +} + +// ---- Format parsers ---- + +/** Our own goose-2fa backup */ +function tryGoose2fa(json: Record): NewAccountInput[] | null { + if (json.app === "goose-2fa" && Array.isArray(json.accounts)) { + return parseAccountArray(json.accounts); + } + return null; +} + +/** 2FAS Authenticator backup: { "services": [...], "updatedAt": ..., "schemaVersion": ... } */ +function try2FAS(json: Record): NewAccountInput[] | null { + if (!Array.isArray(json.services)) return null; + const results: NewAccountInput[] = []; + for (const svc of json.services as Record[]) { + const otp = svc.otp as Record | undefined; + const secret = (otp?.secret ?? svc.secret) as string | undefined; + if (!secret) continue; + + results.push({ + name: ((otp?.account ?? otp?.label ?? svc.name ?? "Unknown") as string), + issuer: ((otp?.issuer ?? svc.name ?? "") as string), + secret: String(secret).replace(/\s/g, "").toUpperCase(), + type: normalizeType((otp?.tokenType ?? otp?.type ?? "TOTP") as string), + digits: parseInt(String(otp?.digits ?? 6)) || 6, + period: parseInt(String(otp?.period ?? 30)) || 30, + counter: parseInt(String(otp?.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((otp?.algorithm ?? "SHA1") as string), + }); + } + return results.length > 0 ? results : null; +} + +/** Aegis Authenticator backup: { "version": N, "db": { "entries": [...] } } */ +function tryAegis(json: Record): NewAccountInput[] | null { + const db = json.db as Record | undefined; + if (!db || !Array.isArray(db.entries)) return null; + const results: NewAccountInput[] = []; + for (const entry of db.entries as Record[]) { + const info = entry.info as Record | undefined; + const secret = (info?.secret ?? entry.secret) as string | undefined; + if (!secret) continue; + + results.push({ + name: (entry.name as string) || "Unknown", + issuer: (entry.issuer as string) || "", + secret: String(secret).replace(/\s/g, "").toUpperCase(), + type: normalizeType((entry.type ?? "totp") as string), + digits: parseInt(String(info?.digits ?? 6)) || 6, + period: parseInt(String(info?.period ?? 30)) || 30, + counter: parseInt(String(info?.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((info?.algo ?? info?.algorithm ?? "SHA1") as string), + }); + } + return results.length > 0 ? results : null; +} + +/** andOTP backup: plain JSON array with { secret, issuer, label, type, algorithm, digits, period } */ +function tryAndOTP(arr: unknown[]): NewAccountInput[] | null { + if (arr.length === 0) return null; + const first = arr[0] as Record; + // andOTP entries have "secret" and usually "type" fields + if (!("secret" in first)) return null; + + const results: NewAccountInput[] = []; + for (const entry of arr as Record[]) { + const secret = entry.secret as string | undefined; + if (!secret) continue; + + let name = ((entry.label ?? entry.name ?? entry.account ?? "") as string); + let issuer = ((entry.issuer ?? entry.issuerExt ?? "") as string); + + // andOTP uses "Issuer:Account" format in label + if (!issuer && name.includes(":")) { + const idx = name.indexOf(":"); + issuer = name.slice(0, idx).trim(); + name = name.slice(idx + 1).trim(); + } + + results.push({ + name: name || issuer || "Unknown", + issuer, + secret: String(secret).replace(/\s/g, "").toUpperCase(), + type: normalizeType((entry.type ?? "TOTP") as string), + digits: parseInt(String(entry.digits ?? 6)) || 6, + period: parseInt(String(entry.period ?? entry.timer ?? 30)) || 30, + counter: parseInt(String(entry.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((entry.algorithm ?? entry.algo ?? "SHA1") as string), + }); + } + return results.length > 0 ? results : null; +} + +/** Raivo OTP backup: JSON array with { issuer, account, secret, algorithm, digits, kind, timer, counter } */ +function tryRaivo(arr: unknown[]): NewAccountInput[] | null { + if (arr.length === 0) return null; + const first = arr[0] as Record; + if (!("secret" in first) || !("kind" in first || "timer" in first)) return null; + + const results: NewAccountInput[] = []; + for (const entry of arr as Record[]) { + const secret = entry.secret as string | undefined; + if (!secret) continue; + + results.push({ + name: ((entry.account ?? entry.name ?? entry.label ?? "") as string) || (entry.issuer as string) || "Unknown", + issuer: (entry.issuer as string) || "", + secret: String(secret).replace(/\s/g, "").toUpperCase(), + type: normalizeType((entry.kind ?? entry.type ?? "TOTP") as string), + digits: parseInt(String(entry.digits ?? 6)) || 6, + period: parseInt(String(entry.timer ?? entry.period ?? 30)) || 30, + counter: parseInt(String(entry.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((entry.algorithm ?? "SHA1") as string), + }); + } + return results.length > 0 ? results : null; +} + +/** + * FreeOTP+ backup: JSON array with { issuerExt, label, secret (byte array or base32), algo, digits, period, type } + * FreeOTP+ may encode secret as a JSON number array instead of base32 string. + */ +function tryFreeOTP(arr: unknown[]): NewAccountInput[] | null { + if (arr.length === 0) return null; + const first = arr[0] as Record; + if (!("secret" in first) || !("issuerExt" in first || "issuerInt" in first)) return null; + + const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + function numArrayToBase32(nums: number[]): string { + let result = ""; + let bits = 0; + let value = 0; + for (const byte of nums) { + value = (value << 8) | (byte & 0xff); + bits += 8; + while (bits >= 5) { + bits -= 5; + result += B32[(value >> bits) & 31]; + } + } + if (bits > 0) result += B32[(value << (5 - bits)) & 31]; + return result; + } + + const results: NewAccountInput[] = []; + for (const entry of arr as Record[]) { + let secret = ""; + if (Array.isArray(entry.secret)) { + secret = numArrayToBase32(entry.secret as number[]); + } else if (typeof entry.secret === "string") { + secret = (entry.secret as string).replace(/\s/g, "").toUpperCase(); + } + if (!secret) continue; + + results.push({ + name: ((entry.label ?? entry.name ?? "") as string) || ((entry.issuerExt ?? entry.issuerInt ?? "") as string) || "Unknown", + issuer: ((entry.issuerExt ?? entry.issuerInt ?? entry.issuer ?? "") as string), + secret, + type: normalizeType((entry.type ?? "TOTP") as string), + digits: parseInt(String(entry.digits ?? 6)) || 6, + period: parseInt(String(entry.period ?? 30)) || 30, + counter: parseInt(String(entry.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((entry.algo ?? entry.algorithm ?? "SHA1") as string), + }); + } + return results.length > 0 ? results : null; +} + +/** Generic fallback: any JSON array or object with accounts that have a "secret" field */ +function parseAccountArray(arr: unknown[]): NewAccountInput[] { + const results: NewAccountInput[] = []; + for (const a of arr as Record[]) { + const secret = a.secret as string | undefined; + if (!secret) continue; + + let name = ((a.name ?? a.label ?? a.account ?? "") as string); + let issuer = ((a.issuer ?? a.issuerExt ?? "") as string); + + if (!issuer && name.includes(":")) { + const idx = name.indexOf(":"); + issuer = name.slice(0, idx).trim(); + name = name.slice(idx + 1).trim(); + } + + results.push({ + name: name || issuer || "Unknown", + issuer, + secret: String(secret).replace(/\s/g, "").toUpperCase(), + type: normalizeType((a.type ?? a.tokenType ?? a.kind ?? "totp") as string), + digits: parseInt(String(a.digits ?? 6)) || 6, + period: parseInt(String(a.period ?? a.timer ?? 30)) || 30, + counter: parseInt(String(a.counter ?? 0)) || 0, + algorithm: normalizeAlgorithm((a.algorithm ?? a.algo ?? "SHA1") as string), + }); + } + return results; +} + +// ---- Main import parser ---- + +function parseImportDataUnchecked(text: string): NewAccountInput[] | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + // 1. Google Authenticator migration URI + if (trimmed.startsWith("otpauth-migration://")) { + const entries = parseGoogleAuthMigration(trimmed); + if (entries && entries.length > 0) return entries; + } + + // 2. Single or multi-line otpauth:// URIs + if (trimmed.startsWith("otpauth://")) { + const lines = trimmed.split(/[\n\r]+/).map((l) => l.trim()).filter(Boolean); + const parsed: NewAccountInput[] = []; + for (const line of lines) { + if (line.startsWith("otpauth-migration://")) { + const migrated = parseGoogleAuthMigration(line); + if (migrated) parsed.push(...migrated); + } else if (line.startsWith("otpauth://")) { + const p = parseOtpAuthUri(line); + if (p) parsed.push({ ...p }); + } + } + if (parsed.length > 0) return parsed; + } + + // 3. JSON formats + try { + const json = JSON.parse(trimmed); + + if (typeof json === "object" && json !== null && !Array.isArray(json)) { + // Try named formats in order of specificity + const obj = json as Record; + const goose = tryGoose2fa(obj); + if (goose && goose.length > 0) return goose; + + const twofas = try2FAS(obj); + if (twofas && twofas.length > 0) return twofas; + + const aegis = tryAegis(obj); + if (aegis && aegis.length > 0) return aegis; + + // Object with "accounts" or "entries" array (generic) + const arr = (obj.accounts ?? obj.entries ?? obj.otp_parameters) as unknown[] | undefined; + if (Array.isArray(arr)) { + const generic = parseAccountArray(arr); + if (generic.length > 0) return generic; + } + } + + if (Array.isArray(json) && json.length > 0) { + // Try array-based formats + const andotp = tryAndOTP(json); + if (andotp && andotp.length > 0) return andotp; + + const raivo = tryRaivo(json); + if (raivo && raivo.length > 0) return raivo; + + const freeotp = tryFreeOTP(json); + if (freeotp && freeotp.length > 0) return freeotp; + + // Generic array fallback + const generic = parseAccountArray(json); + if (generic.length > 0) return generic; + } + } catch { + // not JSON + } + + // 4. Mixed text: lines that are otpauth:// URIs or otpauth-migration:// URIs + const lines = trimmed.split(/[\n\r]+/).map((l) => l.trim()).filter(Boolean); + const fromUris: NewAccountInput[] = []; + for (const line of lines) { + if (line.startsWith("otpauth-migration://")) { + const migrated = parseGoogleAuthMigration(line); + if (migrated) fromUris.push(...migrated); + } else if (line.startsWith("otpauth://")) { + const p = parseOtpAuthUri(line); + if (p) fromUris.push({ ...p }); + } + } + if (fromUris.length > 0) return fromUris; + + // 5. Single base32 secret (one per line) + const base32Lines = lines.filter((l) => isBase32Secret(l)); + if (base32Lines.length > 0) { + return base32Lines.map((s, i) => ({ + name: `Account ${i + 1}`, + issuer: "", + secret: s.replace(/[\s-]/g, "").toUpperCase(), + type: "totp" as const, + digits: 6, + period: 30, + counter: 0, + algorithm: "SHA-1" as const, + })); + } + + return null; +} + +export interface ImportBundle { + accounts: NewAccountInput[]; + groups: VaultGroup[]; +} + +function normalizeGroups(value: unknown): VaultGroup[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const groups: VaultGroup[] = []; + for (const [index, item] of value.entries()) { + if (!item || typeof item !== "object") continue; + const raw = item as Record; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + const name = typeof raw.name === "string" ? raw.name.trim().replace(/\s+/g, " ") : ""; + if (!id || !name || seen.has(id)) continue; + seen.add(id); + groups.push({ + id, + name: name.slice(0, 20), + order: Number.isFinite(raw.order) ? Number(raw.order) : index, + createdAt: Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : Date.now() + index, + }); + } + return groups; +} + +function normalizeAccounts(values: unknown): NewAccountInput[] { + if (!Array.isArray(values)) return []; + return values + .map(normalizeNewAccountInput) + .filter((account): account is NewAccountInput => account !== null); +} + +export function parseImportData(text: string): NewAccountInput[] | null { + const parsed = parseImportDataUnchecked(text); + if (!parsed) return null; + const normalized = normalizeAccounts(parsed); + return normalized.length > 0 ? normalized : null; +} + +export function parseImportBundle(text: string): ImportBundle | null { + const trimmed = text.trim(); + try { + const json = JSON.parse(trimmed) as Record; + if (json && json.app === "goose-2fa" && Array.isArray(json.accounts)) { + const accounts = normalizeAccounts(json.accounts); + if (accounts.length === 0) return null; + return { accounts, groups: normalizeGroups(json.groups) }; + } + } catch { + // 其他支持格式由统一解析器处理。 + } + const accounts = parseImportData(trimmed); + return accounts ? { accounts, groups: [] } : null; +} + +// ---- Deduplication ---- + +export function deduplicateImports( + incoming: NewAccountInput[], + existing: AccountData[], +): { newAccounts: NewAccountInput[]; dupeCount: number } { + const identity = (account: Pick) => + [account.type, normalizeBase32Secret(account.secret) ?? account.secret, account.issuer.trim().toLocaleLowerCase(), account.name.trim().toLocaleLowerCase()].join("\u0000"); + const seen = new Set(existing.map(identity)); + const newAccounts: NewAccountInput[] = []; + for (const account of incoming) { + const key = identity(account); + if (seen.has(key)) continue; + seen.add(key); + newAccounts.push(account); + } + return { newAccounts, dupeCount: incoming.length - newAccounts.length }; +} diff --git a/extensions/goose-2fa/vendor/lib/google-auth-migration.ts b/extensions/goose-2fa/vendor/lib/google-auth-migration.ts new file mode 100644 index 00000000000..62b203f6ed7 --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/google-auth-migration.ts @@ -0,0 +1,220 @@ +/** + * Google Authenticator migration format decoder. + * + * URI: otpauth-migration://offline?data=BASE64_PROTOBUF + * + * Protobuf schema (MigrationPayload): + * field 1 (repeated, embedded): OtpParameters + * + * OtpParameters: + * field 1 (bytes): secret + * field 2 (string): name + * field 3 (string): issuer + * field 4 (varint): algorithm (0=unspecified/SHA1, 1=SHA1, 2=SHA256, 3=SHA512, 4=MD5) + * field 5 (varint): digits (0=unspecified/6, 1=six, 2=eight) + * field 6 (varint): type (0=unspecified/TOTP, 1=HOTP, 2=TOTP) + * field 7 (varint): counter + */ + +import type { NewAccountInput } from "./types"; +import { normalizeNewAccountInput } from "./account-validation"; + +// ---- protobuf primitives ---- + +function decodeVarint(buf: Uint8Array, offset: number): [bigint, number] { + let result = 0n; + let shift = 0n; + let pos = offset; + while (pos < buf.length) { + const byte = buf[pos]!; + result |= BigInt(byte & 0x7f) << shift; + pos++; + if (!(byte & 0x80)) return [result, pos]; + shift += 7n; + if (shift > 63n) throw new Error("Varint exceeds 64 bits"); + } + throw new Error("Truncated protobuf varint"); +} + +function safeNumber(value: bigint): number { + const number = Number(value); + if (!Number.isSafeInteger(number) || number < 0) throw new Error("Unsafe protobuf integer"); + return number; +} + +// ---- base32 encode raw bytes ---- + +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function bytesToBase32(bytes: Uint8Array): string { + let result = ""; + let bits = 0; + let value = 0; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + result += B32[(value >> bits) & 31]; + } + } + if (bits > 0) { + result += B32[(value << (5 - bits)) & 31]; + } + return result; +} + +// ---- parse one OtpParameters message ---- + +function parseOtpEntry(buf: Uint8Array): NewAccountInput | null { + let secret: Uint8Array | null = null; + let name = ""; + let issuer = ""; + let algorithm = 0; + let digits = 0; + let otpType = 0; + let counter = 0; + + let pos = 0; + while (pos < buf.length) { + const [tag, p1] = decodeVarint(buf, pos); + pos = p1; + const field = safeNumber(tag >> 3n); + const wire = safeNumber(tag & 0x7n); + + if (wire === 2) { + const [len, p2] = decodeVarint(buf, pos); + pos = p2; + const size = safeNumber(len); + if (size > buf.length - pos) throw new Error("Truncated protobuf field"); + const data = buf.slice(pos, pos + size); + pos += size; + if (field === 1) secret = data; + else if (field === 2) name = new TextDecoder().decode(data); + else if (field === 3) issuer = new TextDecoder().decode(data); + } else if (wire === 0) { + const [val, p2] = decodeVarint(buf, pos); + pos = p2; + const number = safeNumber(val); + if (field === 4) algorithm = number; + else if (field === 5) digits = number; + else if (field === 6) otpType = number; + else if (field === 7) counter = number; + } else { + // skip unknown wire types + if (wire === 1) pos += 8; + else if (wire === 5) pos += 4; + else break; + if (pos > buf.length) throw new Error("Truncated protobuf field"); + } + } + + if (!secret || secret.length === 0) return null; + + const algMap: Record = { + 0: "SHA-1", + 1: "SHA-1", + 2: "SHA-256", + 3: "SHA-512", + }; + const digitMap: Record = { 0: 6, 1: 6, 2: 8 }; + const typeMap: Record = { 0: "totp", 1: "hotp", 2: "totp" }; + + // Handle label format "Issuer:account" when issuer is empty + if (!issuer && name.includes(":")) { + const idx = name.indexOf(":"); + issuer = name.slice(0, idx).trim(); + name = name.slice(idx + 1).trim(); + } + + const normalized = normalizeNewAccountInput({ + name: name || issuer || "Unknown", + issuer, + secret: bytesToBase32(secret), + type: typeMap[otpType] ?? "invalid", + digits: digitMap[digits] ?? Number.NaN, + period: 30, + counter, + algorithm: algMap[algorithm] ?? "invalid", + }); + return normalized; +} + +// ---- parse the outer MigrationPayload ---- + +export interface GoogleAuthMigrationPayload { + accounts: NewAccountInput[]; + batchSize: number; + batchIndex: number; + batchId: number; +} + +function parseMigrationPayload(buf: Uint8Array): GoogleAuthMigrationPayload { + const entries: NewAccountInput[] = []; + let batchSize = 1; + let batchIndex = 0; + let batchId = 0; + let pos = 0; + while (pos < buf.length) { + const [tag, p1] = decodeVarint(buf, pos); + pos = p1; + const field = safeNumber(tag >> 3n); + const wire = safeNumber(tag & 0x7n); + + if (wire === 2) { + const [len, p2] = decodeVarint(buf, pos); + pos = p2; + const size = safeNumber(len); + if (size > buf.length - pos) throw new Error("Truncated protobuf field"); + if (field === 1) { + const entry = parseOtpEntry(buf.slice(pos, pos + size)); + if (entry) entries.push(entry); + } + pos += size; + } else if (wire === 0) { + const [value, p2] = decodeVarint(buf, pos); + pos = p2; + const number = safeNumber(value); + if (field === 3) batchSize = number; + else if (field === 4) batchIndex = number; + else if (field === 5) batchId = number; + } else { + break; + } + } + return { accounts: entries, batchSize: Math.max(1, batchSize), batchIndex, batchId }; +} + +// ---- public API ---- + +/** + * Parse a Google Authenticator migration URI. + * Format: otpauth-migration://offline?data=BASE64 + */ +export function parseGoogleAuthMigrationPayload(uri: string): GoogleAuthMigrationPayload | null { + const trimmed = uri.trim(); + if (!trimmed.startsWith("otpauth-migration://")) return null; + + try { + const dataMatch = trimmed.match(/[?&]data=([^&]+)/); + if (!dataMatch) return null; + + const b64 = decodeURIComponent(dataMatch[1]!); + const raw = atob(b64); + const buf = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + buf[i] = raw.charCodeAt(i); + } + + const payload = parseMigrationPayload(buf); + return payload.accounts.length > 0 ? payload : null; + } catch { + return null; + } +} + +export function parseGoogleAuthMigration(uri: string): NewAccountInput[] | null { + const payload = parseGoogleAuthMigrationPayload(uri); + if (!payload || payload.batchSize > 1) return null; + return payload.accounts; +} diff --git a/extensions/goose-2fa/vendor/lib/groups.ts b/extensions/goose-2fa/vendor/lib/groups.ts new file mode 100644 index 00000000000..a0017d0db9f --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/groups.ts @@ -0,0 +1,91 @@ +import type { AccountData, VaultGroup } from "./types"; + +/** 一级「未分组」筛选键(不是真实 VaultGroup.id) */ +export const UNGROUPED_KEY = "__ungrouped__"; +export const MAX_GROUP_NAME_LENGTH = 20; + +export function isUngrouped(account: AccountData): boolean { + return !account.groupId; +} + +export function filterByGroup( + accounts: AccountData[], + groupId: string | null, +): AccountData[] { + if (groupId === null) return accounts; + if (groupId === UNGROUPED_KEY) return accounts.filter(isUngrouped); + return accounts.filter((a) => a.groupId === groupId); +} + +export interface GroupTally { + id: string; + name: string; + count: number; + order: number; +} + +/** 按 order 排列用户分组,并附带账户数。 */ +export function buildGroupTallies( + groups: VaultGroup[], + accounts: AccountData[], +): GroupTally[] { + const counts = new Map(); + let ungrouped = 0; + for (const account of accounts) { + if (isUngrouped(account)) { + ungrouped += 1; + continue; + } + const id = account.groupId as string; + counts.set(id, (counts.get(id) ?? 0) + 1); + } + + const sorted = [...groups].sort( + (a, b) => a.order - b.order || a.name.localeCompare(b.name, "zh"), + ); + + const tallies: GroupTally[] = sorted.map((g) => ({ + id: g.id, + name: g.name, + count: counts.get(g.id) ?? 0, + order: g.order, + })); + + // 未分组始终可点,便于整理 + tallies.push({ + id: UNGROUPED_KEY, + name: "未分组", + count: ungrouped, + order: Number.MAX_SAFE_INTEGER, + }); + + return tallies; +} + +/** + * 解析一级选中态。 + * - null → 全部 + * - UNGROUPED_KEY 始终合法 + * - 真实 group id 必须仍存在;accounts 空时不误清 + */ +export function resolveSelectedGroup( + selected: string | null, + groups: VaultGroup[], + accounts: AccountData[], +): string | null { + if (selected === null) return null; + if (selected === UNGROUPED_KEY) return UNGROUPED_KEY; + if (groups.some((g) => g.id === selected)) return selected; + // 分组列表尚未 hydrate 且 accounts 也空时保留,避免闪断 + if (groups.length === 0 && accounts.length === 0) return selected; + return null; +} + +export function nextGroupOrder(groups: VaultGroup[]): number { + if (groups.length === 0) return 0; + return Math.max(...groups.map((g) => g.order)) + 1; +} + +export function normalizeGroupName(name: string): string { + return name.trim().replace(/\s+/g, " ").slice(0, MAX_GROUP_NAME_LENGTH); +} diff --git a/extensions/goose-2fa/vendor/lib/otp.ts b/extensions/goose-2fa/vendor/lib/otp.ts new file mode 100644 index 00000000000..57a738f942b --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/otp.ts @@ -0,0 +1,166 @@ +import type { ParsedOtpAuth } from "./types"; +import { normalizeBase32Secret, normalizeNewAccountInput } from "./account-validation"; + +const BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function base32Decode(input: string): Uint8Array { + const cleaned = normalizeBase32Secret(input); + if (!cleaned) throw new Error("Invalid Base32 secret"); + const bits: number[] = []; + + for (const char of cleaned) { + const val = BASE32_CHARS.indexOf(char); + if (val === -1) throw new Error("Invalid Base32 secret"); + for (let i = 4; i >= 0; i--) { + bits.push((val >> i) & 1); + } + } + + const bytes = new Uint8Array(Math.floor(bits.length / 8)); + for (let i = 0; i < bytes.length; i++) { + let byte = 0; + for (let j = 0; j < 8; j++) { + byte = (byte << 1) | (bits[i * 8 + j] ?? 0); + } + bytes[i] = byte; + } + + return bytes; +} + +function intToBytes(num: number): Uint8Array { + if (!Number.isSafeInteger(num) || num < 0) throw new Error("Invalid HOTP counter"); + const bytes = new Uint8Array(8); + for (let i = 7; i >= 0; i--) { + bytes[i] = num & 0xff; + num = Math.floor(num / 256); + } + return bytes; +} + +async function hmac( + algorithm: string, + key: Uint8Array, + data: Uint8Array, +): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + key.buffer as ArrayBuffer, + { name: "HMAC", hash: algorithm }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", cryptoKey, data.buffer as ArrayBuffer); + return new Uint8Array(signature); +} + +function dynamicTruncate(hmacResult: Uint8Array, digits: number): string { + if (digits !== 6 && digits !== 8) throw new Error("Unsupported OTP digits"); + const offset = hmacResult[hmacResult.length - 1]! & 0x0f; + const code = + ((hmacResult[offset]! & 0x7f) << 24) | + ((hmacResult[offset + 1]! & 0xff) << 16) | + ((hmacResult[offset + 2]! & 0xff) << 8) | + (hmacResult[offset + 3]! & 0xff); + + return (code % Math.pow(10, digits)).toString().padStart(digits, "0"); +} + +export async function generateHOTP( + secret: string, + counter: number, + digits: number = 6, + algorithm: string = "SHA-1", +): Promise { + const key = base32Decode(secret); + const data = intToBytes(counter); + const hash = await hmac(algorithm, key, data); + return dynamicTruncate(hash, digits); +} + +export async function generateTOTP( + secret: string, + period: number = 30, + digits: number = 6, + algorithm: string = "SHA-1", +): Promise<{ code: string; remaining: number }> { + if (!Number.isSafeInteger(period) || period < 1 || period > 300) { + throw new Error("Invalid TOTP period"); + } + const now = Math.floor(Date.now() / 1000); + const timeStep = Math.floor(now / period); + const remaining = period - (now % period); + const code = await generateHOTP(secret, timeStep, digits, algorithm); + return { code, remaining }; +} + +export function formatCode(code: string): string { + if (code.length === 6) return `${code.slice(0, 3)} ${code.slice(3)}`; + if (code.length === 8) + return `${code.slice(0, 4)} ${code.slice(4)}`; + return code; +} + +export function parseOtpAuthUri(uri: string): ParsedOtpAuth | null { + try { + const trimmed = uri.trim(); + if (!trimmed.startsWith("otpauth://")) return null; + + // Manual parsing to avoid URL constructor mishandling @ in labels. + // Format: otpauth://TYPE/LABEL?PARAMS + const withoutScheme = trimmed.slice("otpauth://".length); + const slashIdx = withoutScheme.indexOf("/"); + if (slashIdx === -1) return null; + + const type = withoutScheme.slice(0, slashIdx).toLowerCase(); + if (type !== "totp" && type !== "hotp") return null; + + const rest = withoutScheme.slice(slashIdx + 1); + const qIdx = rest.indexOf("?"); + const rawLabel = qIdx === -1 ? rest : rest.slice(0, qIdx); + const queryStr = qIdx === -1 ? "" : rest.slice(qIdx + 1); + + const pathLabel = decodeURIComponent(rawLabel); + let issuer = ""; + let name = pathLabel; + + if (pathLabel.includes(":")) { + const colonIdx = pathLabel.indexOf(":"); + issuer = pathLabel.slice(0, colonIdx).trim(); + name = pathLabel.slice(colonIdx + 1).trim(); + } + + const params = new URLSearchParams(queryStr); + const secret = params.get("secret"); + if (!secret) return null; + + const paramIssuer = params.get("issuer")?.trim(); + if (paramIssuer) issuer = paramIssuer; + if (!name && issuer) name = issuer; + if (!name) name = "Unknown"; + + const algParam = (params.get("algorithm") || "SHA1").toUpperCase(); + const algorithmMap: Record = { + SHA1: "SHA-1", + "SHA-1": "SHA-1", + SHA256: "SHA-256", + "SHA-256": "SHA-256", + SHA512: "SHA-512", + "SHA-512": "SHA-512", + }; + + const normalized = normalizeNewAccountInput({ + name, + issuer, + secret, + type: type as "totp" | "hotp", + digits: params.get("digits") ?? 6, + period: params.get("period") ?? 30, + counter: params.get("counter") ?? 0, + algorithm: algorithmMap[algParam] ?? algParam, + }); + return normalized as ParsedOtpAuth | null; + } catch { + return null; + } +} diff --git a/extensions/goose-2fa/vendor/lib/search.ts b/extensions/goose-2fa/vendor/lib/search.ts new file mode 100644 index 00000000000..6f7b2506438 --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/search.ts @@ -0,0 +1,31 @@ +import type { AccountData } from "./types"; + +/** + * 统一的账户匹配引擎:lowercase + 多关键字 AND。 + * 命中字段:issuer、name、remark、note。 + * 空查询返回 true(全量)。 + */ +export function matchAccount(account: AccountData, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const haystack = [ + account.issuer, + account.name, + account.remark ?? "", + account.note ?? "", + ] + .join(" ") + .toLowerCase(); + return q + .split(/\s+/) + .filter(Boolean) + .every((token) => haystack.includes(token)); +} + +export function filterAccounts( + accounts: AccountData[], + query: string, +): AccountData[] { + if (!query.trim()) return accounts; + return accounts.filter((a) => matchAccount(a, query)); +} diff --git a/extensions/goose-2fa/vendor/lib/types.ts b/extensions/goose-2fa/vendor/lib/types.ts new file mode 100644 index 00000000000..f330ef514ab --- /dev/null +++ b/extensions/goose-2fa/vendor/lib/types.ts @@ -0,0 +1,53 @@ +export interface AccountLocation { + latitude: number; + longitude: number; + accuracy?: number; +} + +export interface AccountMeta { + timezone?: string; + locale?: string; + platform?: string; + location?: AccountLocation; +} + +export interface AccountData { + id: string; + name: string; + issuer: string; + secret: string; + type: "totp" | "hotp"; + digits: number; + period: number; + counter: number; + algorithm: "SHA-1" | "SHA-256" | "SHA-512"; + createdAt: number; + meta?: AccountMeta; + originalName?: string; + note?: string; + remark?: string; + /** 一级分组 id;null/缺省 = 未分组 */ + groupId?: string | null; + deletedAt?: number; +} + +export type NewAccountInput = Omit; + +export interface ParsedOtpAuth { + name: string; + issuer: string; + secret: string; + type: "totp" | "hotp"; + digits: number; + period: number; + counter: number; + algorithm: "SHA-1" | "SHA-256" | "SHA-512"; +} + +/** 用户一级保险柜/分组 */ +export interface VaultGroup { + id: string; + name: string; + order: number; + createdAt: number; +} diff --git a/extensions/goose-2fa/vendor/shared/sync-protocol.ts b/extensions/goose-2fa/vendor/shared/sync-protocol.ts new file mode 100644 index 00000000000..f0d33d7cdf7 --- /dev/null +++ b/extensions/goose-2fa/vendor/shared/sync-protocol.ts @@ -0,0 +1,82 @@ +/** + * uTools 与 Raycast 共享的数据源文件协议:文件契约、路径规范化、锁命名与内容比较规则。 + * 两端必须从这里取同一份定义;任一端口自己拼锁名或自己定比较规则,都会让同一文件出现两把锁。 + * 本模块只允许纯函数与常量,不引入平台 API。 + */ + +export const SYNC_APP = "goose-2fa"; +export const SYNC_FILE_VERSION = 2; +/** 数据源文件与 MCP 只读端一致的硬上限。 */ +export const SYNC_MAX_BYTES = 5 * 1024 * 1024; +/** 同目录独占写锁:`<数据源文件>.lock`。 */ +export const SYNC_LOCK_SUFFIX = ".lock"; +/** + * 抢锁的最长等待时间,两端用同一预算(uTools preload 为同步上下文,只能短暂忙等)。 + * 超时即拒绝写入:锁只能由持有者释放,或由用户在界面上显式清理,绝不按时间自动抢占—— + * 用 mtime 判「残留」不可靠(写入端可能刚好在慢盘/休眠中),删掉活锁会直接造成数据丢失。 + */ +export const SYNC_LOCK_WAIT_MS = 250; + +/** 锁文件内容里的持有者信息,用于向用户报告残留锁。 */ +export interface SyncLockInfo { + pid: number | null; + createdAt: number | null; +} + +export interface SyncFileStat { + mtimeMs: number; + size: number; +} + +/** + * 把路径规范成两端一致的形式:展开 `~`、折叠重复斜杠、解析 `.`/`..`、去掉尾部斜杠。 + * homeDir 由调用方传入(Node 侧是 os.homedir()),保持本模块无平台依赖。 + */ +export function normalizeSyncPath(input: string, homeDir = ""): string { + const trimmed = (input ?? "").trim(); + if (!trimmed) return ""; + let value = trimmed; + const home = homeDir.replace(/\/+$/, ""); + if (home && (value === "~" || value.startsWith("~/"))) value = `${home}${value.slice(1)}`; + const absolute = value.startsWith("/"); + const segments: string[] = []; + for (const part of value.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (segments.length > 0 && segments[segments.length - 1] !== "..") segments.pop(); + else if (!absolute) segments.push(".."); + continue; + } + segments.push(part); + } + return `${absolute ? "/" : ""}${segments.join("/")}`; +} + +/** 加锁目标路径。两端都必须调用它,不能各自拼后缀。 */ +export function syncLockPath(filePath: string): string { + return `${normalizeSyncPath(filePath)}${SYNC_LOCK_SUFFIX}`; +} + +/** 内容比较协议:逐字节相等才算同一版本,不做 JSON 语义比较(键序变化也算外部改动)。 */ +export function isSameSyncContent(a: string, b: string): boolean { + return a === b; +} + +/** 快速路径:mtime 与 size 都没变才认为文件没被动过;写前的最终依据永远是逐字节内容比较。 */ +export function isSameSyncStat(a: SyncFileStat, b: SyncFileStat): boolean { + return a.mtimeMs === b.mtimeMs && a.size === b.size; +} + +/** + * 解析锁文件内容(`pid\n创建时间\n`),只用于向用户报告残留锁与人工清理决策。 + * 本模块不提供「锁是否过期」这类判定:任何按时间自动删锁的路径都已被移除。 + */ +export function parseSyncLockInfo(content: string | null | undefined): SyncLockInfo { + const lines = (content ?? "").split("\n"); + const pid = Number.parseInt((lines[0] ?? "").trim(), 10); + const createdAt = Number.parseInt((lines[1] ?? "").trim(), 10); + return { + pid: Number.isFinite(pid) ? pid : null, + createdAt: Number.isFinite(createdAt) ? createdAt : null, + }; +} diff --git a/extensions/goose-2fa/vendor/shared/vault-ops.ts b/extensions/goose-2fa/vendor/shared/vault-ops.ts new file mode 100644 index 00000000000..b3e6f853855 --- /dev/null +++ b/extensions/goose-2fa/vendor/shared/vault-ops.ts @@ -0,0 +1,42 @@ +/** + * uTools 与 Raycast 共享的保险柜语义:HOTP 计数器下限与回收站过期规则。 + * 两端读同一份数据源文件,这两条规则必须完全一致,否则一端会把另一端已消费的 + * HOTP 计数器写回旧值、或让「30 天后自动删除」在另一端失效。 + */ +import type { AccountData } from "../lib/types"; + +export const TRASH_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +/** 回收站只保留 30 天;本地库与数据源文件共用同一条规则。 */ +export function pruneExpiredTrash(trash: AccountData[], now: number = Date.now()): AccountData[] { + const cutoff = now - TRASH_MAX_AGE_MS; + return trash.filter((account) => (account.deletedAt ?? 0) > cutoff); +} + +/** HOTP 下限按密钥与算法参数匹配,普通导入换 id 也不会绕过回退保护。 */ +export function hotpIdentity(account: AccountData): string { + return [account.algorithm, account.digits, account.type, account.secret].join("|"); +} + +/** + * 合并两边的 HOTP 计数器,只增不减:incoming 里低于 current 的计数器被抬回高水位。 + * raised=true 表示内存与磁盘不一致,调用方需要回写纠正值。 + */ +export function keepHotpFloor( + incoming: AccountData[], + current: AccountData[], +): { accounts: AccountData[]; raised: boolean } { + const floor = new Map(); + for (const account of current) { + if (account.type === "hotp") floor.set(hotpIdentity(account), account.counter); + } + let raised = false; + const accounts = incoming.map((account) => { + if (account.type !== "hotp") return account; + const lowest = floor.get(hotpIdentity(account)); + if (lowest === undefined || lowest <= account.counter) return account; + raised = true; + return { ...account, counter: lowest }; + }); + return { accounts, raised }; +}