mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-05-07 22:04:52 +02:00
wip
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.937.0",
|
||||
"@convex-dev/auth": "^0.0.90",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"convex": "^1.29.3",
|
||||
"convex": "^1.35.1",
|
||||
"convex-helpers": "^0.1.106",
|
||||
"esptool-js": "^0.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -20,6 +20,7 @@
|
||||
"react-router-dom": "^7.14.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"semver": "^7.7.4",
|
||||
"sonner": "^2.0.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"postcss": "^8.5.9",
|
||||
@@ -586,6 +588,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
@@ -644,7 +648,7 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convex": ["convex@1.29.3", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-tg5TXzMjpNk9m50YRtdp6US+t7ckxE4E+7DNKUCjJ2MupQs2RBSPF/z5SNN4GUmQLSfg0eMILDySzdAvjTrhnw=="],
|
||||
"convex": ["convex@1.35.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="],
|
||||
|
||||
@@ -998,7 +1002,7 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
@@ -1106,6 +1110,10 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@radix-ui/react-popover/@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=="],
|
||||
@@ -1128,7 +1136,7 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"convex/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
@@ -1170,6 +1178,56 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
|
||||
|
||||
"convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="],
|
||||
|
||||
"convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="],
|
||||
|
||||
"convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="],
|
||||
|
||||
"convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="],
|
||||
|
||||
"convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="],
|
||||
|
||||
"convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="],
|
||||
|
||||
"convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="],
|
||||
|
||||
"convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="],
|
||||
|
||||
"convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="],
|
||||
|
||||
"convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="],
|
||||
|
||||
"convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="],
|
||||
|
||||
"convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
|
||||
|
||||
"convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"guidelinesHash": "294b619f8246c26bd6bfb6a57122503f0e2149872fc6b26609b7a95bfefaf2b8",
|
||||
"agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
|
||||
"claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
|
||||
"agentSkillsSha": "dc8ff761cfe4da450af2ea8a9ec708f737064bed",
|
||||
"installedSkillNames": [
|
||||
"convex-create-component",
|
||||
"convex-migration-helper",
|
||||
"convex-performance-audit",
|
||||
"convex-quickstart",
|
||||
"convex-setup-auth"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
# Convex guidelines
|
||||
## Function guidelines
|
||||
### Http endpoint syntax
|
||||
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
|
||||
```typescript
|
||||
import { httpRouter } from "convex/server";
|
||||
import { httpAction } from "./_generated/server";
|
||||
const http = httpRouter();
|
||||
http.route({
|
||||
path: "/echo",
|
||||
method: "POST",
|
||||
handler: httpAction(async (ctx, req) => {
|
||||
const body = await req.bytes();
|
||||
return new Response(body, { status: 200 });
|
||||
}),
|
||||
});
|
||||
```
|
||||
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
|
||||
|
||||
### Validators
|
||||
- Below is an example of an array validator:
|
||||
```typescript
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default mutation({
|
||||
args: {
|
||||
simpleArray: v.array(v.union(v.string(), v.number())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
//...
|
||||
},
|
||||
});
|
||||
```
|
||||
- Below is an example of a schema with validators that codify a discriminated union type:
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
results: defineTable(
|
||||
v.union(
|
||||
v.object({
|
||||
kind: v.literal("error"),
|
||||
errorMessage: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
kind: v.literal("success"),
|
||||
value: v.number(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
```
|
||||
- Here are the valid Convex types along with their respective validators:
|
||||
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
|
||||
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Id | string | `doc._id` | `v.id(tableName)` | |
|
||||
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
|
||||
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
|
||||
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
|
||||
| Boolean | boolean | `true` | `v.boolean()` |
|
||||
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
|
||||
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
|
||||
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
|
||||
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
|
||||
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". |
|
||||
|
||||
### Function registration
|
||||
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
|
||||
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
|
||||
- You CANNOT register a function through the `api` or `internal` objects.
|
||||
- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`.
|
||||
|
||||
### Function calling
|
||||
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
|
||||
- Use `ctx.runMutation` to call a mutation from a mutation or action.
|
||||
- Use `ctx.runAction` to call an action from an action.
|
||||
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
|
||||
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
|
||||
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
|
||||
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
|
||||
```
|
||||
export const f = query({
|
||||
args: { name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return "Hello " + args.name;
|
||||
},
|
||||
});
|
||||
|
||||
export const g = query({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Function references
|
||||
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
|
||||
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
|
||||
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
|
||||
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
|
||||
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
|
||||
|
||||
### Pagination
|
||||
- Define pagination using the following syntax:
|
||||
|
||||
```ts
|
||||
import { v } from "convex/values";
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
export const listWithExtraArg = query({
|
||||
args: { paginationOpts: paginationOptsValidator, author: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_author", (q) => q.eq("author", args.author))
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
},
|
||||
});
|
||||
```
|
||||
Note: `paginationOpts` is an object with the following properties:
|
||||
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
|
||||
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
|
||||
- A query that ends in `.paginate()` returns an object that has the following properties:
|
||||
- page (contains an array of documents that you fetches)
|
||||
- isDone (a boolean that represents whether or not this is the last page of documents)
|
||||
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
|
||||
|
||||
|
||||
## Schema guidelines
|
||||
- Always define your schema in `convex/schema.ts`.
|
||||
- Always import the schema definition functions from `convex/server`.
|
||||
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
|
||||
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
|
||||
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
|
||||
- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent.
|
||||
- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record.
|
||||
|
||||
## Authentication guidelines
|
||||
- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`.
|
||||
- Example `convex/auth.config.ts`:
|
||||
```typescript
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: "https://your-auth-provider.com",
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim.
|
||||
- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier.
|
||||
- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key.
|
||||
- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`.
|
||||
- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`:
|
||||
```tsx
|
||||
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
|
||||
|
||||
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
|
||||
|
||||
function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexProviderWithAuth client={convex} useAuth={useYourAuthHook}>
|
||||
{children}
|
||||
</ConvexProviderWithAuth>
|
||||
);
|
||||
}
|
||||
```
|
||||
The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests.
|
||||
|
||||
## Typescript guidelines
|
||||
- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
|
||||
- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table.
|
||||
- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type.
|
||||
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
|
||||
```ts
|
||||
import { query } from "./_generated/server";
|
||||
import { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { userIds: v.array(v.id("users")) },
|
||||
handler: async (ctx, args) => {
|
||||
const idToUsername: Record<Id<"users">, string> = {};
|
||||
for (const userId of args.userIds) {
|
||||
const user = await ctx.db.get("users", userId);
|
||||
if (user) {
|
||||
idToUsername[user._id] = user.username;
|
||||
}
|
||||
}
|
||||
|
||||
return idToUsername;
|
||||
},
|
||||
});
|
||||
```
|
||||
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
|
||||
|
||||
## Full text search guidelines
|
||||
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
.withSearchIndex("search_body", (q) =>
|
||||
q.search("body", "hello hi").eq("channel", "#general"),
|
||||
)
|
||||
.take(10);
|
||||
|
||||
## Query guidelines
|
||||
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
|
||||
- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way.
|
||||
- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations.
|
||||
- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned.
|
||||
- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits.
|
||||
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
|
||||
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
|
||||
### Ordering
|
||||
- By default Convex always returns documents in ascending `_creationTime` order.
|
||||
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
|
||||
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
|
||||
|
||||
|
||||
## Mutation guidelines
|
||||
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })`
|
||||
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })`
|
||||
|
||||
## Action guidelines
|
||||
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
|
||||
- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.
|
||||
- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`.
|
||||
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
|
||||
- Below is an example of the syntax for an action:
|
||||
```ts
|
||||
import { action } from "./_generated/server";
|
||||
|
||||
export const exampleAction = action({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
console.log("This action does not return anything");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Scheduling guidelines
|
||||
### Cron guidelines
|
||||
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
|
||||
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
|
||||
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
|
||||
```ts
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalAction } from "./_generated/server";
|
||||
|
||||
const empty = internalAction({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
console.log("empty");
|
||||
},
|
||||
});
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
// Run `internal.crons.empty` every two hours.
|
||||
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
|
||||
|
||||
export default crons;
|
||||
```
|
||||
- You can register Convex functions within `crons.ts` just like any other file.
|
||||
- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file.
|
||||
|
||||
|
||||
## Testing guidelines
|
||||
- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`.
|
||||
|
||||
Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`:
|
||||
```typescript
|
||||
/// <reference types="vite/client" />
|
||||
import { convexTest } from "convex-test";
|
||||
import { expect, test } from "vitest";
|
||||
import { api } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
test("some behavior", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" });
|
||||
const messages = await t.query(api.messages.list);
|
||||
expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]);
|
||||
});
|
||||
```
|
||||
The `modules` argument is required so convex-test can discover and load function files. The `/// <reference types="vite/client" />` directive is needed for TypeScript to recognize `import.meta.glob`.
|
||||
|
||||
## File storage guidelines
|
||||
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
|
||||
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
|
||||
|
||||
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
|
||||
```
|
||||
import { query } from "./_generated/server";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
type FileMetadata = {
|
||||
_id: Id<"_storage">;
|
||||
_creationTime: number;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { fileId: v.id("_storage") },
|
||||
handler: async (ctx, args) => {
|
||||
const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId);
|
||||
console.log(metadata);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
|
||||
|
||||
|
||||
Vendored
+4
-2
@@ -16,10 +16,11 @@ import type * as helpers from "../helpers.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as lib_platformioScan from "../lib/platformioScan.js";
|
||||
import type * as lib_r2 from "../lib/r2.js";
|
||||
import type * as repoBranches from "../repoBranches.js";
|
||||
import type * as lib_tagSemver from "../lib/tagSemver.js";
|
||||
import type * as repoBuildDownloads from "../repoBuildDownloads.js";
|
||||
import type * as repoBuilds from "../repoBuilds.js";
|
||||
import type * as repoScans from "../repoScans.js";
|
||||
import type * as repoTags from "../repoTags.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -36,10 +37,11 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
"lib/platformioScan": typeof lib_platformioScan;
|
||||
"lib/r2": typeof lib_r2;
|
||||
repoBranches: typeof repoBranches;
|
||||
"lib/tagSemver": typeof lib_tagSemver;
|
||||
repoBuildDownloads: typeof repoBuildDownloads;
|
||||
repoBuilds: typeof repoBuilds;
|
||||
repoScans: typeof repoScans;
|
||||
repoTags: typeof repoTags;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+1
-1
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
|
||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||
*
|
||||
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
||||
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||
*
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import semver from "semver"
|
||||
|
||||
export type TagEntry = { name: string; sha: string }
|
||||
|
||||
/**
|
||||
* Canonical SemVer string for sorting, or null if the tag is not SemVer-shaped.
|
||||
* Normalizes leading `v`/`V` (only when followed by a digit) and `=` so `v1.0.0`,
|
||||
* `V1.0.0`, and `1.0.0` share one ordering key. Uses loose parse for real-world tags.
|
||||
*/
|
||||
export function semverSortKey(tagName: string): string | null {
|
||||
const trimmed = tagName.trim()
|
||||
const normalized = trimmed.replace(/^=+/, "").replace(/^[vV](?=\d)/, "")
|
||||
const parsed =
|
||||
semver.parse(normalized, { loose: true }) ??
|
||||
semver.parse(trimmed.replace(/^=+/, ""), { loose: true }) ??
|
||||
semver.parse(trimmed, { loose: true })
|
||||
return parsed ? parsed.version : null
|
||||
}
|
||||
|
||||
/** SemVer-descending (prereleases included), tie-break by tag name; non-SemVer last, reverse lex. */
|
||||
export function sortTagNames(tagNames: readonly string[]): string[] {
|
||||
const semverRows: { name: string; ver: string }[] = []
|
||||
const other: string[] = []
|
||||
for (const name of tagNames) {
|
||||
const ver = semverSortKey(name)
|
||||
if (ver) semverRows.push({ name, ver })
|
||||
else other.push(name)
|
||||
}
|
||||
semverRows.sort((a, b) => {
|
||||
const c = semver.rcompare(a.ver, b.ver)
|
||||
if (c !== 0) return c
|
||||
return b.name.localeCompare(a.name)
|
||||
})
|
||||
other.sort((a, b) => b.localeCompare(a))
|
||||
return [...semverRows.map(r => r.name), ...other]
|
||||
}
|
||||
|
||||
export function sortTagEntries(tags: readonly TagEntry[]): TagEntry[] {
|
||||
const byName = new Map<string, TagEntry>()
|
||||
for (const t of tags) {
|
||||
if (!byName.has(t.name)) byName.set(t.name, t)
|
||||
}
|
||||
const orderedNames = sortTagNames([...byName.keys()])
|
||||
return orderedNames.map(name => byName.get(name)!)
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { v } from "convex/values"
|
||||
import { internal } from "./_generated/api"
|
||||
import { action, internalMutation, query } from "./_generated/server"
|
||||
const BRANCH_TTL_MS = 120_000
|
||||
import { sortTagEntries, type TagEntry } from "./lib/tagSemver"
|
||||
|
||||
const branchEntryValidator = v.object({
|
||||
const TAG_TTL_MS = 120_000
|
||||
|
||||
const tagEntryValidator = v.object({
|
||||
name: v.string(),
|
||||
sha: v.optional(v.string()),
|
||||
sha: v.string(),
|
||||
})
|
||||
|
||||
export type { TagEntry }
|
||||
|
||||
export const get = query({
|
||||
args: { owner: v.string(), repo: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const row = await ctx.db
|
||||
.query("repoBranchList")
|
||||
.query("repoTagList")
|
||||
.withIndex("by_owner_repo", q => q.eq("owner", args.owner).eq("repo", args.repo))
|
||||
.first()
|
||||
if (!row) {
|
||||
return { row: null as null, isStale: true as const }
|
||||
}
|
||||
const isStale = Date.now() - row.fetchedAt > BRANCH_TTL_MS
|
||||
const isStale = Date.now() - row.fetchedAt > TAG_TTL_MS
|
||||
return { row, isStale }
|
||||
},
|
||||
})
|
||||
@@ -27,23 +31,21 @@ export const upsertFromGitHub = internalMutation({
|
||||
args: {
|
||||
owner: v.string(),
|
||||
repo: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
branches: v.array(branchEntryValidator),
|
||||
tags: v.array(tagEntryValidator),
|
||||
etag: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
homepage: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("repoBranchList")
|
||||
.query("repoTagList")
|
||||
.withIndex("by_owner_repo", q => q.eq("owner", args.owner).eq("repo", args.repo))
|
||||
.first()
|
||||
const now = Date.now()
|
||||
const doc = {
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
defaultBranch: args.defaultBranch,
|
||||
branches: args.branches,
|
||||
tags: args.tags,
|
||||
fetchedAt: now,
|
||||
etag: args.etag,
|
||||
description: args.description,
|
||||
@@ -53,7 +55,7 @@ export const upsertFromGitHub = internalMutation({
|
||||
await ctx.db.patch(existing._id, doc)
|
||||
return existing._id
|
||||
}
|
||||
return await ctx.db.insert("repoBranchList", doc)
|
||||
return await ctx.db.insert("repoTagList", doc)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -72,40 +74,39 @@ export const refresh = action({
|
||||
throw new Error(`GitHub repo: ${repoRes.status} ${await repoRes.text()}`)
|
||||
}
|
||||
const repoJson = (await repoRes.json()) as {
|
||||
default_branch: string
|
||||
description: string | null
|
||||
homepage: string | null
|
||||
}
|
||||
const defaultBranch = repoJson.default_branch
|
||||
const description = (repoJson.description ?? "").trim()
|
||||
const homepage = (repoJson.homepage ?? "").trim()
|
||||
|
||||
const branches: { name: string; sha?: string }[] = []
|
||||
const rawTags: TagEntry[] = []
|
||||
let page = 1
|
||||
const perPage = 100
|
||||
while (page <= 10) {
|
||||
const br = await fetch(
|
||||
`https://api.github.com/repos/${args.owner}/${args.repo}/branches?per_page=${perPage}&page=${page}`,
|
||||
const tr = await fetch(
|
||||
`https://api.github.com/repos/${args.owner}/${args.repo}/tags?per_page=${perPage}&page=${page}`,
|
||||
{ headers }
|
||||
)
|
||||
if (!br.ok) throw new Error(`GitHub branches: ${br.status}`)
|
||||
const arr = (await br.json()) as { name: string; commit: { sha: string } }[]
|
||||
for (const b of arr) {
|
||||
branches.push({ name: b.name, sha: b.commit.sha })
|
||||
if (!tr.ok) throw new Error(`GitHub tags: ${tr.status}`)
|
||||
const arr = (await tr.json()) as { name: string; commit: { sha: string } }[]
|
||||
for (const t of arr) {
|
||||
rawTags.push({ name: t.name, sha: t.commit.sha })
|
||||
}
|
||||
if (arr.length < perPage) break
|
||||
page++
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.repoBranches.upsertFromGitHub, {
|
||||
const tags = sortTagEntries(rawTags)
|
||||
|
||||
await ctx.runMutation(internal.repoTags.upsertFromGitHub, {
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
defaultBranch,
|
||||
branches,
|
||||
tags,
|
||||
description,
|
||||
homepage,
|
||||
})
|
||||
return { ok: true as const, branchCount: branches.length, defaultBranch }
|
||||
return { ok: true as const, tagCount: tags.length }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,10 +120,7 @@ export const fetchReadme = action({
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const enc = encodeURIComponent(args.ref)
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${args.owner}/${args.repo}/readme?ref=${enc}`,
|
||||
{ headers }
|
||||
)
|
||||
const res = await fetch(`https://api.github.com/repos/${args.owner}/${args.repo}/readme?ref=${enc}`, { headers })
|
||||
if (res.status === 404) return { markdown: "" as string, missing: true as const }
|
||||
if (!res.ok) throw new Error(`readme: ${res.status} ${await res.text()}`)
|
||||
const markdown = await res.text()
|
||||
+6
-11
@@ -2,14 +2,14 @@ import { authTables } from "@convex-dev/auth/server"
|
||||
import { defineSchema, defineTable } from "convex/server"
|
||||
import { v } from "convex/values"
|
||||
|
||||
export const repoBranchListFields = {
|
||||
export const repoTagListFields = {
|
||||
owner: v.string(),
|
||||
repo: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
branches: v.array(
|
||||
/** Tag names + commit SHAs, SemVer-descending then non-SemVer reverse lexicographic (see convex/repoTags). */
|
||||
tags: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
sha: v.optional(v.string()),
|
||||
sha: v.string(),
|
||||
})
|
||||
),
|
||||
fetchedAt: v.number(),
|
||||
@@ -42,12 +42,7 @@ export const repoBuildsFields = {
|
||||
resolvedSourceSha: v.string(),
|
||||
targetEnv: v.string(),
|
||||
buildKey: v.string(),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed")
|
||||
),
|
||||
status: v.union(v.literal("queued"), v.literal("running"), v.literal("succeeded"), v.literal("failed")),
|
||||
startedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
@@ -73,7 +68,7 @@ export const userSettingsFields = {
|
||||
|
||||
export const schema = defineSchema({
|
||||
...authTables,
|
||||
repoBranchList: defineTable(repoBranchListFields).index("by_owner_repo", ["owner", "repo"]),
|
||||
repoTagList: defineTable(repoTagListFields).index("by_owner_repo", ["owner", "repo"]),
|
||||
repoRefScan: defineTable(repoRefScanFields).index("by_repo_sha", ["owner", "repo", "resolvedSourceSha"]),
|
||||
repoBuilds: defineTable(repoBuildsFields)
|
||||
.index("by_buildKey", ["buildKey"])
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"react-router-dom": "^7.14.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"semver": "^7.7.4",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,6 +50,7 @@
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"postcss": "^8.5.9",
|
||||
|
||||
+18
-15
@@ -1,29 +1,32 @@
|
||||
/**
|
||||
* Mesh Forge tree URLs: `/owner/repo/tree/<branch segments>/target/<env>`
|
||||
* Branch ref may contain `/`; `target` is a reserved final segment pair.
|
||||
* Mesh Forge tree URLs: `/owner/repo/tree/<tag-or-ref segments>/target/<env>`
|
||||
* Source ref may contain `/` (nested tags are rare but allowed). `target` is a reserved final segment pair.
|
||||
*/
|
||||
const TARGET_TAIL = /\/target\/([^/]+)$/
|
||||
|
||||
export function parseTreeSplat(treePath: string | undefined): {
|
||||
branchRef: string | null
|
||||
sourceRef: string | null
|
||||
targetEnv: string | null
|
||||
} {
|
||||
if (!treePath?.trim()) return { branchRef: null, targetEnv: null }
|
||||
const segments = treePath.split('/').filter(Boolean)
|
||||
if (segments.length === 0) return { branchRef: null, targetEnv: null }
|
||||
const joined = segments.map(s => decodeURIComponent(s)).join('/')
|
||||
if (!treePath?.trim()) return { sourceRef: null, targetEnv: null }
|
||||
const segments = treePath.split("/").filter(Boolean)
|
||||
if (segments.length === 0) return { sourceRef: null, targetEnv: null }
|
||||
const joined = segments.map(s => decodeURIComponent(s)).join("/")
|
||||
const m = TARGET_TAIL.exec(joined)
|
||||
if (!m) return { branchRef: joined, targetEnv: null }
|
||||
const branchRef = joined.slice(0, m.index).replace(/\/$/, '') || null
|
||||
if (!m) return { sourceRef: joined, targetEnv: null }
|
||||
const sourceRef = joined.slice(0, m.index).replace(/\/$/, "") || null
|
||||
const targetEnv = m[1] ? decodeURIComponent(m[1]) : null
|
||||
return { branchRef, targetEnv }
|
||||
return { sourceRef, targetEnv }
|
||||
}
|
||||
|
||||
/** Path after `/tree/` (no leading slash). Empty string means no branch — use short repo URL. */
|
||||
export function buildTreeSplatPath(branchRef: string | null, targetEnv: string | null): string {
|
||||
const b = branchRef?.trim()
|
||||
if (!b) return ''
|
||||
const enc = b.split('/').map(s => encodeURIComponent(s)).join('/')
|
||||
/** Path after `/tree/` (no leading slash). Empty string means no ref — short `/owner/repo` redirects to latest tag. */
|
||||
export function buildTreeSplatPath(sourceRef: string | null, targetEnv: string | null): string {
|
||||
const b = sourceRef?.trim()
|
||||
if (!b) return ""
|
||||
const enc = b
|
||||
.split("/")
|
||||
.map(s => encodeURIComponent(s))
|
||||
.join("/")
|
||||
const t = targetEnv?.trim()
|
||||
if (!t) return enc
|
||||
return `${enc}/target/${encodeURIComponent(t)}`
|
||||
|
||||
+25
-27
@@ -1,43 +1,43 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { parseGithubUrl } from '../lib/parseGithubUrl'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { parseGithubUrl } from "../lib/parseGithubUrl"
|
||||
|
||||
function encodeBranchPath(ref: string) {
|
||||
return ref.split('/').map(encodeURIComponent).join('/')
|
||||
function encodeTreePath(ref: string) {
|
||||
return ref.split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
const DEMO_REPOS: { label: string; owner: string; repo: string; githubUrl: string }[] = [
|
||||
{
|
||||
label: 'meshtastic/firmware',
|
||||
owner: 'meshtastic',
|
||||
repo: 'firmware',
|
||||
githubUrl: 'https://github.com/meshtastic/firmware',
|
||||
label: "meshtastic/firmware",
|
||||
owner: "meshtastic",
|
||||
repo: "firmware",
|
||||
githubUrl: "https://github.com/meshtastic/firmware",
|
||||
},
|
||||
{
|
||||
label: 'meshcore-dev/MeshCore',
|
||||
owner: 'meshcore-dev',
|
||||
repo: 'MeshCore',
|
||||
githubUrl: 'https://github.com/meshcore-dev/MeshCore',
|
||||
label: "meshcore-dev/MeshCore",
|
||||
owner: "meshcore-dev",
|
||||
repo: "MeshCore",
|
||||
githubUrl: "https://github.com/meshcore-dev/MeshCore",
|
||||
},
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const [input, setInput] = useState('')
|
||||
const [input, setInput] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const go = () => {
|
||||
const parsed = parseGithubUrl(input)
|
||||
if (!parsed) {
|
||||
setError('Paste a GitHub URL like https://github.com/owner/repo or owner/repo')
|
||||
setError("Paste a GitHub URL like https://github.com/owner/repo or owner/repo")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
const o = encodeURIComponent(parsed.owner)
|
||||
const r = encodeURIComponent(parsed.repo)
|
||||
if (parsed.treePath) {
|
||||
const path = encodeBranchPath(parsed.treePath)
|
||||
const path = encodeTreePath(parsed.treePath)
|
||||
navigate(`/${o}/${r}/tree/${path}`)
|
||||
} else {
|
||||
navigate(`/${o}/${r}`)
|
||||
@@ -52,8 +52,8 @@ export default function HomePage() {
|
||||
Mesh Forge
|
||||
</h1>
|
||||
<p className="mt-4 text-slate-400 text-lg">
|
||||
Browse a GitHub PlatformIO repo, scan environments, trigger a CI build — then flash the bundle over USB
|
||||
from the same page with Web Serial (Chromium).
|
||||
Browse a GitHub PlatformIO repo, scan environments, trigger a CI build — then flash the bundle over USB from
|
||||
the same page with Web Serial (Chromium).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +64,10 @@ export default function HomePage() {
|
||||
<input
|
||||
id="gh-url"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-600"
|
||||
placeholder="https://github.com/owner/repo/tree/main"
|
||||
placeholder="https://github.com/owner/repo/tree/v1.0.0"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && go()}
|
||||
onKeyDown={e => e.key === "Enter" && go()}
|
||||
/>
|
||||
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700" type="button" onClick={go}>
|
||||
@@ -85,9 +85,7 @@ export default function HomePage() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-slate-300 hover:text-cyan-400 text-sm justify-start h-auto py-1 px-0 font-normal"
|
||||
onClick={() =>
|
||||
navigate(`/${encodeURIComponent(d.owner)}/${encodeURIComponent(d.repo)}`)
|
||||
}
|
||||
onClick={() => navigate(`/${encodeURIComponent(d.owner)}/${encodeURIComponent(d.repo)}`)}
|
||||
>
|
||||
Open {d.label}
|
||||
</Button>
|
||||
@@ -97,7 +95,7 @@ export default function HomePage() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.githubUrl.replace(/^https:\/\//, '')}
|
||||
{d.githubUrl.replace(/^https:\/\//, "")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -106,8 +104,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Repo URLs: <code className="text-slate-400">/owner/repo</code> (pick branch),{' '}
|
||||
<code className="text-slate-400">/owner/repo/tree/ref</code>, or add{' '}
|
||||
Repo URLs: <code className="text-slate-400">/owner/repo</code> jumps to the latest SemVer tag,{" "}
|
||||
<code className="text-slate-400">/owner/repo/tree/tag</code> pins a tag or commit ref, or add{" "}
|
||||
<code className="text-slate-400">/target/envName</code> to deep-link a PlatformIO environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+169
-136
@@ -1,54 +1,64 @@
|
||||
import { ComboboxField } from '../components/ComboboxField'
|
||||
import EspFlasher from '../components/EspFlasher'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/convex/_generated/api'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { Github, Link2, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { homepageHref, homepageLabel } from '../lib/githubHomepage'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { toast } from 'sonner'
|
||||
import { normalizeBuildKey } from '../lib/buildKey'
|
||||
import { buildFailurePresentation } from '../lib/formatBuildErrorSummary'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { sortTagNames } from "@/convex/lib/tagSemver"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
import { Github, Link2, RefreshCw } from "lucide-react"
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { Link, useNavigate, useParams } from "react-router-dom"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { toast } from "sonner"
|
||||
import { ComboboxField } from "../components/ComboboxField"
|
||||
import EspFlasher from "../components/EspFlasher"
|
||||
import { normalizeBuildKey } from "../lib/buildKey"
|
||||
import { buildFailurePresentation } from "../lib/formatBuildErrorSummary"
|
||||
import { homepageHref, homepageLabel } from "../lib/githubHomepage"
|
||||
import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
|
||||
|
||||
const MESH_FORGE_ACTIONS_REPO = 'MeshEnvy/mesh-forge'
|
||||
const MESH_FORGE_ACTIONS_REPO = "MeshEnvy/mesh-forge"
|
||||
const meshForgeWorkflowUrl = `https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/workflows/custom_build.yml`
|
||||
import { buildTreeSplatPath, parseTreeSplat } from '../lib/repoTreeUrl'
|
||||
|
||||
export default function RepoPage() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ owner: string; repo: string; '*': string }>()
|
||||
const ownerParam = params.owner ?? ''
|
||||
const repoParam = params.repo ?? ''
|
||||
const treePath = params['*']
|
||||
const params = useParams<{ owner: string; repo: string; "*": string }>()
|
||||
const ownerParam = params.owner ?? ""
|
||||
const repoParam = params.repo ?? ""
|
||||
const treePath = params["*"]
|
||||
const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam])
|
||||
const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam])
|
||||
const { branchRef, targetEnv: targetFromUrl } = useMemo(() => parseTreeSplat(treePath), [treePath])
|
||||
const hasBranch = Boolean(branchRef)
|
||||
const { sourceRef, targetEnv: targetFromUrl } = useMemo(() => parseTreeSplat(treePath), [treePath])
|
||||
const hasRef = Boolean(sourceRef)
|
||||
|
||||
const branchData = useQuery(
|
||||
api.repoBranches.get,
|
||||
owner && repo ? { owner, repo } : 'skip'
|
||||
)
|
||||
const refreshBranches = useAction(api.repoBranches.refresh)
|
||||
const tagData = useQuery(api.repoTags.get, owner && repo ? { owner, repo } : "skip")
|
||||
const refreshTags = useAction(api.repoTags.refresh)
|
||||
const resolveRef = useAction(api.repoScans.resolveRefToSha)
|
||||
const fetchReadme = useAction(api.repoBranches.fetchReadme)
|
||||
const fetchReadme = useAction(api.repoTags.fetchReadme)
|
||||
const ensureScan = useMutation(api.repoScans.ensureScan)
|
||||
const ensureBuild = useMutation(api.repoBuilds.ensureBuild)
|
||||
const retryBuild = useMutation(api.repoBuilds.retryBuild)
|
||||
const getSignedUrl = useAction(api.repoBuildDownloads.getSignedDownloadUrl)
|
||||
/** Git branch/ref from URL only (null = `--branch--` / short `/owner/repo`). */
|
||||
const effectiveRef = branchRef
|
||||
/** Git ref from URL only (null = short `/owner/repo` → redirect to latest SemVer tag). */
|
||||
const effectiveRef = sourceRef
|
||||
|
||||
useEffect(() => {
|
||||
if (!owner || !repo || branchData === undefined) return
|
||||
if (branchData.row !== null && !branchData.isStale) return
|
||||
void refreshBranches({ owner, repo }).catch(e => toast.error(String(e)))
|
||||
}, [owner, repo, branchData, refreshBranches])
|
||||
if (!owner || !repo || tagData === undefined) return
|
||||
if (tagData.row !== null && !tagData.isStale) return
|
||||
void refreshTags({ owner, repo }).catch(e => toast.error(String(e)))
|
||||
}, [owner, repo, tagData, refreshTags])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!owner || !repo) return
|
||||
if (sourceRef) return
|
||||
if (tagData === undefined || !tagData.row) return
|
||||
const tags = tagData.row.tags
|
||||
if (tags.length === 0) return
|
||||
const sorted = sortTagNames(tags.map(t => t.name))
|
||||
const latest = sorted[0]
|
||||
if (!latest) return
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
|
||||
}, [owner, repo, sourceRef, tagData, navigate, ownerParam, repoParam])
|
||||
|
||||
const [resolvedSha, setResolvedSha] = useState<string | null>(null)
|
||||
const [refError, setRefError] = useState<string | null>(null)
|
||||
@@ -78,7 +88,7 @@ export default function RepoPage() {
|
||||
|
||||
const scan = useQuery(
|
||||
api.repoScans.getByRepoSha,
|
||||
resolvedSha ? { owner, repo, resolvedSourceSha: resolvedSha } : 'skip'
|
||||
resolvedSha ? { owner, repo, resolvedSourceSha: resolvedSha } : "skip"
|
||||
)
|
||||
|
||||
const [readmeMd, setReadmeMd] = useState<string | null>(null)
|
||||
@@ -91,67 +101,85 @@ export default function RepoPage() {
|
||||
if (!cancelled) setReadmeMd(r.markdown)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setReadmeMd('*(README could not be loaded.)*')
|
||||
if (!cancelled) setReadmeMd("*(README could not be loaded.)*")
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [owner, repo, effectiveRef, fetchReadme])
|
||||
|
||||
const envNames = scan?.scanStatus === 'complete' ? scan.envNames ?? [] : []
|
||||
const envNames = scan?.scanStatus === "complete" ? (scan.envNames ?? []) : []
|
||||
const resolvedTargetEnv =
|
||||
hasBranch && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl)
|
||||
? targetFromUrl
|
||||
: ''
|
||||
hasRef && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl) ? targetFromUrl : ""
|
||||
|
||||
const [branchDraft, setBranchDraft] = useState('')
|
||||
const [tagDraft, setTagDraft] = useState("")
|
||||
useEffect(() => {
|
||||
setBranchDraft(branchRef ?? '')
|
||||
}, [branchRef])
|
||||
|
||||
const [envDraft, setEnvDraft] = useState('')
|
||||
useEffect(() => {
|
||||
if (!hasBranch) {
|
||||
setEnvDraft('')
|
||||
if (!sourceRef) {
|
||||
setTagDraft("")
|
||||
return
|
||||
}
|
||||
setEnvDraft(targetFromUrl ?? '')
|
||||
}, [hasBranch, targetFromUrl])
|
||||
const tags = tagData?.row?.tags
|
||||
if (!tags?.length) {
|
||||
setTagDraft(sourceRef)
|
||||
return
|
||||
}
|
||||
const names = tags.map(t => t.name)
|
||||
const canonical =
|
||||
names.find(n => n === sourceRef) ?? names.find(n => n.toLowerCase() === sourceRef.toLowerCase()) ?? sourceRef
|
||||
setTagDraft(canonical)
|
||||
}, [sourceRef, tagData?.row?.tags])
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
const tags = tagData?.row?.tags
|
||||
if (!tags?.length) return []
|
||||
const raw = tags.map(t => t.name)
|
||||
const needExtra = sourceRef && !raw.some(n => n === sourceRef || n.toLowerCase() === sourceRef.toLowerCase())
|
||||
const merged = needExtra ? [...raw, sourceRef] : [...raw]
|
||||
return sortTagNames(merged)
|
||||
}, [tagData?.row?.tags, sourceRef])
|
||||
|
||||
const [envDraft, setEnvDraft] = useState("")
|
||||
useEffect(() => {
|
||||
if (!hasRef) {
|
||||
setEnvDraft("")
|
||||
return
|
||||
}
|
||||
setEnvDraft(targetFromUrl ?? "")
|
||||
}, [hasRef, targetFromUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchRef || !targetFromUrl) return
|
||||
if (scan?.scanStatus !== 'complete') return
|
||||
if (!sourceRef || !targetFromUrl) return
|
||||
if (scan?.scanStatus !== "complete") return
|
||||
if (envNames.length === 0 || !envNames.includes(targetFromUrl)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, null)}`, { replace: true })
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, { replace: true })
|
||||
}
|
||||
}, [branchRef, targetFromUrl, envNames, scan?.scanStatus, navigate, ownerParam, repoParam])
|
||||
}, [sourceRef, targetFromUrl, envNames, scan?.scanStatus, navigate, ownerParam, repoParam])
|
||||
|
||||
const buildKey =
|
||||
resolvedSha && resolvedTargetEnv ? normalizeBuildKey(resolvedSha, resolvedTargetEnv) : null
|
||||
const build = useQuery(api.repoBuilds.getByBuildKey, buildKey ? { buildKey } : 'skip')
|
||||
const buildKey = resolvedSha && resolvedTargetEnv ? normalizeBuildKey(resolvedSha, resolvedTargetEnv) : null
|
||||
const build = useQuery(api.repoBuilds.getByBuildKey, buildKey ? { buildKey } : "skip")
|
||||
|
||||
const [flashUrl, setFlashUrl] = useState<string | null>(null)
|
||||
const [flashPrep, setFlashPrep] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [flashPrep, setFlashPrep] = useState<"idle" | "loading" | "ready" | "error">("idle")
|
||||
|
||||
useEffect(() => {
|
||||
if (!build?._id || build.status !== 'succeeded') {
|
||||
if (!build?._id || build.status !== "succeeded") {
|
||||
setFlashUrl(null)
|
||||
setFlashPrep('idle')
|
||||
setFlashPrep("idle")
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setFlashPrep('loading')
|
||||
setFlashPrep("loading")
|
||||
setFlashUrl(null)
|
||||
void getSignedUrl({ buildId: build._id })
|
||||
.then(url => {
|
||||
if (cancelled) return
|
||||
setFlashUrl(url)
|
||||
setFlashPrep('ready')
|
||||
setFlashPrep("ready")
|
||||
})
|
||||
.catch(e => {
|
||||
if (cancelled) return
|
||||
toast.error(String(e))
|
||||
setFlashPrep('error')
|
||||
setFlashPrep("error")
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -160,9 +188,9 @@ export default function RepoPage() {
|
||||
|
||||
const queueFlashArtifacts = () => {
|
||||
if (!effectiveRef || !resolvedSha || !resolvedTargetEnv) return
|
||||
if (build?.status === 'failed' && build._id) {
|
||||
if (build?.status === "failed" && build._id) {
|
||||
void retryBuild({ buildId: build._id })
|
||||
.then(() => toast.message('Re-queued build'))
|
||||
.then(() => toast.message("Re-queued build"))
|
||||
.catch(e => toast.error(String(e)))
|
||||
return
|
||||
}
|
||||
@@ -179,21 +207,19 @@ export default function RepoPage() {
|
||||
if (!build?._id) return
|
||||
try {
|
||||
const url = await getSignedUrl({ buildId: build._id })
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (branchData === undefined) {
|
||||
return (
|
||||
<div className="min-h-[40vh] flex items-center justify-center text-slate-400">Loading repository…</div>
|
||||
)
|
||||
if (tagData === undefined) {
|
||||
return <div className="min-h-[40vh] flex items-center justify-center text-slate-400">Loading repository…</div>
|
||||
}
|
||||
if (!branchData.row) {
|
||||
if (!tagData.row) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto px-6 py-16 text-slate-300">
|
||||
<p className="mb-4">Could not load branch list. The repository may be private or missing.</p>
|
||||
<p className="mb-4">Could not load tags. The repository may be private or missing.</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/">Home</Link>
|
||||
</Button>
|
||||
@@ -201,23 +227,35 @@ export default function RepoPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (tagData.row.tags.length === 0) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto px-6 py-16 text-slate-300">
|
||||
<p className="mb-4">
|
||||
This repository has no tags. Mesh Forge needs at least one tag to pick a source revision.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/">Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sourceRef) {
|
||||
return <div className="min-h-[40vh] flex items-center justify-center text-slate-400">Opening latest tag…</div>
|
||||
}
|
||||
|
||||
const ghRepoRoot = `https://github.com/${owner}/${repo}`
|
||||
const ghTree = effectiveRef
|
||||
? `https://github.com/${owner}/${repo}/tree/${effectiveRef.split('/').map(encodeURIComponent).join('/')}`
|
||||
? `https://github.com/${owner}/${repo}/tree/${effectiveRef.split("/").map(encodeURIComponent).join("/")}`
|
||||
: ghRepoRoot
|
||||
|
||||
const branchNames = branchData.row.branches.map(b => b.name)
|
||||
let branchOptions =
|
||||
branchRef && !branchNames.includes(branchRef) ? [branchRef, ...branchNames] : [...branchNames]
|
||||
if (branchOptions.length === 0 && branchRef) branchOptions = [branchRef]
|
||||
const ghAboutDescription = tagData.row.description?.trim() ?? ""
|
||||
const ghAboutHomepage = tagData.row.homepage?.trim() ?? ""
|
||||
|
||||
const ghAboutDescription = branchData.row.description?.trim() ?? ''
|
||||
const ghAboutHomepage = branchData.row.homepage?.trim() ?? ''
|
||||
|
||||
const scanReady = Boolean(hasBranch && resolvedSha && scan?.scanStatus === 'complete' && envNames.length > 0)
|
||||
const buildInProgress = Boolean(build && (build.status === 'queued' || build.status === 'running'))
|
||||
const scanReady = Boolean(hasRef && resolvedSha && scan?.scanStatus === "complete" && envNames.length > 0)
|
||||
const buildInProgress = Boolean(build && (build.status === "queued" || build.status === "running"))
|
||||
const flashPrimaryDisabled =
|
||||
!hasBranch ||
|
||||
!hasRef ||
|
||||
!resolvedSha ||
|
||||
Boolean(refError) ||
|
||||
!resolvedTargetEnv ||
|
||||
@@ -225,20 +263,19 @@ export default function RepoPage() {
|
||||
!scanReady ||
|
||||
buildInProgress
|
||||
|
||||
const flashButtonLabel =
|
||||
build?.status === 'failed' ? 'Retry build' : buildInProgress ? 'Building…' : 'Flash'
|
||||
const flashButtonLabel = build?.status === "failed" ? "Retry build" : buildInProgress ? "Building…" : "Flash"
|
||||
|
||||
const targetPlaceholder = !hasBranch
|
||||
? '--target--'
|
||||
const targetPlaceholder = !hasRef
|
||||
? "--target--"
|
||||
: !resolvedSha
|
||||
? '…'
|
||||
: scan == null || scan.scanStatus === 'in_progress'
|
||||
? 'Scanning…'
|
||||
: scan.scanStatus === 'failed'
|
||||
? 'Scan failed'
|
||||
? "…"
|
||||
: scan == null || scan.scanStatus === "in_progress"
|
||||
? "Scanning…"
|
||||
: scan.scanStatus === "failed"
|
||||
? "Scan failed"
|
||||
: envNames.length === 0
|
||||
? 'No targets'
|
||||
: '--target--'
|
||||
? "No targets"
|
||||
: "--target--"
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-10 text-slate-200">
|
||||
@@ -251,26 +288,26 @@ export default function RepoPage() {
|
||||
<div className="min-w-0 space-y-5 [grid-area:repo-main]">
|
||||
<div className="flex flex-nowrap items-end gap-2 overflow-x-auto border-b border-slate-800 pb-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ComboboxField
|
||||
label="Branch"
|
||||
label="Tag"
|
||||
layout="inline"
|
||||
id="mesh-forge-branch"
|
||||
options={branchOptions}
|
||||
value={branchDraft}
|
||||
placeholder="--branch--"
|
||||
clearSelectionLabel="Clear branch"
|
||||
id="mesh-forge-tag"
|
||||
options={tagOptions}
|
||||
value={tagDraft}
|
||||
placeholder="--tag--"
|
||||
clearSelectionLabel="Clear tag"
|
||||
onChange={v => {
|
||||
setBranchDraft(v)
|
||||
if (v === '') {
|
||||
setTagDraft(v)
|
||||
if (v === "") {
|
||||
navigate(`/${ownerParam}/${repoParam}`)
|
||||
return
|
||||
}
|
||||
if (branchOptions.includes(v)) {
|
||||
if (tagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, null)}`)
|
||||
}
|
||||
}}
|
||||
disabled={branchOptions.length === 0}
|
||||
disabled={tagOptions.length === 0}
|
||||
/>
|
||||
{hasBranch && scanReady && envNames.length > 0 ? (
|
||||
{hasRef && scanReady && envNames.length > 0 ? (
|
||||
<ComboboxField
|
||||
label="Target"
|
||||
layout="inline"
|
||||
@@ -281,15 +318,15 @@ export default function RepoPage() {
|
||||
clearSelectionLabel="Clear target"
|
||||
onChange={v => {
|
||||
setEnvDraft(v)
|
||||
if (!branchRef) return
|
||||
if (v === '') {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, null)}`, {
|
||||
if (!sourceRef) return
|
||||
if (v === "") {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, {
|
||||
replace: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (envNames.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, v)}`)
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
@@ -300,7 +337,7 @@ export default function RepoPage() {
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
disabled={!hasBranch}
|
||||
disabled={!hasRef}
|
||||
value=""
|
||||
placeholder={targetPlaceholder}
|
||||
className="h-9 min-w-28 flex-1 cursor-not-allowed rounded-md border border-slate-800 bg-slate-900/50 px-2.5 text-sm text-slate-500 placeholder:text-slate-600"
|
||||
@@ -319,14 +356,14 @@ export default function RepoPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
{branchData?.isStale ? <span>Branch list may be stale.</span> : null}
|
||||
{tagData?.isStale ? <span>Tag list may be stale.</span> : null}
|
||||
{refError ? <span className="text-red-400">{refError}</span> : null}
|
||||
{!refError && hasBranch && !resolvedSha ? <span>Resolving branch…</span> : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === 'in_progress') ? (
|
||||
{!refError && hasRef && !resolvedSha ? <span>Resolving tag…</span> : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? (
|
||||
<span>Scanning PlatformIO…</span>
|
||||
) : null}
|
||||
{resolvedSha && scan?.scanStatus === 'failed' ? (
|
||||
<span className="text-red-300">Scan failed: {scan.scanError ?? 'unknown'}</span>
|
||||
{resolvedSha && scan?.scanStatus === "failed" ? (
|
||||
<span className="text-red-300">Scan failed: {scan.scanError ?? "unknown"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -345,7 +382,7 @@ export default function RepoPage() {
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : build.status === 'failed' ? (
|
||||
) : build.status === "failed" ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={meshForgeWorkflowUrl}
|
||||
@@ -357,7 +394,7 @@ export default function RepoPage() {
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{build.status === 'failed' && build.errorSummary ? (
|
||||
{build.status === "failed" && build.errorSummary ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
const { headline, body } = buildFailurePresentation(build.errorSummary)
|
||||
@@ -380,7 +417,7 @@ export default function RepoPage() {
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === 'succeeded' ? (
|
||||
{build.status === "succeeded" ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
|
||||
Download bundle
|
||||
</Button>
|
||||
@@ -388,8 +425,8 @@ export default function RepoPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{flashPrep === 'loading' ? <p className="text-sm text-slate-400">Preparing USB flasher…</p> : null}
|
||||
{flashPrep === 'error' ? (
|
||||
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher…</p> : null}
|
||||
{flashPrep === "error" ? (
|
||||
<p className="text-sm text-amber-200/90">
|
||||
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
|
||||
</p>
|
||||
@@ -421,19 +458,17 @@ export default function RepoPage() {
|
||||
href={ghTree}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
>
|
||||
<Github className="size-4" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
</span>
|
||||
</a>
|
||||
) : null}
|
||||
</h3>
|
||||
</div>
|
||||
{ghAboutDescription ? (
|
||||
<p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p>
|
||||
) : null}
|
||||
{ghAboutDescription ? <p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p> : null}
|
||||
{ghAboutHomepage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<a
|
||||
@@ -450,11 +485,11 @@ export default function RepoPage() {
|
||||
href={ghTree}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
>
|
||||
<Github className="size-4" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -465,23 +500,21 @@ export default function RepoPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-slate-600 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-white"
|
||||
title="Refresh branches from GitHub"
|
||||
onClick={() => void refreshBranches({ owner, repo }).catch(e => toast.error(String(e)))}
|
||||
title="Refresh tags from GitHub"
|
||||
onClick={() => void refreshTags({ owner, repo }).catch(e => toast.error(String(e)))}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Refresh branches
|
||||
Refresh tags
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="[grid-area:repo-readme] prose prose-invert prose-sm max-w-none prose-hr:my-6">
|
||||
{!effectiveRef ? (
|
||||
<p className="text-slate-500 not-prose text-sm">Select a branch to load the README.</p>
|
||||
) : readmeMd === null ? (
|
||||
{readmeMd === null ? (
|
||||
<p className="text-slate-500 not-prose">Loading…</p>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
|
||||
{readmeMd || '*No README.*'}
|
||||
{readmeMd || "*No README.*"}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user