@jini-ai/agent-plugins 0.6.17 → 0.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/NOTICE +14 -0
  3. package/README.md +35 -14
  4. package/charts-echarts/plugin.json +39 -0
  5. package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
  6. package/charts-recharts/plugin.json +32 -0
  7. package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
  8. package/cloudflare/NOTES.md +16 -0
  9. package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
  10. package/cloudflare/lib/api.mjs +96 -0
  11. package/cloudflare/lib/assets.mjs +65 -0
  12. package/cloudflare/lib/dns.mjs +32 -0
  13. package/cloudflare/lib/domain-inventory.mjs +61 -0
  14. package/cloudflare/lib/domain-status.mjs +48 -0
  15. package/cloudflare/lib/domains.mjs +95 -0
  16. package/cloudflare/lib/forms.mjs +254 -0
  17. package/cloudflare/lib/pages-upload.mjs +51 -0
  18. package/cloudflare/lib/target.mjs +165 -0
  19. package/cloudflare/lib/workers-upload.mjs +48 -0
  20. package/cloudflare/lib/zones.mjs +25 -0
  21. package/cloudflare/mcp.json +4 -0
  22. package/cloudflare/plugin.json +96 -0
  23. package/cloudflare/skills/cloudflare/SKILL.md +57 -0
  24. package/cloudflare/skills/cloudflare/references/domains.md +31 -0
  25. package/cloudflare/skills/cloudflare/references/pages.md +18 -0
  26. package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
  27. package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
  28. package/cloudflare/skills/cloudflare/references/workers.md +42 -0
  29. package/cloudflare/targets/cloudflare-pages.mjs +12 -0
  30. package/cloudflare/targets/cloudflare-workers.mjs +17 -0
  31. package/cloudflare/tovu-consent.json +6 -0
  32. package/cloudflare/tovu-deploy-targets.json +630 -0
  33. package/cloudflare/tovu-form-destinations.json +12 -0
  34. package/cloudflare/worker/forms-receiver.mjs +249 -0
  35. package/composio/mcp.json +10 -0
  36. package/composio/plugin.json +68 -0
  37. package/composio/skills/composio/SKILL.md +85 -0
  38. package/dist/core/identity-namespace.d.ts +12 -0
  39. package/dist/core/identity-namespace.js +20 -0
  40. package/dist/folder-zip-node.js +29 -7
  41. package/dist/lifecycle/bundled-digests.js +4 -3
  42. package/dist/lifecycle/install.d.ts +7 -0
  43. package/dist/lifecycle/install.js +45 -3
  44. package/dist/lifecycle/manifest.d.ts +2 -2
  45. package/dist/lifecycle/manifest.js +5 -3
  46. package/dist/lifecycle/node.js +2 -0
  47. package/dist/lifecycle/persistent-state.js +1 -1
  48. package/dist/lifecycle/ports.d.ts +8 -0
  49. package/dist/lifecycle/seed-bundled.js +4 -1
  50. package/dist/manifest.d.ts +1 -1
  51. package/dist/manifest.js +3 -2
  52. package/fly/deploy-configs/fly.mjs +115 -0
  53. package/fly/deploy-ops/fly.mjs +102 -0
  54. package/fly/mcp.json +4 -0
  55. package/fly/plugin.json +81 -0
  56. package/fly/scripts/render-deploy-workflow.mjs +150 -0
  57. package/fly/skills/fly/SKILL.md +43 -0
  58. package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
  59. package/fly/skills/fly/references/fly.template.toml +81 -0
  60. package/fly/tovu-credential-schemes.json +11 -0
  61. package/fly/tovu-deploy-configs.json +21 -0
  62. package/fly/tovu-deploy-ops.json +14 -0
  63. package/github/deploy-ops/github-actions.mjs +164 -0
  64. package/github/deployments/github.mjs +415 -0
  65. package/github/mcp.json +4 -0
  66. package/github/plugin.json +105 -0
  67. package/github/skills/github/SKILL.md +249 -0
  68. package/github/skills/github/references/actions.md +224 -0
  69. package/github/skills/github/references/auth-and-tokens.md +140 -0
  70. package/github/skills/github/references/repo-files.md +135 -0
  71. package/github/skills/github/references/site-backup.md +159 -0
  72. package/github/skills/github/references/source-control.md +123 -0
  73. package/github/source-control/backup-push.mjs +242 -0
  74. package/github/source-control/commit-site.mjs +459 -0
  75. package/github/source-control/github.mjs +93 -0
  76. package/github/source-control/write-files.mjs +468 -0
  77. package/github/tovu-deploy-ops.json +13 -0
  78. package/github/tovu-source-control.json +117 -0
  79. package/github-pages/NOTES.md +19 -0
  80. package/github-pages/mcp.json +4 -0
  81. package/github-pages/plugin.json +48 -0
  82. package/github-pages/skills/github-pages/SKILL.md +44 -0
  83. package/github-pages/targets/github-pages.mjs +543 -0
  84. package/github-pages/tovu-deploy-targets.json +327 -0
  85. package/google-login/exports/google-g.svg +1 -0
  86. package/google-login/exports/sign-in-button.css +68 -0
  87. package/google-login/exports/sign-in-button.html +4 -0
  88. package/google-login/mcp.json +4 -0
  89. package/google-login/plugin.json +59 -0
  90. package/google-login/skills/google-login/SKILL.md +96 -0
  91. package/google-login/tovu-login-providers.json +21 -0
  92. package/google-workspace/README.md +132 -0
  93. package/google-workspace/form-destinations/README.md +87 -0
  94. package/google-workspace/form-destinations/dedupe.mjs +43 -0
  95. package/google-workspace/form-destinations/delivery-result.mjs +69 -0
  96. package/google-workspace/form-destinations/mapping.mjs +152 -0
  97. package/google-workspace/form-destinations/sheets.mjs +202 -0
  98. package/google-workspace/lib/auth.mjs +81 -0
  99. package/google-workspace/lib/drive-files.mjs +54 -0
  100. package/google-workspace/lib/form-destination-settings.mjs +72 -0
  101. package/google-workspace/lib/http.mjs +168 -0
  102. package/google-workspace/lib/picker-flow.mjs +171 -0
  103. package/google-workspace/lib/picker-selection.mjs +107 -0
  104. package/google-workspace/lib/product-state.mjs +93 -0
  105. package/google-workspace/lib/request-policy.mjs +163 -0
  106. package/google-workspace/lib/sheets-a1.mjs +79 -0
  107. package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
  108. package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
  109. package/google-workspace/lib/sheets-client.mjs +286 -0
  110. package/google-workspace/lib/surface-actions.mjs +107 -0
  111. package/google-workspace/mcp.json +4 -0
  112. package/google-workspace/plugin.json +77 -0
  113. package/google-workspace/products.json +12 -0
  114. package/google-workspace/skills/google-workspace/SKILL.md +82 -0
  115. package/google-workspace/skills/google-workspace/references/connection.md +102 -0
  116. package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
  117. package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
  118. package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
  119. package/google-workspace/tools/account-info.mjs +18 -0
  120. package/google-workspace/tools/sheets/README.md +54 -0
  121. package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
  122. package/google-workspace/tools/sheets/copy.mjs +23 -0
  123. package/google-workspace/tools/sheets/create.mjs +35 -0
  124. package/google-workspace/tools/sheets/find-replace.mjs +38 -0
  125. package/google-workspace/tools/sheets/find.mjs +52 -0
  126. package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
  127. package/google-workspace/tools/sheets/get.mjs +22 -0
  128. package/google-workspace/tools/sheets/values-append.mjs +27 -0
  129. package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
  130. package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
  131. package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
  132. package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
  133. package/google-workspace/tools/sheets/values-clear.mjs +20 -0
  134. package/google-workspace/tools/sheets/values-get.mjs +21 -0
  135. package/google-workspace/tools/sheets/values-update.mjs +26 -0
  136. package/google-workspace/tools/sheets-admin/README.md +45 -0
  137. package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
  138. package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
  139. package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
  140. package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
  141. package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
  142. package/google-workspace/tools/sheets-admin/format.mjs +70 -0
  143. package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
  144. package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
  145. package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
  146. package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
  147. package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
  148. package/google-workspace/tovu-admin-surfaces.json +63 -0
  149. package/google-workspace/tovu-agent-tools.json +1178 -0
  150. package/google-workspace/tovu-connections.json +35 -0
  151. package/google-workspace/tovu-form-destinations.json +10 -0
  152. package/google-workspace/ui/README.md +73 -0
  153. package/google-workspace/ui/connection-panel.handler.mjs +167 -0
  154. package/google-workspace/ui/connection-panel.html +17 -0
  155. package/google-workspace/ui/connection-panel.mjs +189 -0
  156. package/google-workspace/ui/connection-view.mjs +120 -0
  157. package/google-workspace/ui/destination-panel.handler.mjs +106 -0
  158. package/google-workspace/ui/destination-panel.html +17 -0
  159. package/google-workspace/ui/destination-panel.mjs +150 -0
  160. package/google-workspace/ui/dom.mjs +140 -0
  161. package/google-workspace/ui/panel.css +170 -0
  162. package/google-workspace/ui/picker-flow.mjs +87 -0
  163. package/google-workspace/ui/picker-view.mjs +44 -0
  164. package/google-workspace/ui/picker.handler.mjs +56 -0
  165. package/google-workspace/ui/picker.html +17 -0
  166. package/google-workspace/ui/picker.mjs +88 -0
  167. package/google-workspace/ui/surface-client.mjs +227 -0
  168. package/higgsfield-media/mcp.json +9 -0
  169. package/higgsfield-media/plugin.json +109 -0
  170. package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
  171. package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
  172. package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
  173. package/jev/mcp.json +4 -0
  174. package/jev/plugin.json +44 -0
  175. package/jev/skills/jev/SKILL.md +308 -0
  176. package/jev/skills/jev/references/cookbooks.md +197 -0
  177. package/jev/skills/jev/references/jaggedness.md +135 -0
  178. package/jev/skills/jev/references/javascript-sdk.md +146 -0
  179. package/jev/skills/jev/references/patterns.md +123 -0
  180. package/jev/skills/jev/references/primitives-reference.md +187 -0
  181. package/langfuse/plugin.json +22 -0
  182. package/meilisearch/mcp.json +4 -0
  183. package/meilisearch/plugin.json +54 -0
  184. package/meilisearch/providers/meilisearch.mjs +651 -0
  185. package/meilisearch/skills/meilisearch/SKILL.md +43 -0
  186. package/meilisearch/tovu-search-providers.json +10 -0
  187. package/netlify/NOTES.md +13 -0
  188. package/netlify/mcp.json +4 -0
  189. package/netlify/plugin.json +47 -0
  190. package/netlify/skills/netlify/SKILL.md +39 -0
  191. package/netlify/targets/netlify.mjs +384 -0
  192. package/netlify/tovu-consent.json +6 -0
  193. package/netlify/tovu-deploy-targets.json +163 -0
  194. package/opentelemetry/mcp.json +4 -0
  195. package/opentelemetry/plugin.json +52 -0
  196. package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
  197. package/package.json +161 -6
  198. package/posthog/mcp.json +4 -0
  199. package/posthog/plugin.json +46 -0
  200. package/posthog/skills/posthog/SKILL.md +36 -0
  201. package/railway/deploy-configs/railway.mjs +135 -0
  202. package/railway/deploy-ops/railway.mjs +167 -0
  203. package/railway/mcp.json +4 -0
  204. package/railway/plugin.json +45 -0
  205. package/railway/skills/railway/SKILL.md +66 -0
  206. package/railway/tovu-deploy-configs.json +16 -0
  207. package/railway/tovu-deploy-ops.json +11 -0
  208. package/render/deploy-configs/render.mjs +141 -0
  209. package/render/deploy-ops/render.mjs +152 -0
  210. package/render/mcp.json +4 -0
  211. package/render/plugin.json +47 -0
  212. package/render/skills/render/SKILL.md +63 -0
  213. package/render/tovu-deploy-configs.json +16 -0
  214. package/render/tovu-deploy-ops.json +11 -0
  215. package/resend/NOTES.md +52 -0
  216. package/resend/adapters/resend.mjs +161 -0
  217. package/resend/lib/domains.mjs +137 -0
  218. package/resend/lib/errors.mjs +25 -0
  219. package/resend/lib/feedback.mjs +118 -0
  220. package/resend/mcp.json +13 -0
  221. package/resend/plugin.json +192 -0
  222. package/resend/skills/resend/SKILL.md +107 -0
  223. package/resend/tovu-mail-adapters.json +22 -0
  224. package/s3-compatible/NOTES.md +11 -0
  225. package/s3-compatible/mcp.json +4 -0
  226. package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
  227. package/s3-compatible/plugin.json +56 -0
  228. package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
  229. package/s3-compatible/targets/s3-compatible.mjs +1054 -0
  230. package/s3-compatible/tovu-deploy-targets.json +432 -0
  231. package/s3-compatible/tovu-object-stores.json +29 -0
  232. package/sentry/mcp.json +4 -0
  233. package/sentry/plugin.json +49 -0
  234. package/sentry/skills/sentry/SKILL.md +34 -0
  235. package/shopify/mcp.json +4 -0
  236. package/shopify/plugin.json +22 -0
  237. package/shopify/skills/shopify/SKILL.md +103 -0
  238. package/shopify/skills/shopify/references/catalog-sync.md +74 -0
  239. package/shopify/skills/shopify/references/domains.md +26 -0
  240. package/shopify/skills/shopify/references/live-storefront.md +34 -0
  241. package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
  242. package/site-compliance/mcp.json +4 -0
  243. package/site-compliance/plugin.json +34 -0
  244. package/site-compliance/skills/site-compliance/SKILL.md +220 -0
  245. package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
  246. package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
  247. package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
  248. package/site-import/mcp.json +4 -0
  249. package/site-import/plugin.json +41 -0
  250. package/site-import/skills/site-import/SKILL.md +232 -0
  251. package/site-import/skills/site-import/references/content-mapping.md +166 -0
  252. package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
  253. package/site-import/skills/site-import/references/theme-extraction.md +34 -0
  254. package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  255. package/slide-design/mcp.json +4 -0
  256. package/slide-design/plugin.json +26 -0
  257. package/slide-design/skills/slide-design/SKILL.md +103 -0
  258. package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
  259. package/slide-design/skills/slide-design/references/html-deck.md +175 -0
  260. package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
  261. package/slide-design/skills/slide-design/references/review.md +93 -0
  262. package/slide-design/skills/slide-design/references/storyline.md +118 -0
  263. package/stripe/plugin.json +22 -0
  264. package/supabase/mcp.json +26 -0
  265. package/supabase/plugin.json +78 -0
  266. package/supabase/skills/supabase/SKILL.md +169 -0
  267. package/supabase/skills/supabase/references/failure-modes.md +101 -0
  268. package/ui-ux-design/CHANGELOG.md +28 -0
  269. package/ui-ux-design/LICENSE +201 -0
  270. package/ui-ux-design/NOTICE +14 -0
  271. package/ui-ux-design/README.md +62 -0
  272. package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  273. package/ui-ux-design/mcp.json +4 -0
  274. package/ui-ux-design/plugin.json +26 -0
  275. package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
  276. package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
  277. package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
  278. package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
  279. package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
  280. package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
  281. package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
  282. package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
  283. package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
  284. package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
  285. package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
  286. package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
  287. package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
  288. package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
  289. package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
  290. package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
  291. package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
  292. package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
  293. package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
  294. package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
  295. package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
  296. package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
  297. package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
  298. package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
  299. package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
  300. package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
  301. package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
  302. package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
  303. package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
  304. package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
  305. package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
  306. package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
  307. package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
  308. package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
  309. package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
  310. package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
  311. package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
  312. package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
  313. package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
  314. package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
  315. package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
  316. package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
  317. package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
  318. package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
  319. package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
  320. package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
  321. package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
  322. package/vercel/NOTES.md +23 -0
  323. package/vercel/mcp.json +4 -0
  324. package/vercel/object-stores/vercel-blob.mjs +147 -0
  325. package/vercel/plugin.json +47 -0
  326. package/vercel/skills/vercel/SKILL.md +48 -0
  327. package/vercel/targets/vercel.mjs +586 -0
  328. package/vercel/tovu-consent.json +6 -0
  329. package/vercel/tovu-deploy-targets.json +219 -0
  330. package/vercel/tovu-object-stores.json +10 -0
  331. package/wordpress/NOTES.md +33 -0
  332. package/wordpress/plugin.json +30 -0
  333. package/wordpress/skills/wordpress/SKILL.md +43 -0
  334. package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
  335. package/wordpress/skills/wordpress/references/subdomain.md +66 -0
@@ -0,0 +1,135 @@
1
+ # Writing and updating files in a repository
2
+
3
+ `custom_credential_write_files` is the only way to put a file in a GitHub repository from here.
4
+ Never shell out to `git`, and never hand-roll the Contents API through
5
+ `custom_credential_make_request` — this tool exists so you do not have to, and it carries a human
6
+ confirmation the raw API call would skip.
7
+
8
+ ---
9
+
10
+ ## The call
11
+
12
+ ```
13
+ custom_credential_write_files({
14
+ label: "github",
15
+ owner: "<owner>",
16
+ repo: "<repo>",
17
+ branch: "<branch>",
18
+ commitMessage: "<message>",
19
+ files: [
20
+ { path: "path/relative/to/repo/root", content: "<the file's exact full text>" }
21
+ ]
22
+ })
23
+ ```
24
+
25
+ **One call does everything.** It resolves the branch's real current state, raises an interactive
26
+ confirmation, and **waits** — it does not return until the human answers. There is no second call
27
+ to make, and calling again while one is pending raises a second, separate dialog rather than
28
+ answering the first.
29
+
30
+ On confirm, every file lands in **one atomic commit**, never one commit per file, built directly on
31
+ the branch's current tree. Nothing else on the branch is touched, moved, or deleted.
32
+
33
+ ### What comes back
34
+
35
+ | Result | Meaning |
36
+ |---|---|
37
+ | `{ executed: true, commitSha, commitUrl, filesWritten }` | Written. Report the SHA and URL — do not describe what you believe happened. |
38
+ | `{ executed: false, cancelled: true }` | The human declined. Nothing was written. This is a real outcome; say so and stop. |
39
+ | `{ executed: false, cancelled: false, reason: "expired" \| "abandoned" }` | Nobody answered in time, or the run ended first. Nothing was written. |
40
+ | `{ executed: false, cancelled: false, reason: "error", message }` | GitHub rejected the write, or the branch moved since this call started. Nothing was written — **never a half-commit.** Retry fresh; never assume partial success. |
41
+
42
+ A branch that moved mid-write is refused rather than force-pushed. That is deliberate: the loser of
43
+ a race is told, never silently overwritten. Re-read the file, rebuild your content on what is now
44
+ there, and call again.
45
+
46
+ ---
47
+
48
+ ## The rules this tool enforces for you
49
+
50
+ **The branch must already exist.** This tool never creates one, and it does **not** default to the
51
+ repository's default branch. Name it explicitly. If you are unsure what exists, look it up
52
+ (`GET /repos/{owner}/{repo}/branches`) or ask — a wrong branch name is refused before anything is
53
+ written, but a *valid-but-wrong* branch name is not.
54
+
55
+ **Paths are repository-relative.** Never absolute, never containing a `..` segment, never naming or
56
+ nesting under `.git`. Refused before any confirmation or network call.
57
+
58
+ **Limits:** at most 25 files per call, 1 MiB per file, 4 MiB total, no two files naming the same
59
+ path. Exceeding any of these is a refusal with a reason that names which one.
60
+
61
+ Every refusal above happens **before** the dialog is raised and before any network call. So a
62
+ refusal costs the human nothing — but it also means a refusal is a real signal to read, not a
63
+ transient error to retry.
64
+
65
+ ---
66
+
67
+ ## Updating a file that already exists
68
+
69
+ This is where the damage happens. Read `Rule 4` in `SKILL.md` first; this is the procedure it
70
+ requires.
71
+
72
+ **`custom_credential_write_files` replaces a file's entire content. It does not patch.** Whatever
73
+ you do not carry forward is gone.
74
+
75
+ 1. **Read what is actually there.**
76
+ ```
77
+ custom_credential_make_request({
78
+ label: "github",
79
+ method: "GET",
80
+ url: "https://api.github.com/repos/<owner>/<repo>/contents/<path>?ref=<branch>"
81
+ })
82
+ ```
83
+ The response's `content` is base64. For a large file, `Accept: application/vnd.github.raw` gets
84
+ you the text directly.
85
+
86
+ 2. **Build the new content from that**, never from memory and never from a template you assume
87
+ matches what is on the branch.
88
+
89
+ 3. **State every line you removed.** Not "updated the workflow" — the actual removals. A dropped
90
+ build flag reads as nothing at all in a summary that only lists what was added.
91
+
92
+ The confirmation dialog labels each path a **create** (new path) or an **update** (already exists
93
+ on the branch), resolved against the branch's real state, never guessed. **An "update" label is
94
+ your warning** that a full-content replacement is about to happen. If you expected a create and see
95
+ an update, stop: something is already at that path and you are about to overwrite it.
96
+
97
+ ---
98
+
99
+ ## Workflow files get an extra warning, and they have earned it
100
+
101
+ If any path is inside `.github/workflows/`, the confirmation dialog carries a **second, more
102
+ prominent warning** naming every such path, because a workflow file controls what code runs
103
+ automatically on every future push to that repository.
104
+
105
+ Do not treat that warning as noise to talk the human past. When you write a workflow:
106
+
107
+ - **Say what the workflow will do**, in plain terms, before they confirm — what it triggers on,
108
+ what it runs, what credentials it will use.
109
+ - **Name every secret it references**, so they can confirm each one is actually set. See
110
+ `auth-and-tokens.md`: setting them is their step, always.
111
+ - **Do not silently regenerate a workflow from a template** when one already exists. That is the
112
+ exact shape of the force-push that dropped two build arguments and broke two deploys.
113
+
114
+ ---
115
+
116
+ ## Templates and placeholders
117
+
118
+ A plugin that ships a template file expects it to be read off disk and written into the human's
119
+ repository, with every placeholder replaced.
120
+
121
+ After you write, **re-read what you wrote and confirm no placeholder marker survived.** A leftover
122
+ placeholder is, at best, a confusing failure — and at worst a run that succeeds against the wrong
123
+ target. Then **say which values you left at a default**: an unverified default the human never
124
+ chose is a fact they need, not an implementation detail.
125
+
126
+ ---
127
+
128
+ ## What is happening underneath
129
+
130
+ Worth knowing because it explains the guarantees above: one blob per distinct file content, one
131
+ tree built on top of the branch's current tree (`base_tree`, always — so every path you did not
132
+ name is inherited unchanged), one commit object, and one **non-force** ref update.
133
+
134
+ That last step is the only irreversible one, and GitHub itself rejects it with 422 if the branch
135
+ moved. Which is why "diverged" is a refusal here rather than a lost commit.
@@ -0,0 +1,159 @@
1
+ # Backing up the site itself to a private repository
2
+
3
+ `site_backup_plan` and `site_backup_push` copy **this site** into one folder of a private GitHub
4
+ repository, in one commit: its database, media, themes, installed plugins and skills, and settings.
5
+ Both are native host tools. They push with the same saved credential `custom_credential_*` uses:
6
+ the plugin’s card: `credential_save {kind:"plugin-provider",target:"github"}`.
7
+
8
+ ---
9
+
10
+ ## Which tool
11
+
12
+ Three tools can put the site's things in a repository. They do three different jobs.
13
+
14
+ | The human wants… | Use |
15
+ |---|---|
16
+ | A copy of the site itself (content, members, media, settings) to keep or restore from | `site_backup_plan`, then `site_backup_push` |
17
+ | The site's **rendered** HTML published into a repository (Pages, a static host) | `source_control_execute_commit` — see `source-control.md` |
18
+ | A few named files: a config, a workflow, a script | `custom_credential_write_files` — see `repo-files.md` |
19
+
20
+ The rendered export holds no database. It is what the site *looks like*, not the site, and nothing
21
+ can be restored from it. Going the other way, `custom_credential_write_files` cannot carry a backup:
22
+ 25 files and 1 MiB each, and every byte would pass through your context. The database holds members
23
+ and admin accounts, so that is not a size problem. It is a reason never to try.
24
+
25
+ ---
26
+
27
+ ## Plan, then push
28
+
29
+ ```
30
+ site_backup_plan({
31
+ owner: "<owner>",
32
+ repo: "<repo>",
33
+ // all optional:
34
+ credential: "github", // needed only when more than one saved credential points at GitHub
35
+ branch: "<branch>", // default: the repository's default branch; must already exist
36
+ folder: "<folder>", // default: the site's folder name; replaced as a whole on each backup
37
+ include: { media: false }, // database, media, themes, plugins, settings: each defaults to true
38
+ commitMessage: "<message>" // default: the host's backup message with the site name and time
39
+ })
40
+ ```
41
+
42
+ **The plan writes nothing.** It checks the credential, that the repository is private, that the
43
+ credential can push, and that the branch exists. Then it snapshots the database, lists every file
44
+ with its size, and checks the limits. It returns a `planId`, which is **single-use and valid for
45
+ 10 minutes**, plus everything the human needs to see.
46
+
47
+ **Show the human the plan before you push:** repository, branch, folder, and whether that folder
48
+ already exists (it will be replaced). Also what is included, the file count and total size, and
49
+ anything in `skipped` or `notes`. Then:
50
+
51
+ ```
52
+ site_backup_push({ planId: "<planId>" })
53
+ ```
54
+
55
+ **No dialog: the push starts at once.** The plan you showed is the human's review, so show it
56
+ first. The push re-checks the repository (still private, branch not moved, files unchanged) before
57
+ the first upload.
58
+
59
+ A large site can take longer than one call. The push waits about 4 minutes; if the upload is still
60
+ running then, it returns `{ pushed: false, cancelled: false, inProgress: true, planId, progress,
61
+ message }`, where `progress` is `{ filesDone, filesTotal, bytesDone, bytesTotal }`. Tell the human
62
+ how far it got, then **call `site_backup_push` again with the same `planId`** to keep waiting. That
63
+ does not start a second upload, and nothing is committed until every file is up: the backup is
64
+ still one commit. Keep re-calling while it answers `inProgress`.
65
+
66
+ The `planId` is used up when its push finishes, whatever the outcome. After a failure, a second
67
+ attempt starts with a fresh `site_backup_plan`. A server restart drops a running push; nothing is
68
+ committed, so plan again.
69
+
70
+ GitHub hiccups (a timeout, a 5xx, a rate limit) are retried inside the push with backoff, up to 5
71
+ tries per request. A failure that reaches you is final, and its message names the file it stopped at.
72
+
73
+ ---
74
+
75
+ ## Private repositories only
76
+
77
+ A public or internal repository is refused (`REPOSITORY_NOT_PRIVATE`) at plan time, and checked
78
+ again at push time. The database holds members, form submissions, admin accounts and saved
79
+ credentials. That is not a warning to talk the human past. Never suggest a public repository.
80
+
81
+ The repository must also **already have one commit**. An empty repository is refused
82
+ (`REPOSITORY_EMPTY`). The fix is on GitHub: add a README, then plan again.
83
+
84
+ ---
85
+
86
+ ## What the push returns
87
+
88
+ | Result | Meaning |
89
+ |---|---|
90
+ | `{ pushed: true, commitSha, commitUrl, repository, branch, folder, filesWritten, totalBytes }` | Written. Report the SHA and URL. |
91
+ | `{ pushed: false, cancelled: false, inProgress: true, planId, progress, message }` | Still uploading. Report the progress, then call `site_backup_push` again with the same `planId`. |
92
+ | `{ pushed: false, cancelled: false, reason: "abandoned" }` | The run ended while the push was waiting. The upload stopped and nothing was committed. Plan again. |
93
+ | `{ pushed: false, cancelled: false, code, message }` | Refused. See the codes below. Nothing was written. |
94
+
95
+ ---
96
+
97
+ ## Limits
98
+
99
+ - **100 MiB per file.** That is GitHub's own limit. A media, theme or plugin file over it is **left
100
+ out** of the backup and listed in the plan's `skipped` with its size, **never truncated or
101
+ split**; tell the human which files those are. A database snapshot over it fails the plan
102
+ (`LIMIT_EXCEEDED`), because a backup without its database is not a backup.
103
+ - **At most 3000 files and 1 GiB in total.**
104
+
105
+ Every limit is checked by the plan, before anything is uploaded (`LIMIT_EXCEEDED`). Relay the
106
+ message. The fix is to remove or shrink the named files, or leave out the scope that holds them.
107
+ Media usually holds the most files and the most bytes. A backup without media is a different
108
+ backup, so say plainly that media would be left out and get the human's agreement before you plan
109
+ with `include: { media: false }`.
110
+
111
+ When media lives in object storage rather than on this server, `notes` says so: those files are
112
+ not in the backup, though the database still lists every media item. Pass that on.
113
+
114
+ **Never in a backup, by design:** chat history and chat attachments, the rendered export, MCP
115
+ server config files (they can hold tokens), plugin OAuth data, and `.git`. Symbolic links are never
116
+ followed; each is listed in `skipped`. The Site key is not in the backup either. The saved
117
+ credentials inside the database are encrypted with it, so the human needs to keep it safe on their
118
+ own.
119
+
120
+ ---
121
+
122
+ ## The codes, and what to tell the human
123
+
124
+ | Code | What it means | What to tell them |
125
+ |---|---|---|
126
+ | `CREDENTIAL_NOT_FOUND` | No saved credential has that label, or none points at `https://api.github.com` | Open `credential_save {kind:"plugin-provider",target:"github"}`, or name the right label with `credential`. Never have a token typed into the chat. |
127
+ | `CREDENTIAL_AMBIGUOUS` | More than one saved credential points at GitHub | Ask which one, then plan with `credential` |
128
+ | `CREDENTIAL_UNREADABLE` | The credential **is saved** but this server cannot decrypt it | Not a bad GitHub token, so replacing it is the wrong first move. It starts with the **Site key**, and the message names which case applies. No Site key: start the host with it (the host guidance names how). Malformed: fix it in the host's Site key setting. A different Site key from the one the credential was saved under: check that setting, or save the credential again. |
129
+ | `REPOSITORY_NOT_FOUND` | This credential cannot see that `owner/repo` | Confirm the names, then the token's repository access |
130
+ | `REPOSITORY_NOT_PRIVATE` | Public or internal | Back up to a private repository. Never offer a public one |
131
+ | `NO_PUSH_PERMISSION` | The token can read the repository but not push to it | The token needs write access to the repository's contents. That change is made on GitHub. |
132
+ | `REPOSITORY_EMPTY` | No commits yet | Add a first commit (a README) on GitHub, then plan again |
133
+ | `BRANCH_NOT_FOUND` | The named branch does not exist | Name an existing branch, or leave `branch` out for the default |
134
+ | `FOLDER_IS_FILE` | The folder's path is a file on that branch | Choose another `folder` |
135
+ | `LIMIT_EXCEEDED` | Over a limit above | See **Limits** |
136
+ | `DATABASE_SNAPSHOT_FAILED` | The host could not take a consistent copy of the database | A host-side problem, not a GitHub one. Report it |
137
+ | `PLAN_NOT_FOUND` / `PLAN_EXPIRED` | The `planId` was already used, is over 10 minutes old, or a server restart dropped it | Plan again |
138
+ | `PLAN_STALE` | A file on the site changed after the plan | Plan again, and show the new plan |
139
+ | `DIVERGED_BRANCH` | Someone pushed to the branch after the plan. Nothing was written | Plan again, and show the new plan |
140
+ | `PROVIDER_ERROR` | GitHub rejected a request | The message names what went wrong. Pass it on |
141
+ | `NETWORK_UNREACHABLE` | GitHub could not be reached, even after retries | Says nothing about the credential. Try again later, from a fresh plan |
142
+ | `UNAVAILABLE` | This runtime has no site folder on disk | A backup cannot be made from here |
143
+ | `SITE_BACKUP_FORBIDDEN` | The person lacks the backup or credential-write permission | An admin grants it. Nothing was touched |
144
+ | `SITE_BACKUP_INVALID_INPUT` | A field was malformed | The message names the field. Fix it and plan again |
145
+
146
+ ---
147
+
148
+ ## Never force
149
+
150
+ `site_backup_push` has no force option, and every write it makes is a **non-force** ref update on
151
+ top of the exact commit the plan saw. That is why `DIVERGED_BRANCH` is a refusal, not a lost commit.
152
+
153
+ So when a push is refused because the world moved (`PLAN_EXPIRED`, `PLAN_STALE`,
154
+ `DIVERGED_BRANCH`), the answer is always **plan again** and show the human the new plan. Never force
155
+ the branch through `custom_credential_make_request`, never delete and recreate the branch, and never
156
+ rebuild the backup out of `custom_credential_write_files` calls. Each of these would throw away
157
+ someone else's commit, or push content the human never reviewed.
158
+
159
+ There is no restore tool unless the host guidance names one. Do not promise one.
@@ -0,0 +1,123 @@
1
+ # Source Control: publishing the site's export, and why it is not a file write
2
+
3
+ The host has a second, narrower pair of GitHub tools. They are easy to reach for by mistake, because
4
+ their names sound general and they are not.
5
+
6
+ - `source_control_get_capabilities` — read-only. Which providers have a saved connection.
7
+ - `source_control_execute_commit` — commits the site's **rendered static export** to a repository.
8
+
9
+ Neither is a way to put a file in a repository. That is `custom_credential_write_files`.
10
+
11
+ ---
12
+
13
+ ## The distinction that actually matters
14
+
15
+ **App source is not a rendered export.**
16
+
17
+ | | `custom_credential_write_files` | `source_control_execute_commit` |
18
+ |---|---|---|
19
+ | What it commits | Exactly the files you name, verbatim | A **fresh static copy of the active theme** — generated HTML and assets — that it runs itself |
20
+ | Where the content comes from | You | The host's exporter. You do not supply file content at all |
21
+ | Deletes anything? | **Never.** It writes named paths and touches nothing else | **Yes.** Unchanged stale files recorded by this destination’s manifest are removed; human-edited stale files are preserved |
22
+ | Creates a branch? | No — the branch must already exist | Yes, if the named branch does not exist |
23
+ | Which credential | The Access Tokens page's saved custom provider (`label`) | The **Source Control** credential, falling back to a saved `github` API key only when no explicit connection exists |
24
+ | Repository layout | Wherever you name | Repository root by default, or the optional `folder` the user named |
25
+
26
+ A container build needs **app source** — the repository's real code, its Dockerfile, its lockfiles.
27
+ A rendered export is the *output* of running that code, and a build handed one either fails or,
28
+ worse, succeeds and ships a static site where a server was expected.
29
+
30
+ So when a task says "push the app to a repo so CI can build it", that is **not**
31
+ `source_control_execute_commit`. It is either app source the human already has in a repository, or
32
+ specific files you write with `custom_credential_write_files`. Conflating the two wastes a whole
33
+ build cycle before anyone notices what was actually committed.
34
+
35
+ `source_control_execute_commit` is for one job: **publishing the site's rendered output** into a
36
+ repository as a portable static copy. Hosting this layout requires configuration; use the static-publish flow for hosted deployment.
37
+
38
+ ---
39
+
40
+ ## Two credential stores, not one
41
+
42
+ This surprises people and is worth stating flatly.
43
+
44
+ - `custom_credential_*` reads the **Access Tokens** page's *Add custom provider* table. That is
45
+ where a credential labelled `github` lives.
46
+ - `source_control_*` reads a **separate** table, connected with `credential_save {kind:"plugin-provider",target:"github-source-control"}`.
47
+
48
+ An explicit Source Control connection takes priority. If none exists, `source_control_execute_commit`
49
+ can use the saved `github` API key; capabilities identify this as `credentialSource:"saved-api-key"`.
50
+ A connected Source Control provider does not give `custom_credential_make_request` a label to use.
51
+
52
+ So: call `source_control_get_capabilities` before you promise a human anything about committing an
53
+ export. And never ask a human to paste a token into chat to bridge the gap — open the plugin’s source-control card, which saves it encrypted through that owner.
54
+
55
+ ---
56
+
57
+ ## Using `source_control_execute_commit`
58
+
59
+ ```
60
+ source_control_execute_commit({
61
+ provider: "github",
62
+ owner: "<owner>",
63
+ repo: "<repo>",
64
+ commitMessage: "<message>",
65
+ branch: "<branch>", // optional — the repository's default branch when omitted
66
+ folder: "static_sites", // optional — empty or omitted means repository root
67
+ dryRun: true
68
+ })
69
+ ```
70
+
71
+ Use the folder the user named. If the destination is unclear, ask “repository root or which folder?”.
72
+ Run a dry run first. Folder commits require `supportsFolder:true` from capabilities; an older
73
+ provider is refused rather than allowed to ignore the destination.
74
+
75
+ The package contains `Open website.html`, `README.txt`, `media/` and `sites/<theme>/`.
76
+ The README is deterministic so unchanged re-commits can report `NO_CHANGES`. Open the launcher to
77
+ view the copy. A theme directory needs its sibling `media/`; there is no generated root `index.html`.
78
+ Existing GitHub Pages/root-hosting URLs can break with this layout. The host's other static
79
+ publishing and export commands retain their current output.
80
+
81
+ Ownership is recorded at `<folder>/.tovu/managed-files.json`. Changing folders creates an independent
82
+ destination, preserving the previous root export and sibling folders. A folder commit never reads
83
+ the root manifest as a fallback.
84
+
85
+ Only `provider: "github"` is accepted. A gitlab or bitbucket credential can be **saved** and is
86
+ reported honestly by `source_control_get_capabilities` with `commitSupported: false` — committing
87
+ to either is not implemented. Say that plainly rather than letting a human discover it from a
88
+ failed attempt.
89
+
90
+ Like every gated write here, **one call raises the dialog and waits.** There is no second call.
91
+
92
+ ### Reading the result
93
+
94
+ On success: `{ committed: true, folder, entryPath, branchCreated, commitSha, commitUrl, filesChanged, filesDeleted,
95
+ divergedPaths }`.
96
+
97
+ **Report the destination, `entryPath`, and `filesDeleted` to the human, always.** A nonzero value means real content was *removed*
98
+ from their repository, not merely added. Only paths this tool itself previously wrote, whose live
99
+ content still exactly matches what it wrote, are ever deleted — unrelated files and human-edited stale files are preserved. Current generated paths are replaced,
100
+ including the generated README at this destination.
101
+
102
+ `divergedPaths` lists paths that fell into that last case: no longer part of the export, but
103
+ preserved because their content no longer matches this tool's own record. Tell the human those need
104
+ their own manual review if removal is still wanted. Silence here reads as "everything is clean",
105
+ which is the opposite of what a non-empty `divergedPaths` means.
106
+
107
+ ### The failure codes, and what each one is telling you
108
+
109
+ | Code | What it means | The right next move |
110
+ |---|---|---|
111
+ | `no-credential` | No Source Control credential is connected. Returned **without raising a dialog** | Point them at the admin's Source Control page. Call `source_control_get_capabilities` first next time |
112
+ | `REPOSITORY_NOT_FOUND` | The token cannot see that `owner/repo` | Confirm the identifiers, then the token's access. Not necessarily a bad token |
113
+ | `NO_CHANGES` | Nothing changed since the branch's last commit | **Not a failure.** Say nothing needed to commit |
114
+ | `DIVERGED_BRANCH` | Someone else pushed since this call started; the commit was refused rather than overwriting history | The human resolves it, exactly as with any rejected non-fast-forward push |
115
+ | `NETWORK_UNREACHABLE` | Could not reach GitHub at all | Says **nothing** about the credential. Do not tell them to replace it. Suggest retrying |
116
+ | `PROVIDER_ERROR` | GitHub's API rejected the request — expired token, insufficient scope, permission | The message names what went wrong. Never a raw body, never the credential |
117
+ | `EXPORT_FAILED` | The site failed to export cleanly, before any commit was attempted | A host-side problem, not a GitHub one. Fix the export |
118
+
119
+ `{ committed: false, cancelled: true }` is a human declining. Say so and stop.
120
+
121
+ Wait for the result and report the true outcome. Do not tell a human a dialog is open and then
122
+ stop, and do not call again while a call is pending — a fresh call raises a second, separate dialog
123
+ rather than answering the first.
@@ -0,0 +1,242 @@
1
+ // @ts-check
2
+ import { createHash } from "node:crypto";
3
+
4
+ import { enc, encPath, extractStringField, isOk, parseJsonBody, providerErrorMessage, repoPathOf } from "./write-files.mjs";
5
+
6
+ /**
7
+ * @file Pushes a site backup into a folder of a PRIVATE GitHub repository. Moved out of core
8
+ * (`apps/website/src/features/site-backup/github-push.ts`) unchanged in behavior.
9
+ *
10
+ * Three steps, the tool (`site_backup_*` in core) sequencing them: `inspectBackupRepository` checks
11
+ * the repository BEFORE anything is uploaded (it must exist, be private — a backup holds the whole
12
+ * database — be pushable and non-empty, and the folder must not be a file); `uploadBackupBlob` sends
13
+ * one file as a git blob; `commitBackupTree` replaces the backup folder with a tree of the uploaded
14
+ * blobs on top of the planned parent, with an unforced ref update (a moved branch is `diverged`).
15
+ * No recursive tree fetch and no deletion list are needed: the folder's tree is replaced whole.
16
+ *
17
+ * Large sites (2026-10-06, a 212 MiB backup failed live): every request gets
18
+ * {@link BACKUP_CLIENT_OPTIONS} — a long socket-idle budget, because GitHub stays silent while it
19
+ * stores a big blob, and retries with backoff for timeouts, 5xx and rate limits. Small text files ride
20
+ * inline in the tree requests ({@link BACKUP_INLINE_TEXT_MAX_BYTES}) instead of one blob POST each,
21
+ * which keeps a first backup of ~1500 files to a few hundred writes, under GitHub's content-creation
22
+ * limits.
23
+ *
24
+ * @typedef {import("./write-files.mjs").Target} Target
25
+ * @typedef {import("./write-files.mjs").GitDataClient} GitDataClient
26
+ * @typedef {{ path: string, blobSha: string, bytes: number, sha256: string }} UploadedBackupBlob
27
+ * @typedef {{ path: string, text: string, bytes: number, sha256: string }} InlineBackupText
28
+ */
29
+
30
+ /**
31
+ * The backup's GitHub client. The 2-minute idle budget (the backup egress policy allows it; the
32
+ * custom-credential one caps every request at 10 s) is what a 42 MiB database blob needed: the 10 s
33
+ * cap timed out while GitHub was still answering. Waits follow GitHub's REST guidance: `retry-after`
34
+ * first, a minute for a secondary limit without one, never longer than 10 minutes in one wait.
35
+ *
36
+ * @type {{ timeoutMs: number, retry: import("./write-files.mjs").RetryPolicy }}
37
+ */
38
+ export const BACKUP_CLIENT_OPTIONS = {
39
+ timeoutMs: 120_000,
40
+ retry: { maxAttempts: 5, baseBackoffMs: 2_000, maxBackoffMs: 30_000, rateLimitWaitMs: 60_000, maxWaitMs: 10 * 60_000 },
41
+ };
42
+
43
+ /** A UTF-8 file up to this size goes inline in a tree request instead of as its own blob. */
44
+ export const BACKUP_INLINE_TEXT_MAX_BYTES = 512 * 1024;
45
+
46
+ const PROVIDER_ERROR = "provider-error";
47
+
48
+ /** One tree request's share of the folder: GitHub abandons a request it cannot finish in 10 s, so a
49
+ * large folder is built over several chained `base_tree` requests rather than one. */
50
+ const TREE_CHUNK_MAX_ENTRIES = 300;
51
+ const TREE_CHUNK_MAX_BYTES = 2 * 1024 * 1024;
52
+
53
+ /**
54
+ * Splits the folder's entries into tree requests, by entry count and inline-text bytes.
55
+ *
56
+ * @param {readonly (UploadedBackupBlob | InlineBackupText)[]} entries
57
+ * @complexity O(n).
58
+ */
59
+ function chunkTreeEntries(entries) {
60
+ /** @type {(UploadedBackupBlob | InlineBackupText)[][]} */
61
+ const chunks = [[]];
62
+ let bytes = 0;
63
+ for (const entry of entries) {
64
+ const size = entry.path.length + ("text" in entry ? entry.bytes : 64);
65
+ const current = chunks[chunks.length - 1];
66
+ if (current.length > 0 && (current.length >= TREE_CHUNK_MAX_ENTRIES || bytes + size > TREE_CHUNK_MAX_BYTES)) {
67
+ chunks.push([entry]);
68
+ bytes = size;
69
+ } else {
70
+ current.push(entry);
71
+ bytes += size;
72
+ }
73
+ }
74
+ return chunks;
75
+ }
76
+
77
+ /** @param {UploadedBackupBlob | InlineBackupText} entry */
78
+ function treeEntry(entry) {
79
+ return "text" in entry ? { path: entry.path, mode: "100644", type: "blob", content: entry.text } : { path: entry.path, mode: "100644", type: "blob", sha: entry.blobSha };
80
+ }
81
+
82
+ /** @param {GitDataClient} git */
83
+ export function createBackupPusher(git) {
84
+ /** @param {{ json: any, name: string }} required */
85
+ function checkBackupPrivacy({ json, name }, _options = {}) {
86
+ const fallbackVisibility = json.private === true ? "private" : "public";
87
+ const visibility = typeof json.visibility === "string" ? json.visibility : fallbackVisibility;
88
+ if (json.private !== true || visibility !== "private") {
89
+ return {
90
+ ok: false,
91
+ code: "repo-not-private",
92
+ message:
93
+ `repository ${name} is ${visibility}. A site backup contains the whole database — members, form submissions, admin accounts — ` +
94
+ "which would be exposed to everyone who can read that repository. Back up only to a private repository.",
95
+ };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /** @param {Target} target */
101
+ async function checkRepository(target) {
102
+ const name = `${target.owner}/${target.repo}`;
103
+ const repoJson = await git.getJson(target, repoPathOf(target), "GitHub repository lookup failed");
104
+ if (!repoJson.ok) return repoJson;
105
+ if (!repoJson.found) {
106
+ return { ok: false, code: "repo-not-found", message: `repository ${name} was not found, or this credential cannot see it` };
107
+ }
108
+ const json = repoJson.json;
109
+ const privacy = checkBackupPrivacy({ json, name });
110
+ if (privacy) return privacy;
111
+ const permissions = json.permissions;
112
+ if (typeof permissions === "object" && permissions !== null && permissions.push === false) {
113
+ return { ok: false, code: "no-push-permission", message: `this credential can read ${name} but cannot push to it` };
114
+ }
115
+ const defaultBranch = extractStringField(json, "default_branch");
116
+ const htmlUrl = extractStringField(json, "html_url");
117
+ if (!defaultBranch || !htmlUrl) return { ok: false, code: PROVIDER_ERROR, message: "GitHub repository lookup response did not include a default branch or URL" };
118
+ return { ok: true, defaultBranch, htmlUrl };
119
+ }
120
+
121
+ /** GitHub answers 409 for the ref of an EMPTY repository. @param {Target} target @param {string} branch */
122
+ async function readBranchTip(target, branch) {
123
+ const name = `${target.owner}/${target.repo}`;
124
+ const refResult = await git.send(target, { method: "GET", url: `${repoPathOf(target)}/git/ref/heads/${encPath(branch)}` });
125
+ if (refResult.kind !== "response") return git.nonResponseFailure(refResult);
126
+ const refResponse = refResult.response;
127
+ if (refResponse.status === 409) {
128
+ return { ok: false, code: "repo-empty", message: `repository ${name} is empty. Add a first commit (for example a README) on GitHub, then back up again.` };
129
+ }
130
+ if (refResponse.status === 404) return { ok: false, code: "branch-not-found", message: `branch '${branch}' does not exist in ${name}` };
131
+ if (!isOk(refResponse)) return { ok: false, code: PROVIDER_ERROR, message: providerErrorMessage(refResponse, "GitHub branch lookup failed") };
132
+ const refBody = parseJsonBody(refResponse);
133
+ const parentCommitSha = refBody.ok ? extractStringField(refBody.json?.object, "sha") : "";
134
+ if (!parentCommitSha) return { ok: false, code: PROVIDER_ERROR, message: "GitHub branch lookup response did not include a sha" };
135
+
136
+ return readBackupParentTree({ target, parentCommitSha });
137
+ }
138
+
139
+ /** @param {{ target: Target, parentCommitSha: string }} required */
140
+ async function readBackupParentTree({ target, parentCommitSha }, _options = {}) {
141
+ const commitJson = await git.getJson(target, `${repoPathOf(target)}/git/commits/${enc(parentCommitSha)}`, "GitHub parent-commit lookup failed");
142
+ if (!commitJson.ok) return commitJson;
143
+ const baseTreeSha = commitJson.found ? extractStringField(commitJson.json.tree, "sha") : "";
144
+ if (!baseTreeSha) return { ok: false, code: PROVIDER_ERROR, message: "GitHub parent-commit lookup did not return a tree sha" };
145
+ return { ok: true, parentCommitSha, baseTreeSha };
146
+ }
147
+
148
+ /** @param {Target & { branch?: string, folder: string }} input */
149
+ async function inspectBackupRepository(input) {
150
+ const repo = await checkRepository(input);
151
+ if (!repo.ok) return repo;
152
+ const branch = input.branch ?? repo.defaultBranch;
153
+
154
+ const tip = await readBranchTip(input, branch);
155
+ if (!tip.ok) return tip;
156
+
157
+ const folderJson = await git.getJson(input, `${repoPathOf(input)}/contents/${encPath(input.folder)}?ref=${enc(branch)}`, "GitHub folder lookup failed");
158
+ if (!folderJson.ok) return folderJson;
159
+ if (folderJson.found && !Array.isArray(folderJson.json)) {
160
+ return { ok: false, code: "folder-is-file", message: `'${input.folder}' exists on branch '${branch}' but is not a folder — refusing to replace it` };
161
+ }
162
+ return { ok: true, state: { branch, parentCommitSha: tip.parentCommitSha, baseTreeSha: tip.baseTreeSha, htmlUrl: repo.htmlUrl, folderExists: folderJson.found } };
163
+ }
164
+
165
+ /** @param {Target} target @param {{ path: string, content: Uint8Array }} file */
166
+ async function uploadBackupBlob(target, file) {
167
+ const result = await git.createBlob(target, file.content);
168
+ if (!result.ok) return result;
169
+ const sha256 = createHash("sha256").update(file.content).digest("hex");
170
+ return { ok: true, blob: { path: file.path, blobSha: result.sha, bytes: file.content.byteLength, sha256 } };
171
+ }
172
+
173
+ /**
174
+ * The backup folder's tree, built over chained requests: each one adds a chunk of entries on top of
175
+ * the previous one's tree (`base_tree`), the first starting empty, so the folder still REPLACES
176
+ * whatever was there.
177
+ *
178
+ * @param {Target} input @param {string} repoPath @param {readonly (UploadedBackupBlob | InlineBackupText)[]} entries
179
+ * @complexity O(n) entries over ceil(n / chunk) requests.
180
+ */
181
+ async function createFolderTree(input, repoPath, entries) {
182
+ /** @type {string | undefined} */
183
+ let folderTreeSha;
184
+ for (const chunk of chunkTreeEntries(entries)) {
185
+ const folderTree = await git.postJson(
186
+ input,
187
+ `${repoPath}/git/trees`,
188
+ { ...(folderTreeSha !== undefined ? { base_tree: folderTreeSha } : {}), tree: chunk.map(treeEntry) },
189
+ "GitHub folder tree creation failed"
190
+ );
191
+ if (!folderTree.ok) return folderTree;
192
+ folderTreeSha = extractStringField(folderTree.json, "sha");
193
+ if (!folderTreeSha) return { ok: false, code: PROVIDER_ERROR, message: "GitHub folder tree creation response did not include a sha" };
194
+ }
195
+ return { ok: true, sha: /** @type {string} */ (folderTreeSha) };
196
+ }
197
+
198
+ /**
199
+ * @param {Target & { branch: string, folder: string, commitMessage: string, parentCommitSha: string,
200
+ * baseTreeSha: string, htmlUrl: string, entries: readonly (UploadedBackupBlob | InlineBackupText)[] }} input
201
+ */
202
+ async function commitBackupTree(input) {
203
+ const repoPath = repoPathOf(input);
204
+
205
+ const folderTree = await createFolderTree(input, repoPath, input.entries);
206
+ if (!folderTree.ok) return folderTree;
207
+ const folderTreeSha = folderTree.sha;
208
+
209
+ const rootTree = await git.postJson(
210
+ input,
211
+ `${repoPath}/git/trees`,
212
+ { base_tree: input.baseTreeSha, tree: [{ path: input.folder, mode: "040000", type: "tree", sha: folderTreeSha }] },
213
+ "GitHub tree creation failed"
214
+ );
215
+ if (!rootTree.ok) return rootTree;
216
+ const rootTreeSha = extractStringField(rootTree.json, "sha");
217
+ if (!rootTreeSha) return { ok: false, code: PROVIDER_ERROR, message: "GitHub tree creation response did not include a sha" };
218
+
219
+ const commit = await git.postJson(
220
+ input,
221
+ `${repoPath}/git/commits`,
222
+ { message: input.commitMessage, tree: rootTreeSha, parents: [input.parentCommitSha] },
223
+ "GitHub commit creation failed"
224
+ );
225
+ if (!commit.ok) return commit;
226
+ const commitSha = extractStringField(commit.json, "sha");
227
+ if (!commitSha) return { ok: false, code: PROVIDER_ERROR, message: "GitHub commit creation response did not include a sha" };
228
+
229
+ const ref = await git.updateRef(input, input.branch, commitSha);
230
+ if (!ref.ok) {
231
+ if (ref.code !== "diverged") return ref;
232
+ return {
233
+ ok: false,
234
+ code: "diverged",
235
+ message: `branch '${input.branch}' moved since the backup was planned (someone pushed to it). Nothing was overwritten. Call site_backup_plan again.`,
236
+ };
237
+ }
238
+ return { ok: true, commitSha, commitUrl: `${input.htmlUrl}/commit/${commitSha}` };
239
+ }
240
+
241
+ return { inspectBackupRepository, uploadBackupBlob, commitBackupTree };
242
+ }