mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
028d18d2f0 | ||
|
|
e2217fd230 | ||
|
|
1e1f5a6e2c | ||
|
|
f9e4b95002 | ||
|
|
2af0b416a8 | ||
|
|
8dff99de19 | ||
|
|
bbbdad73c5 | ||
|
|
18aea044fc | ||
|
|
2a4295c3ac | ||
|
|
d761eb768d | ||
|
|
f274b337bd | ||
|
|
cbdfc327be | ||
|
|
df531f4742 | ||
|
|
21d6d01f13 | ||
|
|
98eb954145 | ||
|
|
adc017aa4b | ||
|
|
d6996379c1 | ||
|
|
c1eb58e51f | ||
|
|
21e0bb16f0 |
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.5",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.2",
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
|
||||
|
||||
@@ -550,35 +550,35 @@
|
||||
|
||||
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
@@ -1292,7 +1292,7 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1438,7 +1438,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
|
||||
"vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
@@ -1596,11 +1596,7 @@
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
=======
|
||||
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
>>>>>>> main
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
@@ -1614,11 +1610,10 @@
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -1644,80 +1639,5 @@
|
||||
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
=======
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
|
||||
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
|
||||
"vitest/vite/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
>>>>>>> main
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -77,7 +77,6 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
@@ -194,7 +193,6 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
|
||||
@@ -237,74 +237,6 @@ xuezh snapshot --profile default
|
||||
xuezh review next --limit 10
|
||||
xuezh audio process-voice --file ./utterance.wav
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "hanzi-helper",
|
||||
displayName: "汉字助手",
|
||||
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: "github:example/hanzi-helper",
|
||||
systems: ["aarch64-darwin", "x86_64-linux"],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: ["HANZI_DB_PATH"],
|
||||
stateDirs: [".config/hanzi"],
|
||||
example:
|
||||
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
|
||||
},
|
||||
cliHelp: `汉字助手 - Chinese character learning and analysis
|
||||
|
||||
Usage:
|
||||
hanzi-helper [command]
|
||||
|
||||
Available Commands:
|
||||
lookup 查询汉字信息(笔画、部首、释义)
|
||||
radical 按部首检索汉字
|
||||
stroke 按笔画数筛选汉字
|
||||
words 生成汉字组词
|
||||
practice 练习汉字书写
|
||||
quiz 汉字听写测试
|
||||
|
||||
Flags:
|
||||
-h, --help help for hanzi-helper
|
||||
--json Output JSON
|
||||
`,
|
||||
},
|
||||
},
|
||||
rawSkillMd: `---
|
||||
name: hanzi-helper
|
||||
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
|
||||
---
|
||||
|
||||
# 汉字助手
|
||||
|
||||
## 功能介绍
|
||||
|
||||
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
hanzi-helper lookup --char 学
|
||||
hanzi-helper radical --name 木
|
||||
hanzi-helper stroke --count 8
|
||||
hanzi-helper words --char 大 --limit 20
|
||||
\`\`\`
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
|
||||
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
|
||||
- **组词生成**:输入一个汉字,自动生成常用词语和成语
|
||||
- **听写练习**:随机生成汉字听写测试,巩固学习效果
|
||||
|
||||
## 学习建议
|
||||
|
||||
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -448,22 +448,6 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
["SSH_KEY_DIR"],
|
||||
["generate", "rotate", "deploy", "list", "revoke"],
|
||||
),
|
||||
|
||||
// CJK Language Support (2)
|
||||
makeSkill(
|
||||
"nihongo-check",
|
||||
"日本語チェッカー",
|
||||
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
|
||||
["NIHONGO_API_KEY"],
|
||||
["check", "translate", "kanji", "grammar", "vocabulary"],
|
||||
),
|
||||
makeSkill(
|
||||
"hangukgeo-helper",
|
||||
"한국어 도우미",
|
||||
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
|
||||
["HANGUL_API_KEY"],
|
||||
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
|
||||
),
|
||||
];
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
|
||||
@@ -2552,51 +2552,6 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("packages detail returns stats for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin summary",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
},
|
||||
owner: {
|
||||
handle: "owner",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("packages file serves SKILL.md for skill README requests", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
|
||||
@@ -1433,7 +1433,6 @@ type PublicPackageDocLike = {
|
||||
compatibility?: Doc<"packages">["compatibility"];
|
||||
capabilities?: Doc<"packages">["capabilities"];
|
||||
verification?: Doc<"packages">["verification"];
|
||||
stats?: { downloads: number; installs: number; stars: number; versions: number };
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
|
||||
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
@@ -25,9 +25,7 @@ export function safeTextFileResponse(params: {
|
||||
size: number;
|
||||
headers?: HeadersInit;
|
||||
}) {
|
||||
const contentType =
|
||||
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
|
||||
const isSvg = isSvgLike(contentType, params.path);
|
||||
const isSvg = isSvgLike(params.contentType, params.path);
|
||||
|
||||
// For any text response that a browser might try to render, lock it down.
|
||||
// In particular, this prevents SVG <foreignObject> script execution from reading
|
||||
@@ -35,8 +33,8 @@ export function safeTextFileResponse(params: {
|
||||
const headers = mergeHeaders(
|
||||
params.headers,
|
||||
{
|
||||
"Content-Type": contentType
|
||||
? `${contentType}; charset=utf-8`
|
||||
"Content-Type": params.contentType
|
||||
? `${params.contentType}; charset=utf-8`
|
||||
: "text/plain; charset=utf-8",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
ETag: params.sha256,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import type { LlmEvalDimension } from "../lib/securityPrompt";
|
||||
import { publishVersionForUser } from "../skills";
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
@@ -208,7 +206,7 @@ type SkillSecuritySnapshot = {
|
||||
normalizedStatus: NormalizedSecurityStatus;
|
||||
confidence: string | null;
|
||||
summary: string | null;
|
||||
dimensions: LlmEvalDimension[] | null;
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
|
||||
guidance: string | null;
|
||||
findings: string | null;
|
||||
model: string | null;
|
||||
@@ -264,7 +262,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
|
||||
}
|
||||
|
||||
function hasLlmDimensionWarnings(
|
||||
dimensions: LlmEvalDimension[] | undefined,
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
|
||||
) {
|
||||
if (!Array.isArray(dimensions)) return false;
|
||||
return dimensions.some((dimension) => {
|
||||
@@ -735,7 +733,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
|
||||
contentType: file.contentType ?? null,
|
||||
})),
|
||||
security: security ?? undefined,
|
||||
},
|
||||
|
||||
@@ -22,10 +22,7 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
skillId: skill._id,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Skill not found") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
} catch {
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,173 +92,6 @@ describe("moderationEngine", () => {
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "word-document-organizer",
|
||||
displayName: "Word Document Organizer",
|
||||
summary: "Organize and restyle Word documents",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Generate a Python helper like this:",
|
||||
"```python",
|
||||
'doc_path = "${document_path}"',
|
||||
'output_path = "${output_path}" if "${output_path}" else doc_path',
|
||||
'template = "${style_template}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-docs",
|
||||
displayName: "API Docs",
|
||||
summary: "Shows users how to call an API",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this request template:",
|
||||
"```bash",
|
||||
'curl "https://example.com/search?q=${query}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this payload:",
|
||||
"```json",
|
||||
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
|
||||
).toContain("connection_id");
|
||||
});
|
||||
|
||||
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Call the Sheets bridge like this:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
|
||||
).toContain("spreadsheet ID");
|
||||
});
|
||||
|
||||
it("does not flag placeholder resource identifiers in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use placeholders in public docs:",
|
||||
"```json",
|
||||
'{"connection_id": "YOUR_CONNECTION_ID"}',
|
||||
"```",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Placeholder example first:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
"Real leaked URL later:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("blocks obfuscated terminal install payload prompts in markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "evil-installer",
|
||||
|
||||
@@ -50,14 +50,6 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
|
||||
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
|
||||
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
|
||||
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
|
||||
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
|
||||
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
|
||||
const GENERATED_SOURCE_CONTEXT_PATTERN =
|
||||
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
|
||||
const HARDCODED_CONNECTION_ID_PATTERN =
|
||||
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
|
||||
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
|
||||
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
|
||||
|
||||
function hasMaliciousInstallPrompt(content: string) {
|
||||
const hasTerminalInstruction =
|
||||
@@ -83,10 +75,6 @@ function truncateEvidence(evidence: string, maxLen = 160) {
|
||||
return `${evidence.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
function looksLikePlaceholderIdentifier(identifier: string) {
|
||||
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: ModerationFinding[],
|
||||
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
|
||||
@@ -104,14 +92,6 @@ function findFirstLine(content: string, pattern: RegExp) {
|
||||
return { line: 1, text: lines[0] ?? "" };
|
||||
}
|
||||
|
||||
function findLineAtIndex(content: string, index: number) {
|
||||
const line = content.slice(0, index).split("\n").length;
|
||||
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
||||
const nextNewline = content.indexOf("\n", index);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
return { line, text: content.slice(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
if (!CODE_EXTENSION.test(path)) return;
|
||||
|
||||
@@ -247,53 +227,6 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
|
||||
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
|
||||
) {
|
||||
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "User-controlled placeholder is embedded directly into generated source code.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
|
||||
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete connection_id instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
const spreadsheetUrlPattern = new RegExp(
|
||||
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
|
||||
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
|
||||
);
|
||||
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
|
||||
const spreadsheetId = spreadsheetUrlMatch[1];
|
||||
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
|
||||
|
||||
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
|
||||
@@ -12,13 +12,11 @@ export type ModerationFinding = {
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.0";
|
||||
export const MODERATION_ENGINE_VERSION = "v2.2.0";
|
||||
|
||||
export const REASON_CODES = {
|
||||
DANGEROUS_EXEC: "suspicious.dangerous_exec",
|
||||
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
|
||||
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
|
||||
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
|
||||
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
|
||||
EXFILTRATION: "suspicious.potential_exfiltration",
|
||||
OBFUSCATED_CODE: "suspicious.obfuscated_code",
|
||||
|
||||
@@ -5,8 +5,6 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
|
||||
|
||||
const SHARED_KEYS = [
|
||||
"name",
|
||||
"normalizedName",
|
||||
@@ -24,7 +22,7 @@ const SHARED_KEYS = [
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly SharedPackageKey[];
|
||||
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
|
||||
|
||||
const CAPABILITY_SHARED_KEYS = [
|
||||
"packageId",
|
||||
|
||||
@@ -47,55 +47,4 @@ describe("searchText", () => {
|
||||
it("normalize uses lowercase", () => {
|
||||
expect(__test.normalize("AbC")).toBe("abc");
|
||||
});
|
||||
|
||||
// CJK (Chinese, Japanese, Korean) support tests
|
||||
describe("CJK tokenization", () => {
|
||||
it("tokenizes Chinese text using Intl.Segmenter", () => {
|
||||
const tokens = tokenize("中文搜索");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(tokens).toContain("中文");
|
||||
expect(tokens).toContain("搜索");
|
||||
});
|
||||
|
||||
it("tokenizes mixed Chinese and English text", () => {
|
||||
const tokens = tokenize("React 组件开发");
|
||||
expect(tokens).toContain("react");
|
||||
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Chinese query tokens against Chinese skill names", () => {
|
||||
const queryTokens = tokenize("翻译");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches partial Chinese words", () => {
|
||||
const queryTokens = tokenize("助手");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Japanese text", () => {
|
||||
const tokens = tokenize("こんにちは世界");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles Korean text", () => {
|
||||
const tokens = tokenize("안녕하세요");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty or whitespace-only input", () => {
|
||||
expect(tokenize("")).toEqual([]);
|
||||
expect(tokenize(" ")).toEqual([]);
|
||||
expect(tokenize("!!!")).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects CJK language correctly", () => {
|
||||
expect(__test.detectCJKLanguage("中文")).toBe("zh");
|
||||
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
|
||||
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
|
||||
expect(__test.detectCJKLanguage("hello")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
-130
@@ -1,135 +1,12 @@
|
||||
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
|
||||
|
||||
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
|
||||
|
||||
let zhSegmenter: Intl.Segmenter | null = null;
|
||||
let jaSegmenter: Intl.Segmenter | null = null;
|
||||
let koSegmenter: Intl.Segmenter | null = null;
|
||||
|
||||
function getZhSegmenter(): Intl.Segmenter {
|
||||
if (!zhSegmenter) {
|
||||
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
|
||||
}
|
||||
return zhSegmenter;
|
||||
}
|
||||
|
||||
function getJaSegmenter(): Intl.Segmenter {
|
||||
if (!jaSegmenter) {
|
||||
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
|
||||
}
|
||||
return jaSegmenter;
|
||||
}
|
||||
|
||||
function getKoSegmenter(): Intl.Segmenter {
|
||||
if (!koSegmenter) {
|
||||
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
|
||||
}
|
||||
return koSegmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: split CJK text into individual characters.
|
||||
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
|
||||
*/
|
||||
function segmentCJKByChar(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (const ch of text) {
|
||||
if (CJK_RE.test(ch)) {
|
||||
tokens.push(ch);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
const WORD_RE = /[a-z0-9]+/g;
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary CJK language in a text
|
||||
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
|
||||
*/
|
||||
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
|
||||
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
|
||||
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
|
||||
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
|
||||
if (hiraganaCount + katakanaCount > 0) {
|
||||
return "ja";
|
||||
}
|
||||
if (hangulCount > 0) {
|
||||
return "ko";
|
||||
}
|
||||
if (chineseCount > 0) {
|
||||
return "zh";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment CJK text using Intl.Segmenter, falling back to character-level
|
||||
* tokenization when the API is unavailable.
|
||||
*/
|
||||
function segmentCJK(text: string): string[] {
|
||||
if (!hasSegmenter) return segmentCJKByChar(text);
|
||||
|
||||
const lang = detectCJKLanguage(text);
|
||||
if (!lang) return [];
|
||||
|
||||
let segmenter: Intl.Segmenter;
|
||||
switch (lang) {
|
||||
case "ja":
|
||||
segmenter = getJaSegmenter();
|
||||
break;
|
||||
case "ko":
|
||||
segmenter = getKoSegmenter();
|
||||
break;
|
||||
default:
|
||||
segmenter = getZhSegmenter();
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const { segment, isWordLike } of segmenter.segment(text)) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed && isWordLike) {
|
||||
segments.push(trimmed);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize text for search, supporting both English and CJK languages
|
||||
*
|
||||
* For English: uses word boundaries (whitespace, punctuation)
|
||||
* For CJK: uses Intl.Segmenter for proper word segmentation
|
||||
*/
|
||||
export function tokenize(value: string): string[] {
|
||||
if (!value) return [];
|
||||
|
||||
const normalized = normalize(value);
|
||||
|
||||
if (!CJK_RE.test(normalized)) {
|
||||
return normalized.match(/[a-z0-9]+/g) ?? [];
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
if (CJK_RE.test(part)) {
|
||||
const cjkTokens = segmentCJK(part);
|
||||
tokens.push(...cjkTokens);
|
||||
} else {
|
||||
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
|
||||
tokens.push(...asciiTokens);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
return normalize(value).match(WORD_RE) ?? [];
|
||||
}
|
||||
|
||||
export function matchesExactTokens(
|
||||
@@ -147,8 +24,4 @@ export function matchesExactTokens(
|
||||
);
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
normalize,
|
||||
detectCJKLanguage,
|
||||
segmentCJKByChar,
|
||||
};
|
||||
export const __test = { normalize, tokenize, matchesExactTokens };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -114,7 +113,6 @@ export async function publishVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => ({
|
||||
...file,
|
||||
path: sanitizePath(file.path),
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
}));
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new ConvexError("Invalid file paths");
|
||||
|
||||
@@ -6,8 +6,6 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
|
||||
|
||||
/**
|
||||
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
|
||||
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
|
||||
@@ -37,7 +35,7 @@ const SHARED_KEYS = [
|
||||
"moderationReason",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly SharedSkillKey[];
|
||||
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
|
||||
|
||||
/** Fields stored in the skillSearchDigest table. */
|
||||
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -102,11 +101,7 @@ export async function publishSoulVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => {
|
||||
const path = sanitizePath(file.path);
|
||||
if (!path) throw new ConvexError("Invalid file paths");
|
||||
return {
|
||||
...file,
|
||||
path,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
};
|
||||
return { ...file, path };
|
||||
});
|
||||
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
|
||||
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
|
||||
+11
-25
@@ -4,7 +4,6 @@ import {
|
||||
type PackageChannel,
|
||||
type PackageFamily,
|
||||
type PackagePublishRequest,
|
||||
type PackageVerificationTier,
|
||||
} from "clawhub-schema";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
@@ -128,7 +127,7 @@ type PublicPackageListItem = {
|
||||
latestVersion: string | null;
|
||||
capabilityTags: string[];
|
||||
executesCode: boolean;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
verificationTier: Doc<"packageSearchDigest">["verificationTier"] | null;
|
||||
};
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
@@ -164,14 +163,6 @@ type PublicPageCursorState = {
|
||||
};
|
||||
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
|
||||
|
||||
function stringifyId(value: Id<"users"> | Id<"publishers">): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringifyOptionalId(value: Id<"publishers"> | null | undefined): string | null {
|
||||
return value ? stringifyId(value) : null;
|
||||
}
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
@@ -221,7 +212,6 @@ type PublicPackageDoc = {
|
||||
capabilities?: Doc<"packages">["capabilities"];
|
||||
verification?: Doc<"packages">["verification"];
|
||||
scanStatus?: Doc<"packages">["scanStatus"];
|
||||
stats: Doc<"packages">["stats"];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -327,7 +317,6 @@ function toPublicPackage(
|
||||
capabilities: pkg.capabilities,
|
||||
verification: pkg.verification,
|
||||
scanStatus: pkg.scanStatus,
|
||||
stats: pkg.stats,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
};
|
||||
@@ -2144,11 +2133,6 @@ export const insertReleaseInternal = internalMutation({
|
||||
args.channel ??
|
||||
(existing?.channel === "private" ? "private" : publisherTrusted ? "official" : "community");
|
||||
const nextIsOfficial = nextChannel === "official";
|
||||
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
|
||||
const nextOwnerUserId = stringifyId(args.ownerUserId);
|
||||
const nextName = args.name;
|
||||
const nextRuntimeId = args.runtimeId ?? null;
|
||||
const nextVersion = args.version;
|
||||
if (existing) {
|
||||
const existingIsLegacyPersonalPackage =
|
||||
!existing.ownerPublisherId &&
|
||||
@@ -2160,18 +2144,18 @@ export const insertReleaseInternal = internalMutation({
|
||||
const existingOwnerKey = existing.ownerPublisherId
|
||||
? `publisher:${existing.ownerPublisherId}`
|
||||
: existingIsLegacyPersonalPackage
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
: `user:${existing.ownerUserId}`;
|
||||
const nextOwnerKey = nextOwnerPublisherId
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
: `user:${nextOwnerUserId}`;
|
||||
const nextOwnerKey = args.ownerPublisherId
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
: `user:${args.ownerUserId}`;
|
||||
if (existingOwnerKey !== nextOwnerKey) {
|
||||
throw new ConvexError("Package already exists and belongs to another publisher");
|
||||
}
|
||||
}
|
||||
if (existing && existing.family !== args.family) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
`Package "${args.name}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2182,7 +2166,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
existing.runtimeId !== args.runtimeId
|
||||
) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
`Package "${args.name}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (args.family === "code-plugin" && args.runtimeId) {
|
||||
@@ -2191,7 +2175,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
|
||||
throw new ConvexError(
|
||||
`Plugin id "${args.runtimeId}" is already claimed by another package`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2228,7 +2214,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
|
||||
if (releaseExists) throw new ConvexError(`Version ${args.version} already exists`);
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
+1
-2
@@ -388,8 +388,7 @@ const souls = defineTable({
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
|
||||
const skillVersions = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
|
||||
+7
-2
@@ -330,8 +330,13 @@ export const seedDemoSkills = internalMutation({
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
isSuspicious: false,
|
||||
createdAt,
|
||||
|
||||
@@ -5,10 +5,7 @@ vi.mock("@convex-dev/auth/server", () => ({
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
import {
|
||||
getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
getPendingScanSkillsInternal,
|
||||
} from "./skills";
|
||||
import { getPendingScanSkillsInternal } from "./skills";
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string;
|
||||
@@ -28,17 +25,6 @@ const getPendingScanSkillsHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStaticScanBackfillBatchHandler = (
|
||||
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
|
||||
Record<string, unknown>,
|
||||
{
|
||||
skills: Array<{ skillId: string; versionId: string; slug: string }>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.getPendingScanSkillsInternal", () => {
|
||||
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
|
||||
const recentSkills = [
|
||||
@@ -229,114 +215,6 @@ describe("skills.getPendingScanSkillsInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
||||
it("includes latest active skills with missing or stale static scan engine versions", async () => {
|
||||
const skills = [
|
||||
{
|
||||
_id: "skills:missing-static",
|
||||
_creationTime: 10,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:stale-static",
|
||||
_creationTime: 20,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:current-static",
|
||||
_creationTime: 30,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:current-static",
|
||||
slug: "current-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:hidden-static",
|
||||
_creationTime: 40,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
latestVersionId: "skillVersions:hidden-static",
|
||||
slug: "hidden-static",
|
||||
},
|
||||
];
|
||||
|
||||
const versions = new Map<string, unknown>([
|
||||
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
|
||||
[
|
||||
"skillVersions:stale-static",
|
||||
{
|
||||
_id: "skillVersions:stale-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:current-static",
|
||||
{
|
||||
_id: "skillVersions:current-static",
|
||||
staticScan: { engineVersion: "v2.4.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:hidden-static",
|
||||
{
|
||||
_id: "skillVersions:hidden-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ gt: () => ({}) });
|
||||
if (indexName !== "by_creation_time") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => skills,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getStaticScanBackfillBatchHandler(ctx, {
|
||||
batchSize: 10,
|
||||
cursor: 0,
|
||||
});
|
||||
|
||||
expect(result.skills).toEqual([
|
||||
{
|
||||
skillId: "skills:missing-static",
|
||||
versionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
skillId: "skills:stale-static",
|
||||
versionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
]);
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
versionId: string,
|
||||
|
||||
@@ -35,12 +35,6 @@ const getBySlugHandler = (
|
||||
image: string | null;
|
||||
bio?: string | null;
|
||||
} | null;
|
||||
latestVersion?: {
|
||||
files?: Array<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
} | null;
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
@@ -177,70 +171,4 @@ describe("skills.getBySlug", () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes misleading file MIME types in public version metadata", async () => {
|
||||
const ctx = makeCtx({
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "Public demo skill",
|
||||
ownerUserId: "users:1",
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 10,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
stars: 3,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "demo-owner",
|
||||
name: "Demo Owner",
|
||||
displayName: "Demo Owner",
|
||||
image: null,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: "skillVersions:1",
|
||||
_creationTime: 2,
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "",
|
||||
changelogSource: "user",
|
||||
files: [
|
||||
{
|
||||
path: "src/index.ts",
|
||||
size: 10,
|
||||
sha256: "deadbeef",
|
||||
contentType: "video/mp2t",
|
||||
},
|
||||
],
|
||||
createdBy: "users:1",
|
||||
createdAt: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.latestVersion?.files).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/index.ts",
|
||||
contentType: "application/typescript",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-220
@@ -1,5 +1,4 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v, type Value } from "convex/values";
|
||||
@@ -39,7 +38,6 @@ import { deriveModerationFlags } from "./lib/moderation";
|
||||
import { buildModerationSnapshot } from "./lib/moderationEngine";
|
||||
import {
|
||||
legacyFlagsFromVerdict,
|
||||
MODERATION_ENGINE_VERSION,
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
@@ -75,7 +73,6 @@ import {
|
||||
publishVersionForUser,
|
||||
queueHighlightedWebhook,
|
||||
} from "./lib/skillPublish";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
|
||||
import {
|
||||
@@ -463,7 +460,7 @@ async function syncSkillModerationFromLatestVersion(
|
||||
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
@@ -473,7 +470,7 @@ function buildConflictingSkillUrl(
|
||||
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
@@ -489,7 +486,7 @@ function buildSlugTakenErrorMessage(
|
||||
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
@@ -501,16 +498,6 @@ function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
| {
|
||||
_id: Id<"users"> | Id<"publishers">;
|
||||
handle?: string | null;
|
||||
deletedAt?: number | null;
|
||||
deactivatedAt?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
@@ -1232,7 +1219,7 @@ function toPublicSkillVersion(
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
contentType: file.contentType,
|
||||
})),
|
||||
parsed: version.parsed
|
||||
? {
|
||||
@@ -3699,56 +3686,6 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active latest skill versions whose static scan is missing or uses an older engine version.
|
||||
* Used to backfill new static rules onto already-published skills.
|
||||
*/
|
||||
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 25;
|
||||
const cursor = args.cursor ?? 0;
|
||||
|
||||
const candidates = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 4);
|
||||
|
||||
const results: Array<{
|
||||
skillId: Id<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
slug: string;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const skill of candidates) {
|
||||
nextCursor = skill._creationTime;
|
||||
if (results.length >= batchSize) break;
|
||||
|
||||
if (skill.softDeletedAt) continue;
|
||||
if ((skill.moderationStatus ?? "active") !== "active") continue;
|
||||
if (!skill.latestVersionId) continue;
|
||||
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version) continue;
|
||||
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
|
||||
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
const done = candidates.length < batchSize * 4;
|
||||
return { skills: results, nextCursor, done };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get skills with stale moderationReason that have vtAnalysis cached.
|
||||
* Used to sync moderationReason with cached VT results.
|
||||
@@ -3847,159 +3784,6 @@ export const getPendingVTSkillsInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
return { ok: true as const, skipped: "not_latest" as const };
|
||||
}
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch: {
|
||||
...basePatch,
|
||||
updatedAt: now,
|
||||
},
|
||||
now,
|
||||
});
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
reason:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
|
||||
"malicious.static_scan",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, status: args.staticScan.status };
|
||||
},
|
||||
});
|
||||
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescanned: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
let rescanned = args.rescanned ?? 0;
|
||||
for (const skill of batch.skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: skill.skillId,
|
||||
versionId: skill.versionId,
|
||||
});
|
||||
rescanned += 1;
|
||||
}
|
||||
|
||||
if (!batch.done) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
|
||||
cursor: batch.nextCursor,
|
||||
batchSize,
|
||||
rescanned,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rescanned,
|
||||
nextCursor: batch.nextCursor,
|
||||
done: batch.done,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertAdmin(user);
|
||||
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
|
||||
batchSize: args.batchSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Emergency escalation by skillId for legacy rows without sha256hash.
|
||||
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
|
||||
|
||||
+1
-74
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
|
||||
import { getSoulBySlugInternal, insertVersion } from "./souls";
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
|
||||
@@ -10,7 +10,6 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
|
||||
const getSoulBySlugInternalHandler = (
|
||||
getSoulBySlugInternal as unknown as WrappedHandler<{ slug: string }>
|
||||
)._handler;
|
||||
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)._handler;
|
||||
|
||||
describe("souls.insertVersion", () => {
|
||||
it("throws a soul-specific ownership error for non-owners", async () => {
|
||||
@@ -140,75 +139,3 @@ describe("souls.insertVersion", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("souls.list", () => {
|
||||
it("uses the active browse index and only takes the requested limit", async () => {
|
||||
let requestedIndex: string | null = null;
|
||||
let requestedSoftDeletedAt: number | undefined;
|
||||
let requestedLimit: number | null = null;
|
||||
|
||||
const result = await listHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "souls") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
build:
|
||||
| ((q: { eq: (field: string, value: undefined) => unknown }) => unknown)
|
||||
| undefined,
|
||||
) => {
|
||||
requestedIndex = name;
|
||||
const q = {
|
||||
eq: (field: string, value: undefined) => {
|
||||
if (field !== "softDeletedAt") throw new Error(`unexpected field ${field}`);
|
||||
requestedSoftDeletedAt = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build?.(q);
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (limit: number) => {
|
||||
requestedLimit = limit;
|
||||
return [
|
||||
{
|
||||
_id: "souls:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
summary: "A demo soul",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
stats: { downloads: 1, stars: 2, versions: 3, comments: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ limit: 7 } as never,
|
||||
);
|
||||
|
||||
expect(requestedIndex).toBe("by_active_updated");
|
||||
expect(requestedSoftDeletedAt).toBeUndefined();
|
||||
expect(requestedLimit).toBe(7);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: "souls:1",
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -138,10 +138,11 @@ export const list = query({
|
||||
}
|
||||
const entries = await ctx.db
|
||||
.query("souls")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
.take(limit * 5);
|
||||
return entries
|
||||
.filter((soul) => !soul.softDeletedAt)
|
||||
.slice(0, limit)
|
||||
.map((soul) => toPublicSoul(soul))
|
||||
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
|
||||
},
|
||||
|
||||
+1
-3
@@ -34,8 +34,6 @@ export const toggle = mutation({
|
||||
return { starred: false };
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) throw new Error("Skill not found");
|
||||
|
||||
await ctx.db.insert("stars", {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
@@ -72,7 +70,7 @@ export const addStarInternal = internalMutation({
|
||||
args: { userId: v.id("users"), skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
const existing = await ctx.db
|
||||
.query("stars")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
||||
|
||||
@@ -230,11 +230,8 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
let scanned = 0;
|
||||
let patched = 0;
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue;
|
||||
scanned += 1;
|
||||
// Count actual star records for this skill
|
||||
const starRecords = await ctx.db
|
||||
.query("stars")
|
||||
@@ -266,7 +263,7 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
}
|
||||
|
||||
return {
|
||||
scanned,
|
||||
scanned: page.length,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
|
||||
@@ -1075,81 +1075,6 @@ describe("users.list", () => {
|
||||
expect(result.items[0]?.handle).toBe("alice");
|
||||
});
|
||||
|
||||
it("includes an exact older handle match outside the bounded scan", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [
|
||||
...Array.from({ length: 500 }, (_value, index) => ({
|
||||
_id: `users:recent-${index}`,
|
||||
_creationTime: 10_000 - index,
|
||||
handle: `recent-${index}`,
|
||||
role: "user",
|
||||
})),
|
||||
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
|
||||
];
|
||||
const { ctx, take, collect } = makeListCtx(users);
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]?._id).toBe("users:older");
|
||||
});
|
||||
|
||||
it("includes an exact personal publisher handle match without a full collect", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
|
||||
const { ctx, take, collect } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
lmlukef: {
|
||||
_id: "publishers:lmlukef",
|
||||
kind: "user",
|
||||
handle: "lmlukef",
|
||||
linkedUserId: "users:owner",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:owner": {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "luke",
|
||||
name: "different-gh-login",
|
||||
displayName: "Luke",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
});
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "luke",
|
||||
displayName: "Luke",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps large limit and search scan size", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
|
||||
@@ -62,9 +62,6 @@ When no proxy variable is set, behavior is unchanged (direct connections).
|
||||
Stores your API token + cached registry URL.
|
||||
|
||||
- macOS: `~/Library/Application Support/clawhub/config.json`
|
||||
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
|
||||
- Windows: `%APPDATA%\\clawhub\\config.json`
|
||||
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path
|
||||
- override: `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`)
|
||||
|
||||
## Commands
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
@@ -105,7 +104,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.5",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@ clawhub login --token clh_...
|
||||
Notes:
|
||||
|
||||
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
|
||||
- Default config path:
|
||||
- macOS: `~/Library/Application Support/clawhub/config.json`
|
||||
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
|
||||
- Windows: `%APPDATA%\\clawhub\\config.json`
|
||||
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path.
|
||||
- Override via `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`).
|
||||
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -57,14 +57,6 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -196,7 +188,6 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
Vendored
-13
@@ -46,13 +46,6 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
}, {}>;
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
export declare const PackageStatsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
}, {}>;
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
@@ -268,12 +261,6 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
stats?: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
} | undefined;
|
||||
} | null;
|
||||
owner: {
|
||||
handle: string | null;
|
||||
|
||||
Vendored
-7
@@ -40,12 +40,6 @@ export const PackageVerificationSummarySchema = type({
|
||||
hasProvenance: "boolean?",
|
||||
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
|
||||
});
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -157,7 +151,6 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-2
@@ -3,5 +3,3 @@ export declare const TEXT_FILE_EXTENSION_SET: Set<string>;
|
||||
export declare const TEXT_CONTENT_TYPES: readonly ["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript", "application/typescript", "application/markdown", "image/svg+xml"];
|
||||
export declare const TEXT_CONTENT_TYPE_SET: Set<string>;
|
||||
export declare function isTextContentType(contentType: string): boolean;
|
||||
export declare function guessTextContentType(path: string): string | undefined;
|
||||
export declare function normalizeTextContentType(path: string, contentType?: string | null): string | undefined;
|
||||
|
||||
Vendored
-35
@@ -53,26 +53,6 @@ const RAW_TEXT_CONTENT_TYPES = [
|
||||
];
|
||||
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
|
||||
export const TEXT_CONTENT_TYPE_SET = new Set(TEXT_CONTENT_TYPES);
|
||||
const CANONICAL_TEXT_CONTENT_TYPES = {
|
||||
md: "text/markdown",
|
||||
mdx: "text/markdown",
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
json5: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
toml: "application/toml",
|
||||
js: "application/javascript",
|
||||
cjs: "application/javascript",
|
||||
mjs: "application/javascript",
|
||||
jsx: "application/javascript",
|
||||
ts: "application/typescript",
|
||||
mts: "application/typescript",
|
||||
cts: "application/typescript",
|
||||
tsx: "application/typescript",
|
||||
xml: "application/xml",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
export function isTextContentType(contentType) {
|
||||
if (!contentType)
|
||||
return false;
|
||||
@@ -83,19 +63,4 @@ export function isTextContentType(contentType) {
|
||||
return true;
|
||||
return TEXT_CONTENT_TYPE_SET.has(normalized);
|
||||
}
|
||||
export function guessTextContentType(path) {
|
||||
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
|
||||
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext))
|
||||
return undefined;
|
||||
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
|
||||
}
|
||||
export function normalizeTextContentType(path, contentType) {
|
||||
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
const guessed = guessTextContentType(path);
|
||||
if (!guessed)
|
||||
return normalized || undefined;
|
||||
if (isTextContentType(normalized))
|
||||
return normalized;
|
||||
return guessed;
|
||||
}
|
||||
//# sourceMappingURL=textFiles.js.map
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,4BAA4B,GAA2B;IAC3D,EAAE,EAAE,eAAe;IACnB,GAAG,EAAE,eAAe;IACpB,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,kBAAkB;IACzB,IAAI,EAAE,kBAAkB;IACxB,GAAG,EAAE,kBAAkB;IACvB,IAAI,EAAE,kBAAkB;IACxB,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,eAAe;CACrB,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IAChE,OAAO,4BAA4B,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY,EAAE,WAA2B;IAChF,MAAM,UAAU,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7E,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO;QAAE,OAAO,UAAU,IAAI,SAAS,CAAC;IAC7C,IAAI,iBAAiB,CAAC,UAAU,CAAC;QAAE,OAAO,UAAU,CAAC;IACrD,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
||||
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC"}
|
||||
@@ -57,14 +57,6 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -198,7 +190,6 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as schema from ".";
|
||||
import {
|
||||
guessTextContentType,
|
||||
isTextContentType,
|
||||
normalizeTextContentType,
|
||||
TEXT_FILE_EXTENSION_SET,
|
||||
} from "./textFiles";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "./textFiles";
|
||||
|
||||
describe("clawhub-schema textFiles", () => {
|
||||
it("exports text-file extension set", () => {
|
||||
@@ -21,25 +16,8 @@ describe("clawhub-schema textFiles", () => {
|
||||
expect(isTextContentType("application/octet-stream")).toBe(false);
|
||||
});
|
||||
|
||||
it("guesses canonical content types for text files", () => {
|
||||
expect(guessTextContentType("src/index.ts")).toBe("application/typescript");
|
||||
expect(guessTextContentType("README.md")).toBe("text/markdown");
|
||||
expect(guessTextContentType("image.png")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes misleading MIME types for text files", () => {
|
||||
expect(normalizeTextContentType("src/index.ts", "video/mp2t")).toBe("application/typescript");
|
||||
expect(normalizeTextContentType("README.md", "text/markdown; charset=utf-8")).toBe(
|
||||
"text/markdown",
|
||||
);
|
||||
expect(normalizeTextContentType("image.png", "image/png")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("re-exports helpers from index", () => {
|
||||
expect(typeof schema.isTextContentType).toBe("function");
|
||||
expect(schema.isTextContentType("application/markdown")).toBe(true);
|
||||
expect(schema.normalizeTextContentType("src/index.ts", "video/mp2t")).toBe(
|
||||
"application/typescript",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,27 +57,6 @@ const RAW_TEXT_CONTENT_TYPES = [
|
||||
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
|
||||
export const TEXT_CONTENT_TYPE_SET = new Set<string>(TEXT_CONTENT_TYPES);
|
||||
|
||||
const CANONICAL_TEXT_CONTENT_TYPES: Record<string, string> = {
|
||||
md: "text/markdown",
|
||||
mdx: "text/markdown",
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
json5: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
toml: "application/toml",
|
||||
js: "application/javascript",
|
||||
cjs: "application/javascript",
|
||||
mjs: "application/javascript",
|
||||
jsx: "application/javascript",
|
||||
ts: "application/typescript",
|
||||
mts: "application/typescript",
|
||||
cts: "application/typescript",
|
||||
tsx: "application/typescript",
|
||||
xml: "application/xml",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
export function isTextContentType(contentType: string) {
|
||||
if (!contentType) return false;
|
||||
const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
@@ -85,17 +64,3 @@ export function isTextContentType(contentType: string) {
|
||||
if (normalized.startsWith("text/")) return true;
|
||||
return TEXT_CONTENT_TYPE_SET.has(normalized);
|
||||
}
|
||||
|
||||
export function guessTextContentType(path: string) {
|
||||
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
|
||||
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext)) return undefined;
|
||||
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
|
||||
}
|
||||
|
||||
export function normalizeTextContentType(path: string, contentType?: string | null) {
|
||||
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
const guessed = guessTextContentType(path);
|
||||
if (!guessed) return normalized || undefined;
|
||||
if (isTextContentType(normalized)) return normalized;
|
||||
return guessed;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const convexQueryMock = vi.fn().mockResolvedValue(0);
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -39,20 +30,11 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
setMode: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -84,10 +66,32 @@ vi.mock("../lib/site", () => ({
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/convexError", () => ({
|
||||
getUserFacingConvexError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
query: convexQueryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
skills: {
|
||||
countPublicSkills: "countPublicSkills",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/numberFormat", () => ({
|
||||
formatCompactStat: (n: number) => String(n),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
@@ -98,7 +102,9 @@ vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Header", () => {
|
||||
@@ -110,34 +116,15 @@ describe("Header", () => {
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
it("renders a plain Skills tab without fetching a count", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
convexQueryMock.mockClear();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByText("Theme")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Souls")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hub" }));
|
||||
expect(setThemeMock).toHaveBeenCalledWith("hub");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme family/i }));
|
||||
expect(setThemeMock).toHaveBeenCalledWith("claw");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Souls")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
|
||||
expect(convexQueryMock).not.toHaveBeenCalled();
|
||||
});});
|
||||
|
||||
@@ -277,12 +277,6 @@ describe("plugins publish route", () => {
|
||||
|
||||
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
|
||||
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
|
||||
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
|
||||
expect(docsLink.getAttribute("href")).toBe(
|
||||
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
|
||||
);
|
||||
expect(docsLink.getAttribute("target")).toBe("_blank");
|
||||
expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+53
-195
@@ -1,7 +1,7 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
@@ -24,33 +24,18 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
|
||||
const NAV_ICONS: Record<NavIconName, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
plug: Plug,
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
const THEME_FAMILY_ICONS: Record<string, ComponentType<{ size?: number; className?: string }>> = {
|
||||
claw: Ghost,
|
||||
hub: Plug,
|
||||
};
|
||||
|
||||
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { theme, mode, setMode, setTheme } = useThemeMode();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
@@ -75,44 +60,20 @@ export default function Header() {
|
||||
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
|
||||
const ThemeFamilyIcon = THEME_FAMILY_ICONS[theme] ?? Wrench;
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
const setTheme = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
currentTheme: mode,
|
||||
setTheme: (value) => {
|
||||
const nextMode = value as "system" | "light" | "dark";
|
||||
applyTheme(nextMode, theme);
|
||||
applyTheme(nextMode);
|
||||
setMode(nextMode);
|
||||
},
|
||||
context: { element: toggleRef.current },
|
||||
});
|
||||
};
|
||||
|
||||
const setThemeFamily = (nextTheme: string) => {
|
||||
applyTheme(mode, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
};
|
||||
|
||||
const cycleThemeFamily = () => {
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
THEME_OPTIONS.findIndex((option) => option.value === theme),
|
||||
);
|
||||
const nextTheme = THEME_OPTIONS[(currentIndex + 1) % THEME_OPTIONS.length]?.value ?? "claw";
|
||||
setThemeFamily(nextTheme);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
|
||||
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = navSearchQuery.trim();
|
||||
@@ -130,112 +91,15 @@ export default function Header() {
|
||||
<div className="navbar-inner">
|
||||
{/* Row 1: Brand + Search + Actions */}
|
||||
<div className="navbar-top">
|
||||
<div className="nav-mobile">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<button
|
||||
className="nav-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<SheetContent side="left" className="mobile-nav-sheet">
|
||||
<SheetHeader className="pr-10">
|
||||
<SheetTitle>{siteName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse sections, switch theme, and access account actions.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mobile-nav-section">
|
||||
{isSoulMode ? (
|
||||
<SheetClose asChild>
|
||||
<a href={clawHubUrl} className="mobile-nav-link">
|
||||
ClawHub
|
||||
</a>
|
||||
</SheetClose>
|
||||
) : null}
|
||||
{primaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme family</div>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeFamily(option.value);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{theme === option.value ? <span className="mobile-nav-meta">Selected</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme mode</div>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("system");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("light");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("dark");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
<span className="brand-name">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
@@ -259,52 +123,58 @@ export default function Header() {
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<div className="theme-picker-desktop" aria-label={`Theme family, current ${themeLabel}`}>
|
||||
<div className="theme-family-toggle" role="group" aria-label="Theme family">
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="theme-family-button"
|
||||
data-state={theme === option.value ? "on" : "off"}
|
||||
aria-pressed={theme === option.value}
|
||||
onClick={() => setThemeFamily(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
<div className="nav-mobile">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="nav-mobile-trigger" type="button" aria-label="Open menu">
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isSoulMode ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={clawHubUrl}>ClawHub</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{primaryItems.map((item) => (
|
||||
<DropdownMenuItem key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-cycle-group" aria-label="Theme controls">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button theme-cycle-button-family"
|
||||
onClick={cycleThemeFamily}
|
||||
aria-label={`Cycle theme family. Current: ${themeLabel}`}
|
||||
title={`Theme family: ${themeLabel}`}
|
||||
>
|
||||
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button theme-cycle-button-mode"
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={`Cycle theme mode. Current: ${mode}`}
|
||||
title={`Theme mode: ${mode}`}
|
||||
>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{secondaryItems.map((item) => (
|
||||
<DropdownMenuItem key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<ToggleGroup
|
||||
className="theme-mode-toggle"
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setThemeMode(value as "system" | "light" | "dark");
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label={`Theme mode, ${themeLabel} preset`}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -453,15 +323,3 @@ function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function getThemeModeIcon(mode: "system" | "light" | "dark") {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
return Sun;
|
||||
case "dark":
|
||||
return Moon;
|
||||
case "system":
|
||||
default:
|
||||
return Monitor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
const OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL =
|
||||
'https://docs.openclaw.ai/plugins/sdk-setup#package-metadata';
|
||||
|
||||
export function PackageSourceChooser(props: {
|
||||
files: File[];
|
||||
totalBytes: number;
|
||||
@@ -158,12 +155,7 @@ export function PackageSourceChooser(props: {
|
||||
<Badge variant="accent">
|
||||
Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(", ")}. Add
|
||||
these fields to <code>package.json</code> before publishing. See{" "}
|
||||
<a
|
||||
href={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
<a href="/plugins/sdk-setup#package-metadata" className="underline">
|
||||
Plugin Setup and Config
|
||||
</a>
|
||||
.
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
selectDefaultFilePath,
|
||||
sortVersionsBySemver,
|
||||
} from "../lib/diffing";
|
||||
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
|
||||
import { Button } from "./ui/button";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
|
||||
@@ -233,18 +232,15 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco || typeof document === "undefined") return;
|
||||
const syncTheme = () => applyMonacoTheme(monaco);
|
||||
const observer = new MutationObserver(syncTheme);
|
||||
const observer = new MutationObserver(() => {
|
||||
applyMonacoTheme(monaco);
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
const removeThemeListener = onThemeChange(syncTheme);
|
||||
syncTheme();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
removeThemeListener();
|
||||
};
|
||||
applyMonacoTheme(monaco);
|
||||
return () => observer.disconnect();
|
||||
}, [monaco]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -443,7 +439,7 @@ function renderOptions(options: VersionOption[]) {
|
||||
|
||||
function getMonacoThemeName() {
|
||||
if (typeof document === "undefined") return "clawhub-light";
|
||||
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
|
||||
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
|
||||
}
|
||||
|
||||
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
|
||||
@@ -479,7 +475,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
|
||||
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
|
||||
const background = surface;
|
||||
const gutter = surfaceMuted;
|
||||
const isDark = isDarkThemeResolved();
|
||||
const isDark = document.documentElement.dataset.theme === "dark";
|
||||
const base = isDark ? "vs-dark" : "vs";
|
||||
|
||||
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* Lightweight custom theme support for ClawHub.
|
||||
*
|
||||
* Accepts tweakcn theme URLs, bare tweakcn names, tweakcn JSON payloads,
|
||||
* or raw CSS variable blocks, then maps them into ClawHub's token system.
|
||||
*/
|
||||
|
||||
const SUPPORTED_COLOR_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
"info",
|
||||
"info-foreground",
|
||||
"success",
|
||||
"success-foreground",
|
||||
"warning",
|
||||
"warning-foreground",
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_DESIGN_VARS = ["radius"] as const;
|
||||
const SUPPORTED_FONT_VARS = ["font-sans", "font-serif", "font-mono"] as const;
|
||||
|
||||
const ALL_SUPPORTED_VARS = new Set<string>([
|
||||
...SUPPORTED_COLOR_VARS,
|
||||
...SUPPORTED_DESIGN_VARS,
|
||||
...SUPPORTED_FONT_VARS,
|
||||
]);
|
||||
|
||||
const CUSTOM_THEME_STORAGE_KEY = "clawhub-custom-theme";
|
||||
const CUSTOM_THEME_STYLE_ID = "clawhub-custom-theme-style";
|
||||
const CUSTOM_THEME_FONT_LINK_ID = "clawhub-custom-theme-fonts";
|
||||
|
||||
const SYSTEM_FONTS = new Set([
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"blinkmacsystemfont",
|
||||
"segoe ui",
|
||||
"roboto",
|
||||
"helvetica neue",
|
||||
"arial",
|
||||
"sans-serif",
|
||||
"serif",
|
||||
"monospace",
|
||||
"sf mono",
|
||||
"sfmono-regular",
|
||||
"consolas",
|
||||
"liberation mono",
|
||||
"menlo",
|
||||
"courier new",
|
||||
"dm sans",
|
||||
"georgia",
|
||||
"times new roman",
|
||||
"times",
|
||||
"ui-monospace",
|
||||
"ui-sans-serif",
|
||||
"ui-serif",
|
||||
"bricolage grotesque",
|
||||
"manrope",
|
||||
"ibm plex mono",
|
||||
]);
|
||||
|
||||
export interface CustomThemeData {
|
||||
name?: string | undefined;
|
||||
source?: string | undefined;
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
}
|
||||
|
||||
function hasDom(): boolean {
|
||||
return (
|
||||
typeof document !== "undefined" &&
|
||||
typeof document.getElementById === "function" &&
|
||||
typeof document.createElement === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function extractBraceContent(css: string, startAfterBrace: number): string {
|
||||
let depth = 1;
|
||||
let i = startAfterBrace;
|
||||
while (i < css.length && depth > 0) {
|
||||
if (css[i] === "{") depth++;
|
||||
else if (css[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
return css.substring(startAfterBrace, i - 1);
|
||||
}
|
||||
|
||||
function extractVariables(block: string): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
const regex = /--([\w-]+)\s*:\s*([^;]+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(block)) !== null) {
|
||||
const name = match[1]?.trim();
|
||||
const value = match[2]?.trim();
|
||||
if (!name || !value || !ALL_SUPPORTED_VARS.has(name)) continue;
|
||||
vars[name] = value;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
function filterSupported(vars: Record<string, string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (ALL_SUPPORTED_VARS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseThemeCSS(css: string): CustomThemeData {
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const light: Record<string, string> = {};
|
||||
const dark: Record<string, string> = {};
|
||||
|
||||
const rootRegex = /:root\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rootRegex.exec(cleaned)) !== null) {
|
||||
const start = match.index + match[0].length;
|
||||
Object.assign(light, extractVariables(extractBraceContent(cleaned, start)));
|
||||
}
|
||||
|
||||
const darkRegex = /\.dark\s*\{/g;
|
||||
while ((match = darkRegex.exec(cleaned)) !== null) {
|
||||
const start = match.index + match[0].length;
|
||||
Object.assign(dark, extractVariables(extractBraceContent(cleaned, start)));
|
||||
}
|
||||
|
||||
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
|
||||
const vars = extractVariables(cleaned);
|
||||
Object.assign(light, vars);
|
||||
Object.assign(dark, vars);
|
||||
}
|
||||
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
export function parseTweakcnJSON(json: unknown): CustomThemeData {
|
||||
if (!json || typeof json !== "object") {
|
||||
throw new Error("Invalid theme JSON");
|
||||
}
|
||||
|
||||
const obj = json as Record<string, unknown>;
|
||||
const name = typeof obj.name === "string" ? obj.name : undefined;
|
||||
const cssVars = obj.cssVars as Record<string, Record<string, string>> | undefined;
|
||||
|
||||
if (!cssVars || typeof cssVars !== "object") {
|
||||
throw new Error('Theme JSON missing "cssVars" object');
|
||||
}
|
||||
|
||||
const light = filterSupported({ ...cssVars.theme, ...cssVars.light });
|
||||
const dark = filterSupported({ ...cssVars.theme, ...cssVars.dark });
|
||||
|
||||
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
|
||||
throw new Error("No supported theme variables found in JSON");
|
||||
}
|
||||
|
||||
return { name, light, dark };
|
||||
}
|
||||
|
||||
const TWEAKCN_URL_PATTERNS = [
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/r\/themes\/([^/?#]+)/,
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/themes\/([^/?#]+)/,
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/editor\/theme\?theme=([^&#]+)/,
|
||||
];
|
||||
|
||||
export function isTweakcnURL(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return TWEAKCN_URL_PATTERNS.some((re) => re.test(trimmed));
|
||||
}
|
||||
|
||||
function extractTweakcnThemeId(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
for (const re of TWEAKCN_URL_PATTERNS) {
|
||||
const match = trimmed.match(re);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBareThemeName(input: string): boolean {
|
||||
return /^[a-zA-Z0-9][\w-]*$/.test(input);
|
||||
}
|
||||
|
||||
export async function fetchTweakcnTheme(urlOrName: string): Promise<CustomThemeData> {
|
||||
const themeId = isBareThemeName(urlOrName) ? urlOrName : extractTweakcnThemeId(urlOrName);
|
||||
|
||||
if (!themeId) {
|
||||
throw new Error("Could not extract theme ID from URL");
|
||||
}
|
||||
|
||||
const response = await fetch(`https://tweakcn.com/r/themes/${encodeURIComponent(themeId)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch theme: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return parseTweakcnJSON(await response.json());
|
||||
}
|
||||
|
||||
export async function parseThemeInput(input: string): Promise<CustomThemeData> {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (isTweakcnURL(trimmed)) {
|
||||
return fetchTweakcnTheme(trimmed);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return parseTweakcnJSON(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// fall through to CSS parsing
|
||||
}
|
||||
}
|
||||
|
||||
if (isBareThemeName(trimmed)) {
|
||||
return fetchTweakcnTheme(trimmed);
|
||||
}
|
||||
|
||||
const parsed = parseThemeCSS(trimmed);
|
||||
if (Object.keys(parsed.light).length === 0 && Object.keys(parsed.dark).length === 0) {
|
||||
throw new Error("No theme variables found. Paste CSS, a tweakcn theme URL, or a theme name.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractGoogleFonts(theme: CustomThemeData): string[] {
|
||||
const fonts = new Set<string>();
|
||||
|
||||
for (const mode of [theme.light, theme.dark]) {
|
||||
for (const key of SUPPORTED_FONT_VARS) {
|
||||
const value = mode[key];
|
||||
if (!value) continue;
|
||||
const families = value.split(",").map((part) => part.trim().replace(/^["']|["']$/g, ""));
|
||||
for (const family of families) {
|
||||
if (family && !SYSTEM_FONTS.has(family.toLowerCase())) {
|
||||
fonts.add(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fonts);
|
||||
}
|
||||
|
||||
function loadGoogleFonts(fonts: string[]): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
|
||||
if (fonts.length === 0) return;
|
||||
|
||||
const families = fonts.map((font) => `family=${font.replace(/\s+/g, "+")}:wght@300..800`).join("&");
|
||||
const link = document.createElement("link");
|
||||
link.id = CUSTOM_THEME_FONT_LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function unloadGoogleFonts(): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
|
||||
}
|
||||
|
||||
function getVar(vars: Record<string, string>, key: string, fallback: string): string {
|
||||
return vars[key] ?? fallback;
|
||||
}
|
||||
|
||||
function buildModeLines(vars: Record<string, string>): string[] {
|
||||
const background = getVar(vars, "background", "var(--bg)");
|
||||
const foreground = getVar(vars, "foreground", "var(--ink)");
|
||||
const card = getVar(vars, "card", background);
|
||||
const secondary = getVar(vars, "secondary", card);
|
||||
const muted = getVar(vars, "muted", secondary);
|
||||
const primary = getVar(vars, "primary", "var(--accent)");
|
||||
const primaryForeground = getVar(vars, "primary-foreground", "var(--accent-fg)");
|
||||
const accent = getVar(vars, "accent", primary);
|
||||
const border = getVar(vars, "border", "var(--line)");
|
||||
const input = getVar(vars, "input", border);
|
||||
const ring = getVar(vars, "ring", primary);
|
||||
const destructive = getVar(vars, "destructive", "var(--status-error-fg)");
|
||||
const success = getVar(vars, "success", "var(--seafoam)");
|
||||
const warning = getVar(vars, "warning", "var(--gold)");
|
||||
const mutedForeground = getVar(vars, "muted-foreground", foreground);
|
||||
const radius = vars.radius;
|
||||
const fontSans = vars["font-sans"];
|
||||
const fontMono = vars["font-mono"];
|
||||
|
||||
const lines = [
|
||||
` --bg: ${background};`,
|
||||
` --bg-soft: ${muted};`,
|
||||
` --surface: ${card};`,
|
||||
` --surface-muted: ${secondary};`,
|
||||
` --nav-bg: color-mix(in srgb, ${background} 96%, transparent);`,
|
||||
` --ink: ${foreground};`,
|
||||
` --ink-soft: ${mutedForeground};`,
|
||||
` --accent: ${primary};`,
|
||||
` --accent-fg: ${primaryForeground};`,
|
||||
` --accent-deep: ${accent};`,
|
||||
` --accent-subtle: color-mix(in srgb, ${primary} 14%, transparent);`,
|
||||
` --seafoam: ${success};`,
|
||||
` --gold: ${warning};`,
|
||||
` --line: color-mix(in srgb, ${border} 72%, transparent);`,
|
||||
` --border-ui: color-mix(in srgb, ${border} 90%, transparent);`,
|
||||
` --border-ui-hover: color-mix(in srgb, ${ring} 50%, ${border});`,
|
||||
` --border-ui-active: color-mix(in srgb, ${ring} 70%, ${border});`,
|
||||
` --input-border: ${input};`,
|
||||
` --input-bg: ${card};`,
|
||||
` --input-placeholder: color-mix(in srgb, ${mutedForeground} 72%, transparent);`,
|
||||
` --input-focus-border: ${ring};`,
|
||||
` --input-focus-ring: color-mix(in srgb, ${ring} 18%, transparent);`,
|
||||
` --label-fg: color-mix(in srgb, ${foreground} 82%, transparent);`,
|
||||
` --hover-bg: color-mix(in srgb, ${secondary} 72%, transparent);`,
|
||||
` --active-bg: color-mix(in srgb, ${primary} 12%, transparent);`,
|
||||
` --overlay-bg: color-mix(in srgb, ${background} 76%, transparent);`,
|
||||
` --status-success-bg: color-mix(in srgb, ${success} 14%, transparent);`,
|
||||
` --status-success-fg: ${success};`,
|
||||
` --status-warning-bg: color-mix(in srgb, ${warning} 14%, transparent);`,
|
||||
` --status-warning-fg: ${warning};`,
|
||||
` --status-error-bg: color-mix(in srgb, ${destructive} 14%, transparent);`,
|
||||
` --status-error-fg: ${destructive};`,
|
||||
` --diff-added: ${success};`,
|
||||
` --diff-added-strong: ${success};`,
|
||||
` --diff-removed: ${destructive};`,
|
||||
` --diff-removed-strong: ${destructive};`,
|
||||
` --diff-diagonal: color-mix(in srgb, ${primary} 12%, transparent);`,
|
||||
];
|
||||
|
||||
if (radius) {
|
||||
lines.push(` --r-lg: ${radius};`);
|
||||
lines.push(` --r-md: ${radius};`);
|
||||
lines.push(` --r-sm: ${radius};`);
|
||||
}
|
||||
if (fontSans) {
|
||||
lines.push(` --font-display: ${fontSans};`);
|
||||
lines.push(` --font-body: ${fontSans};`);
|
||||
}
|
||||
if (fontMono) {
|
||||
lines.push(` --font-mono: ${fontMono};`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildCustomThemeCSS(theme: CustomThemeData): string {
|
||||
return [
|
||||
":root.theme-custom {",
|
||||
...buildModeLines(theme.light),
|
||||
"}",
|
||||
"",
|
||||
":root.theme-custom.dark {",
|
||||
...buildModeLines(theme.dark),
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function applyCustomTheme(theme: CustomThemeData): void {
|
||||
if (!hasDom()) return;
|
||||
|
||||
let styleEl = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = CUSTOM_THEME_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
styleEl.textContent = buildCustomThemeCSS(theme);
|
||||
document.documentElement.classList.add("theme-custom");
|
||||
loadGoogleFonts(extractGoogleFonts(theme));
|
||||
}
|
||||
|
||||
export function removeCustomTheme(): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_STYLE_ID)?.remove();
|
||||
document.documentElement.classList.remove("theme-custom");
|
||||
unloadGoogleFonts();
|
||||
}
|
||||
|
||||
export function getStoredCustomTheme(): CustomThemeData | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as CustomThemeData;
|
||||
if (parsed && typeof parsed.light === "object" && typeof parsed.dark === "object") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore bad storage payloads
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setStoredCustomTheme(theme: CustomThemeData): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(theme));
|
||||
}
|
||||
|
||||
export function clearStoredCustomTheme(): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(CUSTOM_THEME_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function syncCustomThemeFromStorage(): void {
|
||||
const theme = getStoredCustomTheme();
|
||||
if (theme) {
|
||||
applyCustomTheme(theme);
|
||||
return;
|
||||
}
|
||||
removeCustomTheme();
|
||||
}
|
||||
@@ -166,6 +166,27 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
|
||||
{ kind: "link", label: "Plugins", to: "/plugins" },
|
||||
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
|
||||
{ kind: "link", label: "Users", to: "/users" },
|
||||
{
|
||||
kind: "link",
|
||||
label: "Staff Picks",
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "link",
|
||||
label: "Search",
|
||||
to: "/search",
|
||||
search: { q: undefined, type: undefined },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -162,45 +162,6 @@ describe("fetchPackages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves package stats from package detail responses", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 7,
|
||||
installs: 3,
|
||||
stars: 2,
|
||||
versions: 4,
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchPackageDetail("demo-plugin")).resolves.toMatchObject({
|
||||
package: {
|
||||
stats: {
|
||||
downloads: 7,
|
||||
installs: 3,
|
||||
stars: 2,
|
||||
versions: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards request cookies and includes credentials for package detail fetches", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://app.example");
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
|
||||
+24
-2
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
ApiV1PackageResponse,
|
||||
PackageCapabilitySummary,
|
||||
PackageCompatibility,
|
||||
PackageVerificationSummary,
|
||||
@@ -25,7 +24,30 @@ export type PackageListItem = {
|
||||
verificationTier?: string | null;
|
||||
};
|
||||
|
||||
export type PackageDetailResponse = ApiV1PackageResponse;
|
||||
export type PackageDetailResponse = {
|
||||
package: {
|
||||
_id?: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
runtimeId?: string | null;
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
summary?: string | null;
|
||||
latestVersion?: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
tags: Record<string, string>;
|
||||
compatibility?: PackageCompatibility | null;
|
||||
capabilities?: PackageCapabilitySummary | null;
|
||||
verification?: PackageVerificationSummary | null;
|
||||
} | null;
|
||||
owner: {
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PackageVersionDetail = {
|
||||
package: {
|
||||
|
||||
@@ -105,25 +105,6 @@ describe("buildPackageUploadEntries", () => {
|
||||
expect(uploaded.map((entry) => entry.path)).toEqual(["package.json", "dist/index.js"]);
|
||||
});
|
||||
|
||||
it("normalizes misleading text MIME types in upload entries", async () => {
|
||||
const uploaded = await buildPackageUploadEntries(
|
||||
[
|
||||
{
|
||||
name: "src/index.ts",
|
||||
size: 20,
|
||||
type: "video/mp2t",
|
||||
},
|
||||
],
|
||||
{
|
||||
generateUploadUrl: async () => "upload-1",
|
||||
hashFile: async () => "sha:1",
|
||||
uploadFile: async () => "storage:1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(uploaded[0]?.contentType).toBe("application/typescript");
|
||||
});
|
||||
|
||||
it("keeps nested archive paths when files do not have webkitRelativePath", async () => {
|
||||
const uploaded = await buildPackageUploadEntries(
|
||||
[
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import ignore from "ignore";
|
||||
import { normalizeTextContentType } from "clawhub-schema/textFiles";
|
||||
|
||||
type NormalizePackageUploadPathOptions = {
|
||||
stripTopLevelFolder?: boolean;
|
||||
@@ -140,7 +139,7 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+31
-43
@@ -1,22 +1,18 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyTheme, getStoredTheme, getStoredThemeName, getStoredThemeSelection, useThemeMode } from "./theme";
|
||||
import { applyTheme, getStoredTheme, useThemeMode } from "./theme";
|
||||
|
||||
describe("theme", () => {
|
||||
let store: Record<string, string>;
|
||||
|
||||
function Harness() {
|
||||
const { family, mode, setFamily, setMode } = useThemeMode();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="mode">{mode}</div>
|
||||
<div data-testid="family">{family}</div>
|
||||
<button type="button" onClick={() => setMode("dark")}>
|
||||
dark
|
||||
</button>
|
||||
<button type="button" onClick={() => setFamily("hub")}>
|
||||
hub
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,43 +39,27 @@ describe("theme", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
delete document.documentElement.dataset.theme;
|
||||
delete document.documentElement.dataset.themeResolved;
|
||||
delete document.documentElement.dataset.themeFamily;
|
||||
delete document.documentElement.dataset.themeMode;
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reads stored selection with legacy fallback", () => {
|
||||
it("reads stored theme with fallback", () => {
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
expect(getStoredThemeName()).toBe("claw");
|
||||
|
||||
window.localStorage.setItem(
|
||||
"clawhub-theme-selection",
|
||||
JSON.stringify({ theme: "hub", mode: "light" }),
|
||||
);
|
||||
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("clawhub-theme", "dark");
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("clawdhub-theme", "openknot");
|
||||
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "dark" });
|
||||
window.localStorage.setItem("clawhub-theme", "nope");
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
window.localStorage.setItem("clawdhub-theme", "dark");
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies family and resolved mode to the document", () => {
|
||||
applyTheme("dark", "hub");
|
||||
it("applies theme and toggles dark class", () => {
|
||||
applyTheme("dark");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
applyTheme("light", "claw");
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("light");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("claw");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -89,11 +69,27 @@ describe("theme", () => {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
applyTheme("system", "claw");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
applyTheme("system");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("useThemeMode persists family and mode", async () => {
|
||||
it("useThemeMode persists and applies mode", async () => {
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId("mode").textContent).toBe("system");
|
||||
fireEvent.click(screen.getByRole("button", { name: "dark" }));
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("loads stored theme after mount without a mismatched initial render", async () => {
|
||||
window.localStorage.setItem("clawhub-theme", "dark");
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
@@ -101,18 +97,10 @@ describe("theme", () => {
|
||||
}));
|
||||
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId("mode").textContent).toBe("system");
|
||||
expect(screen.getByTestId("family").textContent).toBe("claw");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hub" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "dark" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
expect(screen.getByTestId("mode").textContent).toBe("dark");
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
|
||||
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
+30
-205
@@ -1,228 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
clearStoredCustomTheme,
|
||||
getStoredCustomTheme,
|
||||
parseThemeInput,
|
||||
setStoredCustomTheme,
|
||||
syncCustomThemeFromStorage,
|
||||
type CustomThemeData,
|
||||
} from './customTheme';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ThemeName = 'claw' | 'hub';
|
||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
|
||||
export type ThemeSelection = {
|
||||
theme: ThemeName;
|
||||
mode: ThemeMode;
|
||||
};
|
||||
|
||||
const THEME_SELECTION_KEY = 'clawhub-theme-selection';
|
||||
const THEME_KEY = 'clawhub-theme';
|
||||
const LEGACY_THEME_KEY = 'clawdhub-theme';
|
||||
const THEME_NAME_KEY = 'clawhub-theme-name';
|
||||
const THEME_CHANGE_EVENT = 'clawhub:themechange';
|
||||
|
||||
export const THEME_OPTIONS: Array<{ value: ThemeName; label: string; description: string }> = [
|
||||
{
|
||||
value: 'claw',
|
||||
label: 'Claw',
|
||||
description: 'OpenClaw black, white, and red.',
|
||||
},
|
||||
{
|
||||
value: 'hub',
|
||||
label: 'Hub',
|
||||
description: 'Marketplace monochrome index with terminal-style contrast.',
|
||||
},
|
||||
];
|
||||
|
||||
export const THEME_FAMILY_OPTIONS = THEME_OPTIONS;
|
||||
|
||||
const VALID_THEME_NAMES = new Set<ThemeName>(['claw', 'hub']);
|
||||
const VALID_THEME_MODES = new Set<ThemeMode>(['system', 'light', 'dark']);
|
||||
|
||||
const LEGACY_MAP: Record<string, ThemeSelection> = {
|
||||
dark: { theme: 'claw', mode: 'dark' },
|
||||
light: { theme: 'claw', mode: 'light' },
|
||||
system: { theme: 'claw', mode: 'system' },
|
||||
defaultTheme: { theme: 'claw', mode: 'dark' },
|
||||
docsTheme: { theme: 'claw', mode: 'light' },
|
||||
lightTheme: { theme: 'claw', mode: 'dark' },
|
||||
landingTheme: { theme: 'claw', mode: 'dark' },
|
||||
newTheme: { theme: 'claw', mode: 'dark' },
|
||||
openknot: { theme: 'claw', mode: 'dark' },
|
||||
fieldmanual: { theme: 'hub', mode: 'dark' },
|
||||
clawdash: { theme: 'hub', mode: 'light' },
|
||||
};
|
||||
|
||||
function parseThemeSelection(themeRaw: unknown, modeRaw: unknown): ThemeSelection {
|
||||
const theme = typeof themeRaw === 'string' ? themeRaw : '';
|
||||
const mode = typeof modeRaw === 'string' ? modeRaw : '';
|
||||
|
||||
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
|
||||
? (theme as ThemeName)
|
||||
: (LEGACY_MAP[theme]?.theme ?? 'claw');
|
||||
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
|
||||
? (mode as ThemeMode)
|
||||
: (LEGACY_MAP[theme]?.mode ?? 'system');
|
||||
|
||||
return { theme: normalizedTheme, mode: normalizedMode };
|
||||
}
|
||||
|
||||
function persistThemeSelection(selection: ThemeSelection) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(THEME_SELECTION_KEY, JSON.stringify(selection));
|
||||
window.localStorage.setItem(THEME_KEY, selection.mode);
|
||||
window.localStorage.setItem(THEME_NAME_KEY, selection.theme);
|
||||
}
|
||||
|
||||
export function getStoredThemeSelection(): ThemeSelection {
|
||||
if (typeof window === 'undefined') return { theme: 'claw', mode: 'system' };
|
||||
|
||||
try {
|
||||
const storedSelection = window.localStorage.getItem(THEME_SELECTION_KEY);
|
||||
if (storedSelection) {
|
||||
const parsed = JSON.parse(storedSelection) as Partial<ThemeSelection>;
|
||||
return parseThemeSelection(parsed.theme, parsed.mode);
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy keys
|
||||
}
|
||||
|
||||
const storedMode = window.localStorage.getItem(THEME_KEY);
|
||||
const storedTheme = window.localStorage.getItem(THEME_NAME_KEY);
|
||||
if (storedMode || storedTheme) {
|
||||
return parseThemeSelection(storedTheme, storedMode);
|
||||
}
|
||||
|
||||
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
|
||||
if (legacy) {
|
||||
return parseThemeSelection(legacy, undefined);
|
||||
}
|
||||
|
||||
return { theme: 'claw', mode: 'system' };
|
||||
}
|
||||
const THEME_KEY = "clawhub-theme";
|
||||
const LEGACY_THEME_KEY = "clawdhub-theme";
|
||||
|
||||
export function getStoredTheme(): ThemeMode {
|
||||
return getStoredThemeSelection().mode;
|
||||
if (typeof window === "undefined") return "system";
|
||||
const stored = window.localStorage.getItem(THEME_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
|
||||
if (legacy === "light" || legacy === "dark" || legacy === "system") return legacy;
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function getStoredThemeName(): ThemeName {
|
||||
return getStoredThemeSelection().theme;
|
||||
function resolveTheme(mode: ThemeMode) {
|
||||
if (mode !== "system") return mode;
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function getThemeFamilyLabel(theme: ThemeName): string {
|
||||
return THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Claw';
|
||||
}
|
||||
|
||||
function resolveMode(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode !== 'system') return mode;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
void theme;
|
||||
return resolveMode(mode);
|
||||
}
|
||||
|
||||
export function isDarkResolvedTheme(resolvedTheme: string | null | undefined): boolean {
|
||||
return resolvedTheme === 'dark';
|
||||
}
|
||||
|
||||
export function isDarkThemeResolved(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return isDarkResolvedTheme(document.documentElement.dataset.themeResolved);
|
||||
}
|
||||
|
||||
export function applyTheme(selectionOrMode: ThemeSelection | ThemeMode, theme: ThemeName = 'claw') {
|
||||
const selection = typeof selectionOrMode === 'string' ? { theme, mode: selectionOrMode } : selectionOrMode;
|
||||
applyThemeSelection(selection);
|
||||
}
|
||||
|
||||
export function applyThemeSelection(selection: ThemeSelection) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const resolved = resolveTheme(selection.theme, selection.mode);
|
||||
export function applyTheme(mode: ThemeMode) {
|
||||
if (typeof document === "undefined") return;
|
||||
const resolved = resolveTheme(mode);
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.dataset.themeResolved = resolved;
|
||||
document.documentElement.dataset.themeMode = selection.mode;
|
||||
document.documentElement.dataset.themeFamily = selection.theme;
|
||||
document.documentElement.classList.toggle('dark', isDarkResolvedTheme(resolved));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeChange(handler: () => void) {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
window.addEventListener(THEME_CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(THEME_CHANGE_EVENT, handler);
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
const [selection, setSelection] = useState<ThemeSelection>({ theme: 'claw', mode: 'system' });
|
||||
const [mode, setMode] = useState<ThemeMode>("system");
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [customTheme, setCustomTheme] = useState<CustomThemeData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection(getStoredThemeSelection());
|
||||
setCustomTheme(getStoredCustomTheme());
|
||||
setMode(getStoredTheme());
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return;
|
||||
applyThemeSelection(selection);
|
||||
persistThemeSelection(selection);
|
||||
syncCustomThemeFromStorage();
|
||||
|
||||
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
applyTheme(mode);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_KEY, mode);
|
||||
}
|
||||
if (mode !== "system" || typeof window === "undefined") return;
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => applyTheme(mode);
|
||||
media.addEventListener("change", handler);
|
||||
return () => media.removeEventListener("change", handler);
|
||||
}, [isHydrated, mode]);
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => {
|
||||
applyThemeSelection(selection);
|
||||
syncCustomThemeFromStorage();
|
||||
};
|
||||
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', handler);
|
||||
return () => media.removeEventListener('change', handler);
|
||||
}
|
||||
|
||||
media.addListener(handler);
|
||||
return () => media.removeListener(handler);
|
||||
}, [isHydrated, selection]);
|
||||
|
||||
const importCustomTheme = async (input: string) => {
|
||||
const parsed = await parseThemeInput(input);
|
||||
const theme = {
|
||||
...parsed,
|
||||
source: input.trim(),
|
||||
};
|
||||
setStoredCustomTheme(theme);
|
||||
setCustomTheme(theme);
|
||||
syncCustomThemeFromStorage();
|
||||
return theme;
|
||||
};
|
||||
|
||||
const clearCustomTheme = () => {
|
||||
clearStoredCustomTheme();
|
||||
setCustomTheme(null);
|
||||
syncCustomThemeFromStorage();
|
||||
};
|
||||
|
||||
return {
|
||||
theme: selection.theme,
|
||||
family: selection.theme,
|
||||
mode: selection.mode,
|
||||
selection,
|
||||
customTheme,
|
||||
setTheme: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
|
||||
setFamily: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
|
||||
setMode: (mode: ThemeMode) => setSelection((current) => ({ ...current, mode })),
|
||||
importCustomTheme,
|
||||
clearCustomTheme,
|
||||
};
|
||||
return { mode, setMode };
|
||||
}
|
||||
|
||||
@@ -56,27 +56,6 @@ describe("uploadUtils", () => {
|
||||
|
||||
const id = await uploadFile("https://example.com/upload", new File(["x"], "x.txt"));
|
||||
expect(id).toBe("st_123");
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("normalizes misleading upload MIME types for TypeScript files", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "st_123" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await uploadFile(
|
||||
"https://example.com/upload",
|
||||
new File(["x"], "src/index.ts", { type: "video/mp2t" }),
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
headers: { "Content-Type": "application/typescript" },
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import {
|
||||
isTextContentType,
|
||||
normalizeTextContentType,
|
||||
TEXT_FILE_EXTENSION_SET,
|
||||
} from "clawhub-schema/textFiles";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
|
||||
import { getUserFacingConvexError } from "./convexError";
|
||||
|
||||
export async function uploadFile(uploadUrl: string, file: File) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const contentType =
|
||||
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": contentType },
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
+79
-95
@@ -84,104 +84,88 @@ export const Route = createFileRoute('/about')({
|
||||
function AboutPage() {
|
||||
return (
|
||||
<main className="section about-page">
|
||||
<div className="about-bento">
|
||||
<section className="about-panel about-panel-hero">
|
||||
<div className="about-hero-copy">
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
<h1 className="about-title">What ClawHub will not host</h1>
|
||||
<p className="about-lead">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
|
||||
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
|
||||
does not belong here.
|
||||
</p>
|
||||
<div className="about-hero">
|
||||
<div className="about-hero-copy">
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="about-panel about-panel-callout">
|
||||
<div className="about-callout">
|
||||
<span className="about-callout-label">Moderation stance</span>
|
||||
<p>
|
||||
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
|
||||
Predatory workflows get removed.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="about-panel about-panel-categories">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Immediate rejection categories</h2>
|
||||
</div>
|
||||
<div className="about-grid">
|
||||
{prohibitedCategories.map((category, index) => (
|
||||
<article
|
||||
key={category.title}
|
||||
className={`about-rule-card${index % 3 === 0 ? ' about-rule-card-featured' : ''}`}
|
||||
>
|
||||
<h2>{category.title}</h2>
|
||||
<p>{category.examples}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-panel about-panel-patterns">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
|
||||
</div>
|
||||
<div className="about-patterns">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="about-pattern">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-panel about-panel-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">Enforcement</span>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-panel about-panel-actions">
|
||||
<span className="about-callout-label">Next steps</span>
|
||||
<p className="about-panel-copy">
|
||||
If you are reviewing a borderline workflow, use the reviewer doc. If you are browsing,
|
||||
stay in the public catalog.
|
||||
<h1 className="about-title">What ClawHub will not host</h1>
|
||||
<p className="about-lead">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
|
||||
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
|
||||
belong here.
|
||||
</p>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="about-callout">
|
||||
<span className="about-callout-label">Moderation stance</span>
|
||||
<p>
|
||||
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
|
||||
Predatory workflows get removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="about-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Immediate rejection categories</h2>
|
||||
</div>
|
||||
<div className="about-grid">
|
||||
{prohibitedCategories.map((category) => (
|
||||
<article key={category.title} className="about-rule-card">
|
||||
<h2>{category.title}</h2>
|
||||
<p>{category.examples}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
|
||||
</div>
|
||||
<div className="about-patterns">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="about-pattern">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">Enforcement</span>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createTokenMock = vi.fn();
|
||||
const clearAuthErrorMock = vi.fn();
|
||||
let mockSearch: {
|
||||
redirect_uri?: string;
|
||||
label?: string;
|
||||
label_b64?: string;
|
||||
state?: string;
|
||||
} = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => ({
|
||||
...config,
|
||||
useSearch: () => mockSearch,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => createTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
tokens: {
|
||||
create: "tokens.create",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "user_123" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/useAuthError", () => ({
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: clearAuthErrorMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
normalizeClawHubSiteOrigin: () => "https://clawhub.ai",
|
||||
}));
|
||||
|
||||
vi.mock("../../components/layout/Container", () => ({
|
||||
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/SignInButton", () => ({
|
||||
SignInButton: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/card", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
}));
|
||||
|
||||
const { CliAuth } = await import("./auth");
|
||||
|
||||
describe("CliAuth", () => {
|
||||
const assignSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
createTokenMock.mockReset();
|
||||
clearAuthErrorMock.mockReset();
|
||||
assignSpy.mockReset();
|
||||
mockSearch = {
|
||||
redirect_uri: "http://127.0.0.1:43110/callback",
|
||||
state: "state_123",
|
||||
label: "CLI token",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the fallback token and retry link before attempting redirect", async () => {
|
||||
createTokenMock.mockResolvedValue({ token: "clh_test_token" });
|
||||
|
||||
render(<CliAuth navigate={assignSpy} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTokenMock).toHaveBeenCalledWith({ label: "CLI token" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assignSpy).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:43110/callback#token=clh_test_token®istry=https%3A%2F%2Fclawhub.ai&state=state_123",
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/copy this token and run/i)).toBeTruthy();
|
||||
expect(screen.getByText("clh_test_token")).toBeTruthy();
|
||||
expect(screen.getByText(/Redirecting to CLI/i)).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /Retry redirect to CLI/i }).getAttribute("href")).toBe(
|
||||
"http://127.0.0.1:43110/callback#token=clh_test_token®istry=https%3A%2F%2Fclawhub.ai&state=state_123",
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-28
@@ -1,7 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
@@ -14,11 +13,7 @@ export const Route = createFileRoute("/cli/auth")({
|
||||
component: CliAuth,
|
||||
});
|
||||
|
||||
type CliAuthProps = {
|
||||
navigate?: (url: string) => void;
|
||||
};
|
||||
|
||||
export function CliAuth({ navigate = (url: string) => window.location.assign(url) }: CliAuthProps = {}) {
|
||||
function CliAuth() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const createToken = useMutation(api.tokens.create);
|
||||
@@ -31,7 +26,6 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
|
||||
};
|
||||
const [status, setStatus] = useState<string>("Preparing...");
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
|
||||
const hasRun = useRef(false);
|
||||
|
||||
const redirectUri = search.redirect_uri ?? "";
|
||||
@@ -57,21 +51,13 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
|
||||
const run = async () => {
|
||||
setStatus("Creating token...");
|
||||
const result = await createToken({ label });
|
||||
setToken(result.token);
|
||||
setStatus("Redirecting to CLI...");
|
||||
const hash = new URLSearchParams();
|
||||
hash.set("token", result.token);
|
||||
hash.set("registry", registry);
|
||||
hash.set("state", state);
|
||||
const redirectUrl = `${redirectUri}#${hash.toString()}`;
|
||||
// Render the fallback token before attempting navigation so it is
|
||||
// always visible if the browser blocks or fails the http:// redirect
|
||||
// (e.g. ERR_CONNECTION_REFUSED when the CLI server has already shut
|
||||
// down, or Chrome's HTTPS-first mode interfering with localhost).
|
||||
flushSync(() => {
|
||||
setToken(result.token);
|
||||
setCallbackUrl(redirectUrl);
|
||||
setStatus("Redirecting to CLI…");
|
||||
});
|
||||
navigate(redirectUrl);
|
||||
window.location.assign(`${redirectUri}#${hash.toString()}`);
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
@@ -79,7 +65,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
|
||||
setStatus(message);
|
||||
setToken(null);
|
||||
});
|
||||
}, [createToken, isAuthenticated, label, me, navigate, redirectUri, registry, safeRedirect, state]);
|
||||
}, [createToken, isAuthenticated, label, me, redirectUri, registry, safeRedirect, state]);
|
||||
|
||||
if (!safeRedirect) {
|
||||
return (
|
||||
@@ -173,16 +159,8 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">{status}</p>
|
||||
{token ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)] overflow-x-auto">
|
||||
<div className="mb-2">
|
||||
If the redirect did not complete, copy this token and run{" "}
|
||||
<code>clawhub login --token <token></code>:
|
||||
</div>
|
||||
<div className="mb-2">If redirect fails, copy this token:</div>
|
||||
<code className="font-mono text-xs">{token}</code>
|
||||
{callbackUrl ? (
|
||||
<div className="mt-2">
|
||||
<a href={callbackUrl}>Retry redirect to CLI</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
+3
-10
@@ -14,7 +14,7 @@ import {
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SkillCard } from "../components/SkillCard";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
@@ -88,7 +88,7 @@ function SkillsHome() {
|
||||
])
|
||||
.then(([h, t, r, c]) => {
|
||||
if (cancelled) return;
|
||||
setHighlighted((h as SkillPageEntry[]).slice(0, 6));
|
||||
setHighlighted(h as SkillPageEntry[]);
|
||||
setTrending((t as { page: SkillPageEntry[] }).page);
|
||||
setRecent((r as { page: SkillPageEntry[] }).page);
|
||||
setSkillCount(c as number);
|
||||
@@ -110,13 +110,6 @@ function SkillsHome() {
|
||||
});
|
||||
};
|
||||
|
||||
const highlightedGridStyle = {
|
||||
"--staff-picks-cols-lg": String(Math.max(1, Math.min(highlighted.length, 6))),
|
||||
"--staff-picks-cols-md": String(Math.max(1, Math.min(highlighted.length, 3))),
|
||||
"--staff-picks-cols-sm": String(Math.max(1, Math.min(highlighted.length, 2))),
|
||||
"--staff-picks-cols-xs": "1",
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
@@ -368,7 +361,7 @@ function SkillsHome() {
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-staff-picks-grid" style={highlightedGridStyle}>
|
||||
<div className="grid">
|
||||
{highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
|
||||
@@ -265,7 +265,7 @@ function PluginDetailRoute() {
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -283,7 +283,7 @@ function PluginDetailRoute() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
|
||||
{pkg.displayName}
|
||||
{pkg.latestVersion ? (
|
||||
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
@@ -291,16 +291,16 @@ function PluginDetailRoute() {
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
|
||||
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="break-all font-mono text-xs">{pkg.name}</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
|
||||
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
@@ -327,7 +327,7 @@ function PluginDetailRoute() {
|
||||
{/* Install */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<CopyButton text={installSnippet} />
|
||||
@@ -365,7 +365,7 @@ function PluginDetailRoute() {
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
@@ -407,9 +407,7 @@ function PluginDetailRoute() {
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{String(value)}
|
||||
</dd>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -474,7 +472,7 @@ function PluginDetailRoute() {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -487,7 +485,7 @@ function PluginDetailRoute() {
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -495,7 +493,7 @@ function PluginDetailRoute() {
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -530,9 +528,7 @@ function PluginDetailRoute() {
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -142,16 +142,16 @@ function PluginsIndex() {
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<h1 className="browse-title">Plugins</h1>
|
||||
<div className="browse-page-actions">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { normalizeTextContentType } from "clawhub-schema/textFiles";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { Upload as UploadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -424,7 +423,7 @@ export function Upload() {
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+4
-89
@@ -52,7 +52,7 @@ import {
|
||||
type ListViewMode,
|
||||
usePreferences,
|
||||
} from "../lib/preferences";
|
||||
import { getThemeFamilyLabel, THEME_FAMILY_OPTIONS, useThemeMode } from "../lib/theme";
|
||||
import { useThemeMode } from "../lib/theme";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: Settings,
|
||||
@@ -62,15 +62,7 @@ export function Settings() {
|
||||
const me = useQuery(api.users.me);
|
||||
const updateProfile = useMutation(api.users.updateProfile);
|
||||
const deleteAccount = useMutation(api.users.deleteAccount);
|
||||
const {
|
||||
customTheme,
|
||||
family: themeFamily,
|
||||
importCustomTheme,
|
||||
mode: themeMode,
|
||||
setFamily: setThemeFamily,
|
||||
setMode: setThemeMode,
|
||||
clearCustomTheme,
|
||||
} = useThemeMode();
|
||||
const { mode: themeMode, setMode: setThemeMode } = useThemeMode();
|
||||
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
|
||||
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
|
||||
| Array<{
|
||||
@@ -108,7 +100,6 @@ export function Settings() {
|
||||
const [memberHandle, setMemberHandle] = useState("");
|
||||
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [customThemeInput, setCustomThemeInput] = useState("");
|
||||
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
|
||||
const selectedOrg =
|
||||
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
|
||||
@@ -191,20 +182,6 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportCustomTheme() {
|
||||
try {
|
||||
const importedTheme = await importCustomTheme(customThemeInput);
|
||||
setCustomThemeInput("");
|
||||
toast.success(`Imported ${importedTheme.name ?? "custom theme"}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import theme");
|
||||
}
|
||||
}
|
||||
function onRemoveCustomTheme() {
|
||||
clearCustomTheme();
|
||||
toast.success("Removed custom theme");
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="narrow" className="py-10">
|
||||
<main className="flex flex-col gap-6">
|
||||
@@ -290,31 +267,8 @@ export function Settings() {
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-3">
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{THEME_FAMILY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setThemeFamily(option.value)}
|
||||
className="rounded-[var(--r-md)] border p-4 text-left transition-colors"
|
||||
style={{
|
||||
borderColor:
|
||||
themeFamily === option.value ? "var(--accent)" : "var(--border-ui)",
|
||||
background:
|
||||
themeFamily === option.value
|
||||
? "var(--accent-subtle)"
|
||||
: "var(--surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-[color:var(--ink-soft)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={themeMode === "light" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
@@ -343,45 +297,6 @@ export function Settings() {
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[var(--r-md)] border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
tweakcn overlay
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Paste a tweakcn URL, a tweakcn theme name, CSS variables, or JSON.
|
||||
</p>
|
||||
</div>
|
||||
{customTheme ? (
|
||||
<Badge variant="accent">
|
||||
{customTheme.name} on {getThemeFamilyLabel(themeFamily)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={customThemeInput}
|
||||
onChange={(event) => setCustomThemeInput(event.target.value)}
|
||||
placeholder="https://tweakcn.com/... or midnight-ocean"
|
||||
aria-label="Import tweakcn theme"
|
||||
/>
|
||||
<Button type="button" variant="primary" onClick={onImportCustomTheme}>
|
||||
Import
|
||||
</Button>
|
||||
{customTheme ? (
|
||||
<Button type="button" variant="ghost" onClick={onRemoveCustomTheme}>
|
||||
<RotateCcw size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{customTheme ? (
|
||||
<div className="text-xs text-[color:var(--ink-soft)]">
|
||||
Source: {customTheme.source}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -112,6 +112,12 @@ export function SkillsIndex() {
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
@@ -120,12 +126,6 @@ export function SkillsIndex() {
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import {
|
||||
isTextContentType,
|
||||
normalizeTextContentType,
|
||||
TEXT_FILE_EXTENSION_SET,
|
||||
} from "clawhub-schema/textFiles";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
|
||||
import { getUserFacingConvexError } from "../../lib/convexError";
|
||||
|
||||
export async function uploadFile(uploadUrl: string, file: File) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const contentType =
|
||||
normalizeTextContentType(path, file.type) ?? file.type ?? "application/octet-stream";
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": contentType },
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
+51
-741
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user