@jini-ai/agent-plugins 0.6.16 → 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.
- package/CHANGELOG.md +35 -1
- package/NOTICE +14 -0
- package/README.md +35 -14
- package/charts-echarts/plugin.json +39 -0
- package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
- package/charts-recharts/plugin.json +32 -0
- package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
- package/cloudflare/NOTES.md +16 -0
- package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
- package/cloudflare/lib/api.mjs +96 -0
- package/cloudflare/lib/assets.mjs +65 -0
- package/cloudflare/lib/dns.mjs +32 -0
- package/cloudflare/lib/domain-inventory.mjs +61 -0
- package/cloudflare/lib/domain-status.mjs +48 -0
- package/cloudflare/lib/domains.mjs +95 -0
- package/cloudflare/lib/forms.mjs +254 -0
- package/cloudflare/lib/pages-upload.mjs +51 -0
- package/cloudflare/lib/target.mjs +165 -0
- package/cloudflare/lib/workers-upload.mjs +48 -0
- package/cloudflare/lib/zones.mjs +25 -0
- package/cloudflare/mcp.json +4 -0
- package/cloudflare/plugin.json +96 -0
- package/cloudflare/skills/cloudflare/SKILL.md +57 -0
- package/cloudflare/skills/cloudflare/references/domains.md +31 -0
- package/cloudflare/skills/cloudflare/references/pages.md +18 -0
- package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
- package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
- package/cloudflare/skills/cloudflare/references/workers.md +42 -0
- package/cloudflare/targets/cloudflare-pages.mjs +12 -0
- package/cloudflare/targets/cloudflare-workers.mjs +17 -0
- package/cloudflare/tovu-consent.json +6 -0
- package/cloudflare/tovu-deploy-targets.json +630 -0
- package/cloudflare/tovu-form-destinations.json +12 -0
- package/cloudflare/worker/forms-receiver.mjs +249 -0
- package/composio/mcp.json +10 -0
- package/composio/plugin.json +68 -0
- package/composio/skills/composio/SKILL.md +85 -0
- package/dist/core/identity-namespace.d.ts +12 -0
- package/dist/core/identity-namespace.js +20 -0
- package/dist/folder-zip-node.js +29 -7
- package/dist/lifecycle/bundled-digests.js +4 -3
- package/dist/lifecycle/install.d.ts +7 -0
- package/dist/lifecycle/install.js +45 -3
- package/dist/lifecycle/manifest.d.ts +2 -2
- package/dist/lifecycle/manifest.js +5 -3
- package/dist/lifecycle/node.js +2 -0
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +8 -0
- package/dist/lifecycle/seed-bundled.js +4 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -2
- package/fly/deploy-configs/fly.mjs +115 -0
- package/fly/deploy-ops/fly.mjs +102 -0
- package/fly/mcp.json +4 -0
- package/fly/plugin.json +81 -0
- package/fly/scripts/render-deploy-workflow.mjs +150 -0
- package/fly/skills/fly/SKILL.md +43 -0
- package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
- package/fly/skills/fly/references/fly.template.toml +81 -0
- package/fly/tovu-credential-schemes.json +11 -0
- package/fly/tovu-deploy-configs.json +21 -0
- package/fly/tovu-deploy-ops.json +14 -0
- package/github/deploy-ops/github-actions.mjs +164 -0
- package/github/deployments/github.mjs +415 -0
- package/github/mcp.json +4 -0
- package/github/plugin.json +105 -0
- package/github/skills/github/SKILL.md +249 -0
- package/github/skills/github/references/actions.md +224 -0
- package/github/skills/github/references/auth-and-tokens.md +140 -0
- package/github/skills/github/references/repo-files.md +135 -0
- package/github/skills/github/references/site-backup.md +159 -0
- package/github/skills/github/references/source-control.md +123 -0
- package/github/source-control/backup-push.mjs +242 -0
- package/github/source-control/commit-site.mjs +459 -0
- package/github/source-control/github.mjs +93 -0
- package/github/source-control/write-files.mjs +468 -0
- package/github/tovu-deploy-ops.json +13 -0
- package/github/tovu-source-control.json +117 -0
- package/github-pages/NOTES.md +19 -0
- package/github-pages/mcp.json +4 -0
- package/github-pages/plugin.json +48 -0
- package/github-pages/skills/github-pages/SKILL.md +44 -0
- package/github-pages/targets/github-pages.mjs +543 -0
- package/github-pages/tovu-deploy-targets.json +327 -0
- package/google-login/exports/google-g.svg +1 -0
- package/google-login/exports/sign-in-button.css +68 -0
- package/google-login/exports/sign-in-button.html +4 -0
- package/google-login/mcp.json +4 -0
- package/google-login/plugin.json +59 -0
- package/google-login/skills/google-login/SKILL.md +96 -0
- package/google-login/tovu-login-providers.json +21 -0
- package/google-workspace/README.md +132 -0
- package/google-workspace/form-destinations/README.md +87 -0
- package/google-workspace/form-destinations/dedupe.mjs +43 -0
- package/google-workspace/form-destinations/delivery-result.mjs +69 -0
- package/google-workspace/form-destinations/mapping.mjs +152 -0
- package/google-workspace/form-destinations/sheets.mjs +202 -0
- package/google-workspace/lib/auth.mjs +81 -0
- package/google-workspace/lib/drive-files.mjs +54 -0
- package/google-workspace/lib/form-destination-settings.mjs +72 -0
- package/google-workspace/lib/http.mjs +168 -0
- package/google-workspace/lib/picker-flow.mjs +171 -0
- package/google-workspace/lib/picker-selection.mjs +107 -0
- package/google-workspace/lib/product-state.mjs +93 -0
- package/google-workspace/lib/request-policy.mjs +163 -0
- package/google-workspace/lib/sheets-a1.mjs +79 -0
- package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
- package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
- package/google-workspace/lib/sheets-client.mjs +286 -0
- package/google-workspace/lib/surface-actions.mjs +107 -0
- package/google-workspace/mcp.json +4 -0
- package/google-workspace/plugin.json +77 -0
- package/google-workspace/products.json +12 -0
- package/google-workspace/skills/google-workspace/SKILL.md +82 -0
- package/google-workspace/skills/google-workspace/references/connection.md +102 -0
- package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
- package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
- package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
- package/google-workspace/tools/account-info.mjs +18 -0
- package/google-workspace/tools/sheets/README.md +54 -0
- package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
- package/google-workspace/tools/sheets/copy.mjs +23 -0
- package/google-workspace/tools/sheets/create.mjs +35 -0
- package/google-workspace/tools/sheets/find-replace.mjs +38 -0
- package/google-workspace/tools/sheets/find.mjs +52 -0
- package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
- package/google-workspace/tools/sheets/get.mjs +22 -0
- package/google-workspace/tools/sheets/values-append.mjs +27 -0
- package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
- package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
- package/google-workspace/tools/sheets/values-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-get.mjs +21 -0
- package/google-workspace/tools/sheets/values-update.mjs +26 -0
- package/google-workspace/tools/sheets-admin/README.md +45 -0
- package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
- package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
- package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
- package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
- package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
- package/google-workspace/tools/sheets-admin/format.mjs +70 -0
- package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
- package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
- package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
- package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
- package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
- package/google-workspace/tovu-admin-surfaces.json +63 -0
- package/google-workspace/tovu-agent-tools.json +1178 -0
- package/google-workspace/tovu-connections.json +35 -0
- package/google-workspace/tovu-form-destinations.json +10 -0
- package/google-workspace/ui/README.md +73 -0
- package/google-workspace/ui/connection-panel.handler.mjs +167 -0
- package/google-workspace/ui/connection-panel.html +17 -0
- package/google-workspace/ui/connection-panel.mjs +189 -0
- package/google-workspace/ui/connection-view.mjs +120 -0
- package/google-workspace/ui/destination-panel.handler.mjs +106 -0
- package/google-workspace/ui/destination-panel.html +17 -0
- package/google-workspace/ui/destination-panel.mjs +150 -0
- package/google-workspace/ui/dom.mjs +140 -0
- package/google-workspace/ui/panel.css +170 -0
- package/google-workspace/ui/picker-flow.mjs +87 -0
- package/google-workspace/ui/picker-view.mjs +44 -0
- package/google-workspace/ui/picker.handler.mjs +56 -0
- package/google-workspace/ui/picker.html +17 -0
- package/google-workspace/ui/picker.mjs +88 -0
- package/google-workspace/ui/surface-client.mjs +227 -0
- package/higgsfield-media/mcp.json +9 -0
- package/higgsfield-media/plugin.json +109 -0
- package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
- package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
- package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
- package/jev/mcp.json +4 -0
- package/jev/plugin.json +44 -0
- package/jev/skills/jev/SKILL.md +308 -0
- package/jev/skills/jev/references/cookbooks.md +197 -0
- package/jev/skills/jev/references/jaggedness.md +135 -0
- package/jev/skills/jev/references/javascript-sdk.md +146 -0
- package/jev/skills/jev/references/patterns.md +123 -0
- package/jev/skills/jev/references/primitives-reference.md +187 -0
- package/langfuse/plugin.json +22 -0
- package/meilisearch/mcp.json +4 -0
- package/meilisearch/plugin.json +54 -0
- package/meilisearch/providers/meilisearch.mjs +651 -0
- package/meilisearch/skills/meilisearch/SKILL.md +43 -0
- package/meilisearch/tovu-search-providers.json +10 -0
- package/netlify/NOTES.md +13 -0
- package/netlify/mcp.json +4 -0
- package/netlify/plugin.json +47 -0
- package/netlify/skills/netlify/SKILL.md +39 -0
- package/netlify/targets/netlify.mjs +384 -0
- package/netlify/tovu-consent.json +6 -0
- package/netlify/tovu-deploy-targets.json +163 -0
- package/opentelemetry/mcp.json +4 -0
- package/opentelemetry/plugin.json +52 -0
- package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
- package/package.json +161 -6
- package/posthog/mcp.json +4 -0
- package/posthog/plugin.json +46 -0
- package/posthog/skills/posthog/SKILL.md +36 -0
- package/railway/deploy-configs/railway.mjs +135 -0
- package/railway/deploy-ops/railway.mjs +167 -0
- package/railway/mcp.json +4 -0
- package/railway/plugin.json +45 -0
- package/railway/skills/railway/SKILL.md +66 -0
- package/railway/tovu-deploy-configs.json +16 -0
- package/railway/tovu-deploy-ops.json +11 -0
- package/render/deploy-configs/render.mjs +141 -0
- package/render/deploy-ops/render.mjs +152 -0
- package/render/mcp.json +4 -0
- package/render/plugin.json +47 -0
- package/render/skills/render/SKILL.md +63 -0
- package/render/tovu-deploy-configs.json +16 -0
- package/render/tovu-deploy-ops.json +11 -0
- package/resend/NOTES.md +52 -0
- package/resend/adapters/resend.mjs +161 -0
- package/resend/lib/domains.mjs +137 -0
- package/resend/lib/errors.mjs +25 -0
- package/resend/lib/feedback.mjs +118 -0
- package/resend/mcp.json +13 -0
- package/resend/plugin.json +192 -0
- package/resend/skills/resend/SKILL.md +107 -0
- package/resend/tovu-mail-adapters.json +22 -0
- package/s3-compatible/NOTES.md +11 -0
- package/s3-compatible/mcp.json +4 -0
- package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
- package/s3-compatible/plugin.json +56 -0
- package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
- package/s3-compatible/targets/s3-compatible.mjs +1054 -0
- package/s3-compatible/tovu-deploy-targets.json +432 -0
- package/s3-compatible/tovu-object-stores.json +29 -0
- package/sentry/mcp.json +4 -0
- package/sentry/plugin.json +49 -0
- package/sentry/skills/sentry/SKILL.md +34 -0
- package/shopify/mcp.json +4 -0
- package/shopify/plugin.json +22 -0
- package/shopify/skills/shopify/SKILL.md +103 -0
- package/shopify/skills/shopify/references/catalog-sync.md +74 -0
- package/shopify/skills/shopify/references/domains.md +26 -0
- package/shopify/skills/shopify/references/live-storefront.md +34 -0
- package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
- package/site-compliance/mcp.json +4 -0
- package/site-compliance/plugin.json +34 -0
- package/site-compliance/skills/site-compliance/SKILL.md +220 -0
- package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
- package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
- package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
- package/site-import/mcp.json +4 -0
- package/site-import/plugin.json +41 -0
- package/site-import/skills/site-import/SKILL.md +232 -0
- package/site-import/skills/site-import/references/content-mapping.md +166 -0
- package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
- package/site-import/skills/site-import/references/theme-extraction.md +34 -0
- package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/slide-design/mcp.json +4 -0
- package/slide-design/plugin.json +26 -0
- package/slide-design/skills/slide-design/SKILL.md +103 -0
- package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
- package/slide-design/skills/slide-design/references/html-deck.md +175 -0
- package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
- package/slide-design/skills/slide-design/references/review.md +93 -0
- package/slide-design/skills/slide-design/references/storyline.md +118 -0
- package/stripe/plugin.json +22 -0
- package/supabase/mcp.json +26 -0
- package/supabase/plugin.json +78 -0
- package/supabase/skills/supabase/SKILL.md +169 -0
- package/supabase/skills/supabase/references/failure-modes.md +101 -0
- package/ui-ux-design/CHANGELOG.md +28 -0
- package/ui-ux-design/LICENSE +201 -0
- package/ui-ux-design/NOTICE +14 -0
- package/ui-ux-design/README.md +62 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +26 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
- package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
- package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
- package/vercel/NOTES.md +23 -0
- package/vercel/mcp.json +4 -0
- package/vercel/object-stores/vercel-blob.mjs +147 -0
- package/vercel/plugin.json +47 -0
- package/vercel/skills/vercel/SKILL.md +48 -0
- package/vercel/targets/vercel.mjs +586 -0
- package/vercel/tovu-consent.json +6 -0
- package/vercel/tovu-deploy-targets.json +219 -0
- package/vercel/tovu-object-stores.json +10 -0
- package/wordpress/NOTES.md +33 -0
- package/wordpress/plugin.json +30 -0
- package/wordpress/skills/wordpress/SKILL.md +43 -0
- package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
- 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
|
+
}
|