codeply-cli 0.5.1 → 0.5.2
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/LICENSE +21 -0
- package/THIRD_PARTY_NOTICES.md +65 -63
- package/bin/codeply.js +1 -1
- package/lib/agent.mjs +133 -24
- package/lib/ai.js +268 -9
- package/lib/ai.mjs +6 -6
- package/lib/apply-patch.js +217 -217
- package/lib/arity-table.json +138 -138
- package/lib/arity.js +70 -70
- package/lib/auth.mjs +6 -6
- package/lib/bots.js +82 -17
- package/lib/chatgpt.js +282 -0
- package/lib/cloud-browser.mjs +143 -143
- package/lib/cloud.mjs +1186 -1144
- package/lib/commands.js +103 -103
- package/lib/config.js +83 -3
- package/lib/design-library/assemble-artifact.mjs +21 -21
- package/lib/design-library/cleanup.mjs +45 -45
- package/lib/design-library/index.json +22379 -22379
- package/lib/design-library/make-table-data.mjs +39 -39
- package/lib/diagnostics.js +192 -192
- package/lib/edit-engine.js +364 -364
- package/lib/github-agent.mjs +311 -311
- package/lib/history.js +75 -75
- package/lib/mail-watch.js +504 -0
- package/lib/mcp.js +258 -258
- package/lib/model-sync.js +114 -0
- package/lib/native-tools.mjs +16 -1
- package/lib/oauth-connectors.js +146 -24
- package/lib/permissions.js +109 -109
- package/lib/plugins.js +353 -353
- package/lib/publish.js +916 -0
- package/lib/reach.js +67 -0
- package/lib/server.mjs +457 -457
- package/lib/session-db.js +220 -220
- package/lib/share.js +168 -168
- package/lib/skills.js +2 -0
- package/lib/snapshot.js +146 -146
- package/lib/telemetry.js +102 -0
- package/lib/terminal-hints.js +154 -154
- package/lib/tools.mjs +316 -14
- package/lib/typecheck-worker.js +147 -147
- package/lib/web-tools.js +209 -158
- package/package.json +49 -49
- package/skills/SOURCE.md +58 -58
- package/skills/accessibility/SKILL.md +147 -147
- package/skills/agent-architecture-audit/SKILL.md +257 -257
- package/skills/agent-eval/SKILL.md +147 -147
- package/skills/agent-harness-construction/SKILL.md +74 -74
- package/skills/agent-introspection-debugging/SKILL.md +154 -154
- package/skills/agent-payment-x402/SKILL.md +225 -225
- package/skills/agent-self-evaluation/SKILL.md +182 -182
- package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
- package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
- package/skills/agent-sort/SKILL.md +216 -216
- package/skills/agentic-engineering/SKILL.md +64 -64
- package/skills/agentic-os/SKILL.md +388 -388
- package/skills/ai-first-engineering/SKILL.md +52 -52
- package/skills/ai-regression-testing/SKILL.md +386 -386
- package/skills/android-clean-architecture/SKILL.md +340 -340
- package/skills/angular-developer/SKILL.md +155 -155
- package/skills/angular-developer/references/angular-animations.md +160 -160
- package/skills/angular-developer/references/angular-aria.md +410 -410
- package/skills/angular-developer/references/cli.md +86 -86
- package/skills/angular-developer/references/component-harnesses.md +59 -59
- package/skills/angular-developer/references/component-styling.md +91 -91
- package/skills/angular-developer/references/components.md +117 -117
- package/skills/angular-developer/references/creating-services.md +97 -97
- package/skills/angular-developer/references/data-resolvers.md +69 -69
- package/skills/angular-developer/references/define-routes.md +67 -67
- package/skills/angular-developer/references/defining-providers.md +72 -72
- package/skills/angular-developer/references/di-fundamentals.md +120 -120
- package/skills/angular-developer/references/e2e-testing.md +56 -56
- package/skills/angular-developer/references/effects.md +83 -83
- package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
- package/skills/angular-developer/references/host-elements.md +80 -80
- package/skills/angular-developer/references/injection-context.md +63 -63
- package/skills/angular-developer/references/inputs.md +101 -101
- package/skills/angular-developer/references/linked-signal.md +59 -59
- package/skills/angular-developer/references/loading-strategies.md +61 -61
- package/skills/angular-developer/references/mcp.md +108 -108
- package/skills/angular-developer/references/navigate-to-routes.md +69 -69
- package/skills/angular-developer/references/outputs.md +86 -86
- package/skills/angular-developer/references/reactive-forms.md +122 -122
- package/skills/angular-developer/references/rendering-strategies.md +44 -44
- package/skills/angular-developer/references/resource.md +77 -77
- package/skills/angular-developer/references/route-animations.md +56 -56
- package/skills/angular-developer/references/route-guards.md +52 -52
- package/skills/angular-developer/references/router-lifecycle.md +45 -45
- package/skills/angular-developer/references/router-testing.md +87 -87
- package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
- package/skills/angular-developer/references/signal-forms.md +795 -795
- package/skills/angular-developer/references/signals-overview.md +94 -94
- package/skills/angular-developer/references/tailwind-css.md +69 -69
- package/skills/angular-developer/references/template-driven-forms.md +114 -114
- package/skills/angular-developer/references/testing-fundamentals.md +65 -65
- package/skills/api-connector-builder/SKILL.md +121 -121
- package/skills/api-design/SKILL.md +524 -524
- package/skills/architecture-decision-records/SKILL.md +180 -180
- package/skills/article-writing/SKILL.md +80 -80
- package/skills/automation-audit-ops/SKILL.md +143 -143
- package/skills/autonomous-agent-harness/SKILL.md +274 -274
- package/skills/autonomous-loops/SKILL.md +611 -611
- package/skills/backend-patterns/SKILL.md +562 -562
- package/skills/benchmark/SKILL.md +95 -95
- package/skills/benchmark-methodology/SKILL.md +191 -191
- package/skills/benchmark-optimization-loop/SKILL.md +71 -71
- package/skills/blender-motion-state-inspection/SKILL.md +165 -165
- package/skills/blueprint/SKILL.md +106 -106
- package/skills/brand-discovery/SKILL.md +145 -145
- package/skills/brand-discovery/references/10_purpose-why.md +40 -40
- package/skills/brand-discovery/references/20_positioning.md +44 -44
- package/skills/brand-discovery/references/30_audience-niche.md +52 -52
- package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
- package/skills/brand-discovery/references/50_voice-tone.md +59 -59
- package/skills/brand-discovery/references/60_narrative-story.md +50 -50
- package/skills/brand-discovery/references/70_founder-tension.md +49 -49
- package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
- package/skills/brand-voice/SKILL.md +98 -98
- package/skills/brand-voice/references/voice-profile-schema.md +55 -55
- package/skills/browser-qa/SKILL.md +105 -105
- package/skills/bun-runtime/SKILL.md +85 -85
- package/skills/canary-watch/SKILL.md +108 -108
- package/skills/carrier-relationship-management/SKILL.md +212 -212
- package/skills/cisco-ios-patterns/SKILL.md +164 -164
- package/skills/ck/SKILL.md +148 -148
- package/skills/claude-devfleet/SKILL.md +112 -112
- package/skills/click-path-audit/SKILL.md +245 -245
- package/skills/clickhouse-io/SKILL.md +445 -445
- package/skills/code-tour/SKILL.md +254 -254
- package/skills/codebase-onboarding/SKILL.md +234 -234
- package/skills/codehealth-mcp/SKILL.md +167 -167
- package/skills/coding-standards/SKILL.md +551 -551
- package/skills/competitive-platform-analysis/SKILL.md +214 -214
- package/skills/competitive-report-structure/SKILL.md +162 -162
- package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
- package/skills/config-gc/SKILL.md +120 -120
- package/skills/configure-ecc/SKILL.md +385 -385
- package/skills/connections-optimizer/SKILL.md +190 -190
- package/skills/content-engine/SKILL.md +132 -132
- package/skills/content-hash-cache-pattern/SKILL.md +162 -162
- package/skills/context-budget/SKILL.md +136 -136
- package/skills/continuous-agent-loop/SKILL.md +46 -46
- package/skills/continuous-learning/SKILL.md +132 -132
- package/skills/continuous-learning-v2/SKILL.md +361 -361
- package/skills/contract-first/SKILL.md +287 -287
- package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
- package/skills/cost-tracking/SKILL.md +97 -97
- package/skills/council/SKILL.md +204 -204
- package/skills/cpp-coding-standards/SKILL.md +724 -724
- package/skills/cpp-testing/SKILL.md +325 -325
- package/skills/crosspost/SKILL.md +112 -112
- package/skills/csharp-testing/SKILL.md +322 -322
- package/skills/customer-billing-ops/SKILL.md +141 -141
- package/skills/customs-trade-compliance/SKILL.md +263 -263
- package/skills/dart-flutter-patterns/SKILL.md +564 -564
- package/skills/dashboard-builder/SKILL.md +109 -109
- package/skills/data-scraper-agent/SKILL.md +765 -765
- package/skills/data-throughput-accelerator/SKILL.md +74 -74
- package/skills/database-migrations/SKILL.md +430 -430
- package/skills/deep-research/SKILL.md +160 -160
- package/skills/defi-amm-security/SKILL.md +167 -167
- package/skills/delivery-gate/SKILL.md +126 -126
- package/skills/deployment-patterns/SKILL.md +428 -428
- package/skills/design-system/SKILL.md +83 -83
- package/skills/django-celery/SKILL.md +458 -458
- package/skills/django-patterns/SKILL.md +735 -735
- package/skills/django-security/SKILL.md +644 -644
- package/skills/django-tdd/SKILL.md +730 -730
- package/skills/django-verification/SKILL.md +470 -470
- package/skills/dmux-workflows/SKILL.md +192 -192
- package/skills/docker-patterns/SKILL.md +365 -365
- package/skills/documentation-lookup/SKILL.md +91 -91
- package/skills/dotnet-patterns/SKILL.md +322 -322
- package/skills/dynamic-workflow-mode/SKILL.md +124 -124
- package/skills/e2e-testing/SKILL.md +327 -327
- package/skills/ecc-guide/SKILL.md +190 -190
- package/skills/ecc-recipes/SKILL.md +149 -149
- package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
- package/skills/email-ops/SKILL.md +122 -122
- package/skills/energy-procurement/SKILL.md +228 -228
- package/skills/enterprise-agent-ops/SKILL.md +51 -51
- package/skills/error-handling/SKILL.md +377 -377
- package/skills/eval-harness/SKILL.md +271 -271
- package/skills/evm-token-decimals/SKILL.md +131 -131
- package/skills/exa-search/SKILL.md +108 -108
- package/skills/fal-ai-media/SKILL.md +289 -289
- package/skills/fastapi-patterns/SKILL.md +514 -514
- package/skills/finance-billing-ops/SKILL.md +128 -128
- package/skills/flox-environments/SKILL.md +497 -497
- package/skills/flutter-dart-code-review/SKILL.md +436 -436
- package/skills/foundation-models-on-device/SKILL.md +243 -243
- package/skills/frontend-a11y/SKILL.md +446 -446
- package/skills/frontend-design-direction/SKILL.md +93 -93
- package/skills/frontend-patterns/SKILL.md +657 -657
- package/skills/frontend-slides/SKILL.md +185 -185
- package/skills/fsharp-testing/SKILL.md +281 -281
- package/skills/gan-style-harness/SKILL.md +279 -279
- package/skills/gateguard/SKILL.md +133 -133
- package/skills/generating-python-installer/SKILL.md +820 -820
- package/skills/git-workflow/SKILL.md +716 -716
- package/skills/github-ops/SKILL.md +145 -145
- package/skills/golang-patterns/SKILL.md +676 -676
- package/skills/golang-testing/SKILL.md +721 -721
- package/skills/google-workspace-ops/SKILL.md +96 -96
- package/skills/growth-log/SKILL.md +128 -128
- package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
- package/skills/healthcare-emr-patterns/SKILL.md +160 -160
- package/skills/healthcare-eval-harness/SKILL.md +208 -208
- package/skills/healthcare-phi-compliance/SKILL.md +146 -146
- package/skills/hermes-imports/SKILL.md +89 -89
- package/skills/hexagonal-architecture/SKILL.md +277 -277
- package/skills/hipaa-compliance/SKILL.md +79 -79
- package/skills/homelab-network-readiness/SKILL.md +170 -170
- package/skills/homelab-network-setup/SKILL.md +130 -130
- package/skills/homelab-pihole-dns/SKILL.md +275 -275
- package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
- package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
- package/skills/hookify-rules/SKILL.md +128 -128
- package/skills/inherit-legacy-style/SKILL.md +157 -157
- package/skills/intent-driven-development/SKILL.md +360 -360
- package/skills/inventory-demand-planning/SKILL.md +247 -247
- package/skills/investor-materials/SKILL.md +97 -97
- package/skills/investor-outreach/SKILL.md +92 -92
- package/skills/ios-icon-gen/SKILL.md +158 -158
- package/skills/iterative-retrieval/SKILL.md +212 -212
- package/skills/ito-basket-compare/SKILL.md +64 -64
- package/skills/ito-compute/SKILL.md +129 -129
- package/skills/ito-data-atlas-agent/SKILL.md +64 -64
- package/skills/ito-market-intelligence/SKILL.md +61 -61
- package/skills/ito-trade-planner/SKILL.md +68 -68
- package/skills/java-coding-standards/SKILL.md +384 -384
- package/skills/jira-integration/SKILL.md +303 -303
- package/skills/jpa-patterns/SKILL.md +152 -152
- package/skills/knowledge-ops/SKILL.md +155 -155
- package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
- package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
- package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
- package/skills/kotlin-patterns/SKILL.md +712 -712
- package/skills/kotlin-testing/SKILL.md +825 -825
- package/skills/kubernetes-patterns/SKILL.md +756 -756
- package/skills/laravel-patterns/SKILL.md +416 -416
- package/skills/laravel-plugin-discovery/SKILL.md +230 -230
- package/skills/laravel-security/SKILL.md +948 -948
- package/skills/laravel-tdd/SKILL.md +675 -675
- package/skills/laravel-verification/SKILL.md +180 -180
- package/skills/latency-critical-systems/SKILL.md +75 -75
- package/skills/lead-intelligence/SKILL.md +322 -322
- package/skills/liquid-glass-design/SKILL.md +279 -279
- package/skills/llm-trading-agent-security/SKILL.md +147 -147
- package/skills/logistics-exception-management/SKILL.md +222 -222
- package/skills/loop-design-check/SKILL.md +143 -143
- package/skills/mailtrap-email-integration/SKILL.md +77 -77
- package/skills/make-interfaces-feel-better/SKILL.md +152 -152
- package/skills/manim-video/SKILL.md +90 -90
- package/skills/market-research/SKILL.md +76 -76
- package/skills/marketing-campaign/SKILL.md +114 -114
- package/skills/mcp-server-patterns/SKILL.md +70 -70
- package/skills/messages-ops/SKILL.md +105 -105
- package/skills/ml-adoption-playbook/SKILL.md +57 -57
- package/skills/mle-workflow/SKILL.md +348 -348
- package/skills/motion-advanced/SKILL.md +596 -596
- package/skills/motion-foundations/SKILL.md +299 -299
- package/skills/motion-patterns/SKILL.md +434 -434
- package/skills/motion-ui/SKILL.md +576 -576
- package/skills/mysql-patterns/SKILL.md +413 -413
- package/skills/nanoclaw-repl/SKILL.md +34 -34
- package/skills/nestjs-patterns/SKILL.md +231 -231
- package/skills/netmiko-ssh-automation/SKILL.md +174 -174
- package/skills/network-bgp-diagnostics/SKILL.md +168 -168
- package/skills/network-config-validation/SKILL.md +211 -211
- package/skills/network-interface-health/SKILL.md +153 -153
- package/skills/nextjs-turbopack/SKILL.md +58 -58
- package/skills/nodejs-keccak256/SKILL.md +103 -103
- package/skills/nutrient-document-processing/SKILL.md +168 -168
- package/skills/nuxt4-patterns/SKILL.md +101 -101
- package/skills/openclaw-persona-forge/SKILL.md +289 -289
- package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
- package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
- package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
- package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
- package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
- package/skills/openclaw-persona-forge/references/output-template.md +166 -166
- package/skills/opensource-pipeline/SKILL.md +256 -256
- package/skills/orch-add-feature/SKILL.md +45 -45
- package/skills/orch-build-mvp/SKILL.md +49 -49
- package/skills/orch-change-feature/SKILL.md +43 -43
- package/skills/orch-fix-defect/SKILL.md +43 -43
- package/skills/orch-pipeline/SKILL.md +121 -121
- package/skills/orch-refine-code/SKILL.md +44 -44
- package/skills/parallel-execution-optimizer/SKILL.md +74 -74
- package/skills/perl-patterns/SKILL.md +505 -505
- package/skills/perl-security/SKILL.md +504 -504
- package/skills/perl-testing/SKILL.md +476 -476
- package/skills/plan-canvas/SKILL.md +153 -153
- package/skills/plan-orchestrate/SKILL.md +263 -263
- package/skills/plankton-code-quality/SKILL.md +237 -237
- package/skills/postgres-patterns/SKILL.md +148 -148
- package/skills/prediction-market-oracle-research/SKILL.md +64 -64
- package/skills/prediction-market-risk-review/SKILL.md +61 -61
- package/skills/premium-web-design/SKILL.md +154 -154
- package/skills/prisma-patterns/SKILL.md +401 -401
- package/skills/product-capability/SKILL.md +142 -142
- package/skills/product-lens/SKILL.md +93 -93
- package/skills/production-audit/SKILL.md +207 -207
- package/skills/production-scheduling/SKILL.md +238 -238
- package/skills/project-flow-ops/SKILL.md +112 -112
- package/skills/prompt-optimizer/SKILL.md +398 -398
- package/skills/publish-website/SKILL.md +76 -0
- package/skills/python-patterns/SKILL.md +751 -751
- package/skills/python-testing/SKILL.md +817 -817
- package/skills/pytorch-patterns/SKILL.md +397 -397
- package/skills/quality-nonconformance/SKILL.md +260 -260
- package/skills/quarkus-patterns/SKILL.md +723 -723
- package/skills/quarkus-security/SKILL.md +468 -468
- package/skills/quarkus-tdd/SKILL.md +812 -812
- package/skills/quarkus-verification/SKILL.md +480 -480
- package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
- package/skills/react-native-patterns/SKILL.md +326 -326
- package/skills/react-patterns/SKILL.md +342 -342
- package/skills/react-performance/SKILL.md +575 -575
- package/skills/react-testing/SKILL.md +424 -424
- package/skills/recsys-pipeline-architect/SKILL.md +115 -115
- package/skills/recursive-decision-ledger/SKILL.md +81 -81
- package/skills/redis-patterns/SKILL.md +404 -404
- package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
- package/skills/remotion-video-creation/SKILL.md +43 -43
- package/skills/repo-scan/SKILL.md +79 -79
- package/skills/research-ops/SKILL.md +113 -113
- package/skills/returns-reverse-logistics/SKILL.md +240 -240
- package/skills/rules-distill/SKILL.md +265 -265
- package/skills/rust-patterns/SKILL.md +500 -500
- package/skills/rust-testing/SKILL.md +501 -501
- package/skills/safety-guard/SKILL.md +76 -76
- package/skills/santa-method/SKILL.md +307 -307
- package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
- package/skills/scientific-db-uspto-database/SKILL.md +178 -178
- package/skills/scientific-pkg-gget/SKILL.md +167 -167
- package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
- package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
- package/skills/search-first/SKILL.md +183 -183
- package/skills/security-bounty-hunter/SKILL.md +100 -100
- package/skills/security-review/SKILL.md +504 -504
- package/skills/security-scan/SKILL.md +166 -166
- package/skills/seo/SKILL.md +155 -155
- package/skills/skill-comply/SKILL.md +59 -59
- package/skills/skill-scout/SKILL.md +141 -141
- package/skills/skill-stocktake/SKILL.md +195 -195
- package/skills/social-graph-ranker/SKILL.md +155 -155
- package/skills/social-publisher/SKILL.md +130 -130
- package/skills/springboot-patterns/SKILL.md +315 -315
- package/skills/springboot-security/SKILL.md +273 -273
- package/skills/springboot-tdd/SKILL.md +159 -159
- package/skills/springboot-verification/SKILL.md +232 -232
- package/skills/strategic-compact/SKILL.md +142 -142
- package/skills/swift-actor-persistence/SKILL.md +144 -144
- package/skills/swift-concurrency-6-2/SKILL.md +216 -216
- package/skills/swift-protocol-di-testing/SKILL.md +191 -191
- package/skills/swiftui-patterns/SKILL.md +259 -259
- package/skills/taste/SKILL.md +264 -264
- package/skills/taste/references/genre-taxonomy.md +87 -87
- package/skills/tdd-workflow/SKILL.md +583 -583
- package/skills/team-agent-orchestration/SKILL.md +111 -111
- package/skills/team-builder/SKILL.md +169 -169
- package/skills/terminal-ops/SKILL.md +110 -110
- package/skills/tinystruct-patterns/SKILL.md +279 -279
- package/skills/tinystruct-patterns/references/architecture.md +90 -90
- package/skills/tinystruct-patterns/references/data-handling.md +60 -60
- package/skills/tinystruct-patterns/references/database.md +99 -99
- package/skills/tinystruct-patterns/references/routing.md +64 -64
- package/skills/tinystruct-patterns/references/system-usage.md +97 -97
- package/skills/tinystruct-patterns/references/testing.md +72 -72
- package/skills/token-budget-advisor/SKILL.md +134 -134
- package/skills/ui-demo/SKILL.md +466 -466
- package/skills/ui-to-vue/SKILL.md +135 -135
- package/skills/uncloud/SKILL.md +344 -344
- package/skills/unified-memory/SKILL.md +170 -170
- package/skills/unified-notifications-ops/SKILL.md +188 -188
- package/skills/verification-loop/SKILL.md +129 -129
- package/skills/video-editing/SKILL.md +311 -311
- package/skills/videodb/SKILL.md +375 -375
- package/skills/visa-doc-translate/SKILL.md +117 -117
- package/skills/vite-patterns/SKILL.md +450 -450
- package/skills/vue-patterns/SKILL.md +471 -471
- package/skills/windows-desktop-e2e/SKILL.md +888 -888
- package/skills/workspace-surface-audit/SKILL.md +126 -126
- package/skills/x-api/SKILL.md +235 -235
package/lib/publish.js
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply - end-to-end publishing.
|
|
3
|
+
*
|
|
4
|
+
* "Publish the website" turns into: a database on Supabase when the app needs
|
|
5
|
+
* one (project picked or created, URL + anon key written into the app the way
|
|
6
|
+
* its stack reads them, schema applied with RLS), a live Vercel deployment
|
|
7
|
+
* (project, env vars, file upload, build polled until READY) and, if the user
|
|
8
|
+
* wants it, a GitHub repo linked to the Vercel project so later pushes deploy
|
|
9
|
+
* on their own. The user only connects Vercel and Supabase (and GitHub if
|
|
10
|
+
* they opt in); the agent does the rest through the tools made here.
|
|
11
|
+
*
|
|
12
|
+
* Nothing in this file runs unless the user asked to publish: the tools
|
|
13
|
+
* refuse when the request in front of them is not a publish request, every
|
|
14
|
+
* step that creates something or goes live asks first, and no token is ever
|
|
15
|
+
* written to output, logs or files. Progress is reported as a card (Database,
|
|
16
|
+
* Vercel, GitHub, Live) that the desktop app draws from each result's meta.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { execFile } = require('child_process');
|
|
22
|
+
const oauth = require('./oauth-connectors.js');
|
|
23
|
+
|
|
24
|
+
const STATE_FILE = path.join('.codeply', 'publish.json');
|
|
25
|
+
const LABEL = { supabase: 'Supabase', vercel: 'Vercel', github: 'GitHub' };
|
|
26
|
+
|
|
27
|
+
// Only a request that is about putting the app online opens this flow. Checked
|
|
28
|
+
// against the user's own message, so a model that drifts into "let me deploy
|
|
29
|
+
// it for you" after an unrelated edit is stopped by the tool itself.
|
|
30
|
+
const PUBLISH_INTENT = /\b(publish|deploy|re-?deploy|go live|put (?:it|this|the \w+) (?:live|online|on the (?:web|internet))|make (?:it|this) live|ship (?:it|this)|host (?:it|this)|launch|vercel|live link|live url|online)\b/i;
|
|
31
|
+
|
|
32
|
+
function wantsPublish(message) {
|
|
33
|
+
return message == null || PUBLISH_INTENT.test(String(message));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NOT_ASKED =
|
|
37
|
+
'The user has not asked to publish or deploy in this request, so nothing was published. Never publish unprompted. ' +
|
|
38
|
+
'Finish what they asked for; you may mention in one line that you can publish it when they want.';
|
|
39
|
+
|
|
40
|
+
// ─── Project state (.codeply/publish.json) ─────────────────────────────────
|
|
41
|
+
// Ids and names only, never a token or key: which Vercel project, which
|
|
42
|
+
// Supabase project, the GitHub repo and whether the user wanted GitHub.
|
|
43
|
+
|
|
44
|
+
function readState(root) {
|
|
45
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, STATE_FILE), 'utf8')) || {}; } catch { return {}; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveState(root, patch) {
|
|
49
|
+
const prev = readState(root);
|
|
50
|
+
const next = { ...prev };
|
|
51
|
+
for (const [k, v] of Object.entries(patch)) next[k] = v && typeof v === 'object' && !Array.isArray(v) ? { ...(prev[k] || {}), ...v } : v;
|
|
52
|
+
next.updatedAt = new Date().toISOString();
|
|
53
|
+
fs.mkdirSync(path.join(root, '.codeply'), { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(root, STATE_FILE), JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Stack detection ────────────────────────────────────────────────────────
|
|
59
|
+
// Decides three things: Vercel's framework preset, whether Vercel runs a
|
|
60
|
+
// build, and where the Supabase URL/key go so this stack actually reads them.
|
|
61
|
+
|
|
62
|
+
function readPackage(root) {
|
|
63
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function depsOf(pkg) {
|
|
67
|
+
return { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const STACKS = [
|
|
71
|
+
{ id: 'next', label: 'Next.js', dep: 'next', framework: 'nextjs', envFile: '.env.local', prefix: 'NEXT_PUBLIC_' },
|
|
72
|
+
{ id: 'sveltekit', label: 'SvelteKit', dep: '@sveltejs/kit', framework: 'sveltekit', envFile: '.env', prefix: 'PUBLIC_' },
|
|
73
|
+
{ id: 'nuxt', label: 'Nuxt', dep: 'nuxt', framework: 'nuxtjs', envFile: '.env', prefix: 'NUXT_PUBLIC_' },
|
|
74
|
+
{ id: 'astro', label: 'Astro', dep: 'astro', framework: 'astro', envFile: '.env', prefix: 'PUBLIC_' },
|
|
75
|
+
{ id: 'remix', label: 'Remix', dep: '@remix-run/react', framework: 'remix', envFile: '.env', prefix: '' },
|
|
76
|
+
{ id: 'vite', label: 'Vite', dep: 'vite', framework: 'vite', envFile: '.env', prefix: 'VITE_' },
|
|
77
|
+
{ id: 'cra', label: 'Create React App', dep: 'react-scripts', framework: 'create-react-app', envFile: '.env', prefix: 'REACT_APP_' },
|
|
78
|
+
{ id: 'angular', label: 'Angular', dep: '@angular/core', framework: 'angular', envFile: '.env', prefix: 'NG_APP_' },
|
|
79
|
+
];
|
|
80
|
+
const SERVER_DEPS = ['express', 'fastify', 'koa', 'hono', '@hapi/hapi', 'restify'];
|
|
81
|
+
|
|
82
|
+
function detectStack(root) {
|
|
83
|
+
const pkg = readPackage(root);
|
|
84
|
+
const deps = depsOf(pkg);
|
|
85
|
+
for (const s of STACKS) {
|
|
86
|
+
if (deps[s.dep]) return { ...s, builds: true, server: false };
|
|
87
|
+
}
|
|
88
|
+
if (pkg) {
|
|
89
|
+
const server = SERVER_DEPS.find((d) => deps[d]);
|
|
90
|
+
if (server) return { id: 'node', label: `Node (${server})`, framework: null, envFile: '.env', prefix: '', builds: false, server: true };
|
|
91
|
+
if (pkg.scripts?.build) return { id: 'npm', label: 'npm build', framework: null, envFile: '.env', prefix: '', builds: true, server: false, buildCommand: 'npm run build', outputDirectory: 'dist' };
|
|
92
|
+
}
|
|
93
|
+
// Plain HTML/CSS/JS: nothing builds, so the browser reads the values from a
|
|
94
|
+
// small env.js that the pages load before their own scripts.
|
|
95
|
+
return { id: 'static', label: 'Static site', framework: null, envFile: null, configJs: 'env.js', prefix: '', builds: false, server: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function envNames(stack) {
|
|
99
|
+
return { url: `${stack.prefix}SUPABASE_URL`, key: `${stack.prefix}SUPABASE_ANON_KEY` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Project files ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.codeply', '.vercel', '.next', '.svelte-kit', '.nuxt', '.output', '.astro',
|
|
105
|
+
'.cache', '.turbo', 'coverage', '__pycache__', '.venv', 'venv', '.idea', '.vscode']);
|
|
106
|
+
const BUILD_OUTPUT_DIRS = new Set(['dist', 'build', 'out']);
|
|
107
|
+
const SKIP_FILES = /^(\.env(\..*)?|\.DS_Store|Thumbs\.db|npm-debug\.log.*|yarn-error\.log.*)$/i;
|
|
108
|
+
const MAX_DEPLOY_FILES = 5000;
|
|
109
|
+
const MAX_DEPLOY_BYTES = 200 * 1024 * 1024;
|
|
110
|
+
|
|
111
|
+
/** Every file Vercel gets: no .env files, no dependencies, no local build output when Vercel builds it again. */
|
|
112
|
+
function listProjectFiles(root, { builds = false, max = MAX_DEPLOY_FILES } = {}) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const stack = [root];
|
|
115
|
+
while (stack.length && out.length < max) {
|
|
116
|
+
const dir = stack.pop();
|
|
117
|
+
let entries;
|
|
118
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
const abs = path.join(dir, e.name);
|
|
121
|
+
if (e.isDirectory()) {
|
|
122
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
123
|
+
if (e.name.startsWith('.') && e.name !== '.well-known') continue;
|
|
124
|
+
if (builds && dir === root && BUILD_OUTPUT_DIRS.has(e.name)) continue;
|
|
125
|
+
stack.push(abs);
|
|
126
|
+
} else if (e.isFile() && !SKIP_FILES.test(e.name)) {
|
|
127
|
+
out.push(abs);
|
|
128
|
+
if (out.length >= max) break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out.sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Does the app need a database? ──────────────────────────────────────────
|
|
136
|
+
// Evidence, not a verdict the agent must follow: it reads the reasons and
|
|
137
|
+
// makes the call with what it knows about the app.
|
|
138
|
+
|
|
139
|
+
const SCAN_EXT = /\.(html?|jsx?|tsx?|mjs|cjs|vue|svelte|astro|py|sql|prisma)$/i;
|
|
140
|
+
const DB_LIBS = ['@supabase/supabase-js', '@supabase/ssr', 'pg', 'mysql2', 'mongoose', 'mongodb', 'prisma', '@prisma/client', 'sqlite3',
|
|
141
|
+
'better-sqlite3', 'drizzle-orm', 'firebase', 'knex', 'sequelize'];
|
|
142
|
+
|
|
143
|
+
function detectDatabaseNeed(root) {
|
|
144
|
+
const strong = [];
|
|
145
|
+
const weak = [];
|
|
146
|
+
let usesSupabase = false;
|
|
147
|
+
const schemaFiles = [];
|
|
148
|
+
const pkg = readPackage(root);
|
|
149
|
+
const deps = depsOf(pkg);
|
|
150
|
+
for (const lib of DB_LIBS) {
|
|
151
|
+
if (!deps[lib]) continue;
|
|
152
|
+
if (lib.startsWith('@supabase/')) usesSupabase = true;
|
|
153
|
+
strong.push(`package.json depends on ${lib}`);
|
|
154
|
+
}
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
const note = (list, key, text) => { if (!seen.has(key) && list.length < 12) { seen.add(key); list.push(text); } };
|
|
157
|
+
for (const abs of listProjectFiles(root, { max: 800 })) {
|
|
158
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
159
|
+
if (/\.sql$/i.test(rel) || /schema\.prisma$/i.test(rel)) {
|
|
160
|
+
schemaFiles.push(rel);
|
|
161
|
+
note(strong, `schema:${rel}`, `schema file ${rel}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!SCAN_EXT.test(rel)) continue;
|
|
165
|
+
if (/(^|\/)(api|pages\/api|app\/api)\//.test(rel)) note(weak, 'api', `server routes (${rel})`);
|
|
166
|
+
let text;
|
|
167
|
+
try {
|
|
168
|
+
if (fs.statSync(abs).size > 300 * 1024) continue;
|
|
169
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
170
|
+
} catch { continue; }
|
|
171
|
+
if (/@supabase\/supabase-js|supabase\.createClient|createClient\(\s*['"`]https:\/\/[a-z0-9]+\.supabase\.co|SUPABASE_URL/i.test(text)) {
|
|
172
|
+
usesSupabase = true;
|
|
173
|
+
note(strong, 'supabase', `uses Supabase already (${rel})`);
|
|
174
|
+
}
|
|
175
|
+
if (/type\s*=\s*["']password["']|\b(signUp|signIn|signInWithPassword|signInWithOAuth|logIn|register)\s*\(/.test(text)) note(strong, 'auth', `sign-in or sign-up (${rel})`);
|
|
176
|
+
if (/<form[^>]*method\s*=\s*["']post/i.test(text) || /method\s*:\s*['"]POST['"]/i.test(text)) note(weak, 'post', `sends form data (${rel})`);
|
|
177
|
+
if (/\b(app|router)\.(post|put|patch|delete)\s*\(/.test(text)) note(weak, 'routes', `server code that accepts data (${rel})`);
|
|
178
|
+
if (/localStorage\.setItem/.test(text)) note(weak, 'local', `saves data only in the visitor's browser with localStorage (${rel})`);
|
|
179
|
+
}
|
|
180
|
+
const verdict = strong.length ? 'yes' : weak.length ? 'maybe' : 'no';
|
|
181
|
+
return { verdict, reasons: [...strong, ...weak], strong, weak, usesSupabase, schemaFiles };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Writing Supabase settings into the app ─────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Add or replace KEY=value lines, leaving the rest of the file alone. */
|
|
187
|
+
function upsertEnv(file, vars) {
|
|
188
|
+
let existing = '';
|
|
189
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch {}
|
|
190
|
+
const lines = existing.length ? existing.split(/\r?\n/) : [];
|
|
191
|
+
for (const { key, value } of vars) {
|
|
192
|
+
const i = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
193
|
+
if (i === -1) lines.push(`${key}=${value}`);
|
|
194
|
+
else lines[i] = `${key}=${value}`;
|
|
195
|
+
}
|
|
196
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
197
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readEnvFile(file) {
|
|
201
|
+
const out = {};
|
|
202
|
+
try {
|
|
203
|
+
for (const l of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
204
|
+
const m = l.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
205
|
+
if (m) out[m[1]] = m[2].replace(/^["'](.*)["']$/, '$1');
|
|
206
|
+
}
|
|
207
|
+
} catch {}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** .env files stay out of git, so a push to GitHub never carries them. */
|
|
212
|
+
function ensureGitignore(root, entries = ['.env', '.env.local', '.env*.local', '.vercel', 'node_modules']) {
|
|
213
|
+
const file = path.join(root, '.gitignore');
|
|
214
|
+
let text = '';
|
|
215
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch {}
|
|
216
|
+
const have = new Set(text.split(/\r?\n/).map((l) => l.trim()));
|
|
217
|
+
const missing = entries.filter((e) => !have.has(e));
|
|
218
|
+
if (!missing.length) return false;
|
|
219
|
+
fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, 'utf8');
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const ENV_JS_HEADER =
|
|
224
|
+
'// Written by Codeply when publishing. The anon (publishable) key is meant to be\n' +
|
|
225
|
+
'// public: Row Level Security on each table decides what it may read or write.\n' +
|
|
226
|
+
'// Never put the service role key here.\n';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Puts the project URL and anon key where this stack reads them. Returns the
|
|
230
|
+
* files touched and the variables, so the same values can go to Vercel.
|
|
231
|
+
*/
|
|
232
|
+
function writeSupabaseConfig(root, stack, { url, anonKey }) {
|
|
233
|
+
const names = envNames(stack);
|
|
234
|
+
const vars = [{ key: names.url, value: url }, { key: names.key, value: anonKey }];
|
|
235
|
+
const files = [];
|
|
236
|
+
if (stack.configJs) {
|
|
237
|
+
const js = `${ENV_JS_HEADER}window.SUPABASE_URL = ${JSON.stringify(url)};\nwindow.SUPABASE_ANON_KEY = ${JSON.stringify(anonKey)};\n`;
|
|
238
|
+
fs.writeFileSync(path.join(root, stack.configJs), js, 'utf8');
|
|
239
|
+
files.push(stack.configJs);
|
|
240
|
+
// Load it before the page's own scripts, in every page that talks to
|
|
241
|
+
// Supabase (and index.html, the usual entry point).
|
|
242
|
+
for (const abs of listProjectFiles(root, { max: 400 })) {
|
|
243
|
+
if (!/\.html?$/i.test(abs)) continue;
|
|
244
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
245
|
+
let html;
|
|
246
|
+
try { html = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
247
|
+
if (/<script[^>]+env\.js/i.test(html)) continue;
|
|
248
|
+
if (!/supabase/i.test(html) && path.basename(rel).toLowerCase() !== 'index.html') continue;
|
|
249
|
+
const depth = rel.split('/').length - 1;
|
|
250
|
+
const src = `${'../'.repeat(depth)}${stack.configJs}`;
|
|
251
|
+
const tag = `<script src="${src}"></script>`;
|
|
252
|
+
let next;
|
|
253
|
+
if (/<script\b/i.test(html)) next = html.replace(/<script\b/i, `${tag}\n<script`);
|
|
254
|
+
else if (/<\/head>/i.test(html)) next = html.replace(/<\/head>/i, `${tag}\n</head>`);
|
|
255
|
+
else next = `${tag}\n${html}`;
|
|
256
|
+
fs.writeFileSync(abs, next, 'utf8');
|
|
257
|
+
files.push(rel);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
upsertEnv(path.join(root, stack.envFile), vars);
|
|
261
|
+
files.push(stack.envFile);
|
|
262
|
+
ensureGitignore(root);
|
|
263
|
+
}
|
|
264
|
+
return { files, vars, names };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The public Supabase values to hand Vercel, read back from where supabase_setup wrote them. */
|
|
268
|
+
function publicEnvFor(root, state, stack) {
|
|
269
|
+
const sb = state.supabase;
|
|
270
|
+
if (!sb || !sb.envFile || stack.configJs) return [];
|
|
271
|
+
const values = readEnvFile(path.join(root, sb.envFile));
|
|
272
|
+
return (sb.vars || []).filter((k) => values[k]).map((k) => ({ key: k, value: values[k] }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── SQL schema with RLS ────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Every table the SQL creates gets Row Level Security turned on (added when
|
|
279
|
+
* the SQL forgot it). Tables with RLS but no policy are reported, because the
|
|
280
|
+
* browser then can neither read nor write them.
|
|
281
|
+
*/
|
|
282
|
+
function prepareSchemaSql(sql) {
|
|
283
|
+
const text = String(sql || '').trim();
|
|
284
|
+
const tables = [];
|
|
285
|
+
for (const m of text.matchAll(/create\s+table\s+(?:if\s+not\s+exists\s+)?((?:"?[\w]+"?\.)?"?[\w]+"?)/gi)) {
|
|
286
|
+
const full = m[1].replace(/"/g, '');
|
|
287
|
+
if (!tables.includes(full)) tables.push(full);
|
|
288
|
+
}
|
|
289
|
+
const bare = (t) => t.replace(/^public\./i, '');
|
|
290
|
+
const mentions = (t, re) => re.test(text.replace(/"/g, '')) ? true : false;
|
|
291
|
+
const addedRls = [];
|
|
292
|
+
const lines = [text.replace(/;?\s*$/, ';')];
|
|
293
|
+
for (const t of tables) {
|
|
294
|
+
const name = bare(t).replace(/[.$]/g, '\\$&');
|
|
295
|
+
if (!mentions(t, new RegExp(`alter\\s+table\\s+(?:only\\s+)?(?:public\\.)?${name}\\s+enable\\s+row\\s+level\\s+security`, 'i'))) {
|
|
296
|
+
lines.push(`alter table ${t} enable row level security;`);
|
|
297
|
+
addedRls.push(t);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const noPolicy = tables.filter((t) => !new RegExp(`create\\s+policy[\\s\\S]*?\\bon\\s+(?:public\\.)?${bare(t)}\\b`, 'i').test(text.replace(/"/g, '')));
|
|
301
|
+
return { sql: lines.join('\n'), tables, addedRls, noPolicy };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const DESTRUCTIVE_SQL = /\b(drop|truncate|delete\s+from)\b/i;
|
|
305
|
+
|
|
306
|
+
// ─── Vercel ─────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function vercelCall(v, method, apiPath, body) {
|
|
309
|
+
return oauth.apiCall(oauth.API.vercel(), v.accessToken, method, apiPath, body, { teamId: v.teamId || undefined });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Vercel's error in plain words, with what the user can do about it. */
|
|
313
|
+
function vercelError(r, what) {
|
|
314
|
+
const msg = r.body?.error?.message || (typeof r.body === 'string' ? r.body.slice(0, 200) : '') || `HTTP ${r.status}`;
|
|
315
|
+
let hint = '';
|
|
316
|
+
if (r.status === 401 || r.status === 403) hint = ' The Vercel connection was refused. Reconnect Vercel in Connect Apps.';
|
|
317
|
+
else if (r.status === 402) hint = ' Vercel says this needs a paid plan.';
|
|
318
|
+
else if (r.status === 429) hint = ' Vercel is rate limiting this account. Wait a minute and try again.';
|
|
319
|
+
const e = new Error(`${what} failed: ${msg}.${hint}`);
|
|
320
|
+
e.status = r.status;
|
|
321
|
+
return e;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function ensureVercelProject(v, name, stack) {
|
|
325
|
+
const got = await vercelCall(v, 'GET', `/v9/projects/${encodeURIComponent(name)}`);
|
|
326
|
+
if (got.ok) return { id: got.body.id, name: got.body.name, created: false };
|
|
327
|
+
if (got.status !== 404) throw vercelError(got, 'Looking up the Vercel project');
|
|
328
|
+
const made = await vercelCall(v, 'POST', '/v11/projects', { name, framework: stack.framework });
|
|
329
|
+
if (!made.ok) throw vercelError(made, 'Creating the Vercel project');
|
|
330
|
+
return { id: made.body.id, name: made.body.name, created: true };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function setVercelEnv(v, projectId, vars) {
|
|
334
|
+
if (!vars.length) return;
|
|
335
|
+
const r = await vercelCall(v, 'POST', `/v10/projects/${encodeURIComponent(projectId)}/env?upsert=true`,
|
|
336
|
+
vars.map((x) => ({ key: x.key, value: x.value, type: 'encrypted', target: ['production', 'preview', 'development'] })));
|
|
337
|
+
if (!r.ok) throw vercelError(r, 'Setting the Vercel environment variables');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Uploads each file once by its SHA-1 (Vercel skips ones it already has), a few at a time. */
|
|
341
|
+
async function uploadFiles(v, root, files) {
|
|
342
|
+
const out = [];
|
|
343
|
+
const base = oauth.API.vercel();
|
|
344
|
+
const qs = v.teamId ? `?teamId=${encodeURIComponent(v.teamId)}` : '';
|
|
345
|
+
let next = 0;
|
|
346
|
+
async function worker() {
|
|
347
|
+
while (next < files.length) {
|
|
348
|
+
const abs = files[next++];
|
|
349
|
+
const data = fs.readFileSync(abs);
|
|
350
|
+
const sha = crypto.createHash('sha1').update(data).digest('hex');
|
|
351
|
+
const res = await fetch(`${base}/v2/files${qs}`, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers: { Authorization: `Bearer ${v.accessToken}`, 'Content-Type': 'application/octet-stream', 'x-vercel-digest': sha, 'Content-Length': String(data.length) },
|
|
354
|
+
body: data,
|
|
355
|
+
signal: AbortSignal.timeout(120000),
|
|
356
|
+
});
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
let body = null;
|
|
359
|
+
try { body = await res.json(); } catch {}
|
|
360
|
+
throw vercelError({ status: res.status, body }, `Uploading ${path.relative(root, abs)}`);
|
|
361
|
+
}
|
|
362
|
+
out.push({ file: path.relative(root, abs).replace(/\\/g, '/'), sha, size: data.length });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
await Promise.all(Array.from({ length: Math.min(6, files.length) }, worker));
|
|
366
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function projectSettingsFor(stack) {
|
|
370
|
+
const s = { framework: stack.framework };
|
|
371
|
+
if (stack.buildCommand) s.buildCommand = stack.buildCommand;
|
|
372
|
+
if (stack.outputDirectory) s.outputDirectory = stack.outputDirectory;
|
|
373
|
+
return s;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function createDeployment(v, { name, projectId, files, stack }) {
|
|
377
|
+
const r = await vercelCall(v, 'POST', '/v13/deployments?skipAutoDetectionConfirmation=1', {
|
|
378
|
+
name, project: projectId, target: 'production', files, projectSettings: projectSettingsFor(stack),
|
|
379
|
+
});
|
|
380
|
+
if (!r.ok) throw vercelError(r, 'Starting the Vercel deployment');
|
|
381
|
+
return r.body;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const POLL_MS = () => Number(process.env.CODEPLY_PUBLISH_POLL_MS) || 3000;
|
|
385
|
+
const DEPLOY_TIMEOUT_MS = () => Number(process.env.CODEPLY_PUBLISH_TIMEOUT_MS) || 10 * 60 * 1000;
|
|
386
|
+
const FINAL_STATES = new Set(['READY', 'ERROR', 'CANCELED']);
|
|
387
|
+
|
|
388
|
+
async function waitForDeployment(v, id, signal) {
|
|
389
|
+
const deadline = Date.now() + DEPLOY_TIMEOUT_MS();
|
|
390
|
+
let last = null;
|
|
391
|
+
while (Date.now() < deadline) {
|
|
392
|
+
if (signal?.aborted) return { ...(last || {}), readyState: 'CANCELED' };
|
|
393
|
+
const r = await vercelCall(v, 'GET', `/v13/deployments/${encodeURIComponent(id)}`);
|
|
394
|
+
if (!r.ok) throw vercelError(r, 'Checking the deployment');
|
|
395
|
+
last = r.body;
|
|
396
|
+
const st = last.readyState || last.status;
|
|
397
|
+
if (FINAL_STATES.has(st)) return { ...last, readyState: st };
|
|
398
|
+
await new Promise((res) => setTimeout(res, POLL_MS()));
|
|
399
|
+
}
|
|
400
|
+
return { ...(last || {}), readyState: 'TIMEOUT' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The last lines of the build log, errors first in mind. */
|
|
404
|
+
async function buildLog(v, id) {
|
|
405
|
+
const r = await vercelCall(v, 'GET', `/v3/deployments/${encodeURIComponent(id)}/events?builds=1&limit=400`);
|
|
406
|
+
if (!r.ok || !Array.isArray(r.body)) return '';
|
|
407
|
+
const lines = r.body.map((e) => String(e.payload?.text ?? e.text ?? '').trimEnd()).filter(Boolean);
|
|
408
|
+
return lines.slice(-40).join('\n');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function liveUrl(v, project, deployment) {
|
|
412
|
+
const r = await vercelCall(v, 'GET', `/v9/projects/${encodeURIComponent(project.id)}/domains`);
|
|
413
|
+
const domains = r.ok ? (r.body.domains || []) : [];
|
|
414
|
+
const custom = domains.find((d) => d.verified !== false && !/\.vercel\.app$/i.test(d.name));
|
|
415
|
+
if (custom) return `https://${custom.name}`;
|
|
416
|
+
// <project>.vercel.app is public; per-deployment URLs can sit behind Vercel login.
|
|
417
|
+
const own = domains.filter((d) => /\.vercel\.app$/i.test(d.name)).sort((a, b) => a.name.length - b.name.length)[0];
|
|
418
|
+
if (own) return `https://${own.name}`;
|
|
419
|
+
if (project.name) return `https://${project.name}.vercel.app`;
|
|
420
|
+
const alias = (deployment.alias || [])[0] || deployment.url;
|
|
421
|
+
return alias ? `https://${String(alias).replace(/^https?:\/\//, '')}` : '';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function linkGitRepo(v, projectId, repo) {
|
|
425
|
+
const r = await vercelCall(v, 'POST', `/v9/projects/${encodeURIComponent(projectId)}/link`, { type: 'github', repo });
|
|
426
|
+
if (r.ok) return { linked: true };
|
|
427
|
+
const msg = r.body?.error?.message || `HTTP ${r.status}`;
|
|
428
|
+
const needsApp = /install|permission|access|not found|integration|login connection/i.test(msg) || r.status === 400 || r.status === 404 || r.status === 403;
|
|
429
|
+
return {
|
|
430
|
+
linked: false,
|
|
431
|
+
why: needsApp
|
|
432
|
+
? `Vercel could not reach ${repo} (${msg}). Install the Vercel app on your GitHub account at https://github.com/apps/vercel/installations/new, give it access to this repo, then publish again.`
|
|
433
|
+
: `Vercel would not link ${repo}: ${msg}.`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Supabase ───────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function supabaseCall(token, method, apiPath, body) {
|
|
440
|
+
return oauth.apiCall(oauth.API.supabase(), token, method, apiPath, body);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function supabaseError(r, what) {
|
|
444
|
+
const msg = r.body?.message || r.body?.error || (typeof r.body === 'string' ? r.body.slice(0, 200) : '') || `HTTP ${r.status}`;
|
|
445
|
+
const hint = r.status === 401 || r.status === 403 ? ' The Supabase connection was refused. Reconnect Supabase in Connect Apps.' : '';
|
|
446
|
+
const e = new Error(`${what} failed: ${msg}.${hint}`);
|
|
447
|
+
e.status = r.status;
|
|
448
|
+
return e;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The project URL and its public key (publishable first, else anon). Never a secret or service role key. */
|
|
452
|
+
async function getPublicKeys(token, ref) {
|
|
453
|
+
const r = await supabaseCall(token, 'GET', `/v1/projects/${encodeURIComponent(ref)}/api-keys?reveal=true`);
|
|
454
|
+
if (!r.ok || !Array.isArray(r.body)) throw supabaseError(r, 'Reading the project API keys');
|
|
455
|
+
const safe = r.body.filter((k) => !/secret|service/i.test(`${k.type || ''} ${k.name || ''}`) && k.api_key);
|
|
456
|
+
const key = safe.find((k) => /publishable/i.test(k.type || '')) || safe.find((k) => /^anon$/i.test(k.name || '')) || safe.find((k) => /anon|publishable/i.test(`${k.type} ${k.name}`));
|
|
457
|
+
if (!key) throw new Error(`Project ${ref} has no public (anon) key yet. It may still be starting; try again in a minute.`);
|
|
458
|
+
return { url: `https://${ref}.supabase.co`, anonKey: key.api_key };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Git ────────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
function git(args, cwd, extraEnv) {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
execFile('git', args, { cwd, windowsHide: true, timeout: 180000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) } }, (err, stdout, stderr) => {
|
|
466
|
+
if (err) { err.output = `${stdout || ''}${stderr || ''}`; reject(err); return; }
|
|
467
|
+
resolve(String(stdout || '').trim());
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
473
|
+
function githubRepoFromUrl(url) {
|
|
474
|
+
const m = String(url || '').trim().match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i);
|
|
475
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function sanitizeProjectName(raw) {
|
|
479
|
+
const base = String(raw || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 52);
|
|
480
|
+
return base || 'codeply-site';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function sanitizeRepoName(raw) {
|
|
484
|
+
return String(raw || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'codeply-site';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function hideSecrets(text, secrets) {
|
|
488
|
+
let out = String(text || '');
|
|
489
|
+
for (const s of secrets) if (s && s.length > 6) out = out.split(s).join('[hidden]');
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─── The progress card ──────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
const STEPS = ['database', 'vercel', 'github', 'live'];
|
|
496
|
+
|
|
497
|
+
/** Merges a step update into this turn's card and returns a copy for meta.publish. */
|
|
498
|
+
function card(ctx, patch = {}) {
|
|
499
|
+
if (!ctx.publishCard) {
|
|
500
|
+
ctx.publishCard = { id: `pub-${Date.now().toString(36)}`, steps: Object.fromEntries(STEPS.map((s) => [s, { status: 'pending' }])), url: '' };
|
|
501
|
+
// Start from what earlier publishes already set up, so a database made last
|
|
502
|
+
// time reads as done instead of "Waiting".
|
|
503
|
+
const saved = ctx.cwd ? readState(ctx.cwd) : {};
|
|
504
|
+
const st = ctx.publishCard.steps;
|
|
505
|
+
if (saved.supabase?.ref) st.database = { status: 'done', detail: saved.supabase.name || 'Supabase', url: `https://supabase.com/dashboard/project/${saved.supabase.ref}` };
|
|
506
|
+
if (saved.github?.repo && saved.github.linked) st.github = { status: 'done', detail: `${saved.github.repo}, auto-deploy on`, url: `https://github.com/${saved.github.repo}` };
|
|
507
|
+
else if (saved.github?.choice === 'no') st.github = { status: 'skipped', detail: 'Direct deploy' };
|
|
508
|
+
}
|
|
509
|
+
const c = ctx.publishCard;
|
|
510
|
+
for (const [k, v] of Object.entries(patch.steps || {})) c.steps[k] = { ...c.steps[k], ...v };
|
|
511
|
+
if (patch.url !== undefined) c.url = patch.url;
|
|
512
|
+
return JSON.parse(JSON.stringify(c));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── The tools ──────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* `deps` comes from tools.mjs: the config module (read through so tests can
|
|
519
|
+
* stand in for the stored connections), a Supabase token getter that
|
|
520
|
+
* refreshes OAuth tokens, and resolvePath.
|
|
521
|
+
*/
|
|
522
|
+
function makePublishTools({ config, getSupabaseToken, resolveDir }) {
|
|
523
|
+
const integration = (name) => config.getIntegration(name);
|
|
524
|
+
const connected = (name) => !!integration(name).accessToken;
|
|
525
|
+
const rootOf = (args, ctx) => (args.path ? resolveDir(args.path, ctx.cwd) : ctx.cwd);
|
|
526
|
+
|
|
527
|
+
function notConnected(service, ctx) {
|
|
528
|
+
const label = LABEL[service];
|
|
529
|
+
return {
|
|
530
|
+
ok: false,
|
|
531
|
+
output: `${label} is not connected. Call publish_connect with <service>${service}</service> so the user can connect it in one click, then carry on. Do not use a CLI instead.`,
|
|
532
|
+
meta: { label: `${label} not connected`, publish: card(ctx, { steps: { [service === 'supabase' ? 'database' : service]: { status: 'waiting', detail: `Connect ${label}` } } }) },
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function publish_check(args, ctx) {
|
|
537
|
+
const root = rootOf(args, ctx);
|
|
538
|
+
if (!fs.existsSync(root)) return { ok: false, output: `${args.path} does not exist.` };
|
|
539
|
+
const stack = detectStack(root);
|
|
540
|
+
const db = detectDatabaseNeed(root);
|
|
541
|
+
const state = readState(ctx.cwd);
|
|
542
|
+
const files = listProjectFiles(root, { builds: stack.builds });
|
|
543
|
+
let remote = '';
|
|
544
|
+
try { remote = await git(['remote', 'get-url', 'origin'], root); } catch {}
|
|
545
|
+
const lines = [
|
|
546
|
+
`Stack: ${stack.label}${stack.framework ? ` (Vercel preset "${stack.framework}")` : ''}${stack.builds ? ', Vercel runs the build' : ', no build step'}.`,
|
|
547
|
+
`Files to upload: ${files.length}.`,
|
|
548
|
+
`Database: ${db.verdict === 'yes' ? 'likely needed' : db.verdict === 'maybe' ? 'maybe' : 'no sign of one'}.`,
|
|
549
|
+
...db.reasons.map((r) => ` - ${r}`),
|
|
550
|
+
db.usesSupabase ? 'The code already uses Supabase.' : '',
|
|
551
|
+
db.schemaFiles.length ? `Schema files: ${db.schemaFiles.join(', ')}` : '',
|
|
552
|
+
`Supabase settings go in: ${stack.configJs ? `${stack.configJs} (loaded by the pages, as window.SUPABASE_URL and window.SUPABASE_ANON_KEY)` : `${stack.envFile} as ${envNames(stack).url} and ${envNames(stack).key}`}.`,
|
|
553
|
+
`Connected: Vercel ${connected('vercel') ? 'yes' : 'no'}, Supabase ${connected('supabase') ? 'yes' : 'no'}, GitHub ${connected('github') ? 'yes' : 'no'}.`,
|
|
554
|
+
remote ? `Git remote: ${githubRepoFromUrl(remote) || 'not GitHub'}.` : 'No git remote yet.',
|
|
555
|
+
state.vercel?.projectName ? `Published before as Vercel project "${state.vercel.projectName}"${state.vercel.url ? ` at ${state.vercel.url}` : ''}.` : 'Never published from here.',
|
|
556
|
+
state.supabase?.ref ? `Supabase project already set up: ${state.supabase.name || state.supabase.ref} (${state.supabase.ref}).` : '',
|
|
557
|
+
state.github?.choice ? `GitHub auto-deploy: ${state.github.choice === 'yes' ? (state.github.linked ? `linked to ${state.github.repo}` : 'wanted, not linked yet') : 'the user said no'}.` : 'GitHub auto-deploy: not asked yet (publish_deploy asks before the first deploy).',
|
|
558
|
+
'',
|
|
559
|
+
'Decide yourself whether the app needs a database: sign-in, data that must be saved and shared across visitors or devices, or code that already uses Supabase means yes; a brochure site or a page whose forms only need email means no.',
|
|
560
|
+
].filter((l) => l !== '');
|
|
561
|
+
const needsDb = db.verdict !== 'no' || !!state.supabase?.ref;
|
|
562
|
+
return {
|
|
563
|
+
ok: true,
|
|
564
|
+
output: lines.join('\n'),
|
|
565
|
+
meta: {
|
|
566
|
+
label: `${stack.label}, database ${db.verdict}`,
|
|
567
|
+
publish: card(ctx, { steps: {
|
|
568
|
+
database: state.supabase?.ref ? { status: 'done', detail: state.supabase.name || state.supabase.ref, url: `https://supabase.com/dashboard/project/${state.supabase.ref}` } : needsDb ? { status: 'pending', detail: 'Checking' } : { status: 'skipped', detail: 'Not needed' },
|
|
569
|
+
github: state.github?.choice === 'no' ? { status: 'skipped', detail: 'Direct deploy' } : state.github?.linked ? { status: 'done', detail: state.github.repo, url: `https://github.com/${state.github.repo}` } : {},
|
|
570
|
+
} }),
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function publish_connect(args, ctx) {
|
|
576
|
+
const service = String(args.service || '').trim().toLowerCase();
|
|
577
|
+
if (!LABEL[service]) return { ok: false, output: 'publish_connect needs <service>: supabase, vercel or github.' };
|
|
578
|
+
const label = LABEL[service];
|
|
579
|
+
const step = service === 'supabase' ? 'database' : service;
|
|
580
|
+
if (connected(service)) return { ok: true, output: `${label} is already connected. Carry on.`, meta: { label: `${label} connected` } };
|
|
581
|
+
if (typeof ctx.ask !== 'function') {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
output: `${label} is not connected and the user cannot be asked from here. Tell them to connect ${label} in Connect Apps (account menu, Connect Apps, ${label}) and to say "continue" when it is done. Stop here.`,
|
|
585
|
+
meta: { label: `${label} not connected` },
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const why = String(args.reason || '').trim();
|
|
589
|
+
const question = service === 'supabase'
|
|
590
|
+
? `${why || 'This app needs a database to save its data.'} Log in to your Supabase account so Codeply can set it up.`
|
|
591
|
+
: service === 'vercel'
|
|
592
|
+
? `${why || 'Publishing puts the site on Vercel.'} Connect your Vercel account to continue.`
|
|
593
|
+
: `${why || 'GitHub keeps the code and redeploys the site on every push.'} Connect your GitHub account to continue.`;
|
|
594
|
+
const skip = service === 'supabase' ? 'Skip the database' : service === 'github' ? 'Skip GitHub, just deploy' : 'Not now';
|
|
595
|
+
card(ctx, { steps: { [step]: { status: 'waiting', detail: `Waiting for you to connect ${label}` } } });
|
|
596
|
+
const answer = await ctx.ask({ question, options: [skip], connect: service });
|
|
597
|
+
if (connected(service)) {
|
|
598
|
+
const who = integration(service);
|
|
599
|
+
return {
|
|
600
|
+
ok: true,
|
|
601
|
+
output: `${label} is connected now${who.userName || who.email ? ` (${who.userName || who.email})` : ''}. Carry on with the publish right away, without asking again.`,
|
|
602
|
+
meta: { label: `${label} connected`, publish: card(ctx, { steps: { [step]: { status: step === 'github' ? 'active' : 'pending', detail: `${label} connected` } } }) },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const said = answer ? ` They answered: "${String(answer).slice(0, 200)}".` : '';
|
|
606
|
+
const next = service === 'supabase'
|
|
607
|
+
? 'Publish without a database only if the app still works without one; otherwise stop and explain in plain words that the app needs Supabase to save its data.'
|
|
608
|
+
: service === 'github'
|
|
609
|
+
? 'Deploy directly with publish_deploy instead.'
|
|
610
|
+
: 'Stop here and tell the user publishing needs a Vercel connection.';
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
output: `The user did not connect ${label}.${said} ${next}`,
|
|
614
|
+
meta: { label: `${label} not connected`, publish: card(ctx, { steps: { [step]: { status: 'skipped', detail: `${label} not connected` } } }) },
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function supabase_setup(args, ctx) {
|
|
619
|
+
if (!wantsPublish(ctx.userMessage) && !/supabase|database/i.test(String(ctx.userMessage || ''))) return { ok: false, output: NOT_ASKED };
|
|
620
|
+
const token = await getSupabaseToken();
|
|
621
|
+
if (!token) return notConnected('supabase', ctx);
|
|
622
|
+
const root = ctx.cwd;
|
|
623
|
+
const stack = detectStack(root);
|
|
624
|
+
const state = readState(root);
|
|
625
|
+
card(ctx, { steps: { database: { status: 'active', detail: 'Setting up Supabase' } } });
|
|
626
|
+
const fail = (msg) => ({ ok: false, output: msg, meta: { label: 'Supabase setup failed', publish: card(ctx, { steps: { database: { status: 'error', detail: msg.slice(0, 200) } } }) } });
|
|
627
|
+
|
|
628
|
+
const list = await supabaseCall(token, 'GET', '/v1/projects');
|
|
629
|
+
if (!list.ok) return fail(supabaseError(list, 'Listing your Supabase projects').message);
|
|
630
|
+
const projects = Array.isArray(list.body) ? list.body : [];
|
|
631
|
+
const wanted = String(args.project || '').trim();
|
|
632
|
+
let chosen = null;
|
|
633
|
+
let create = wanted.toLowerCase() === 'new';
|
|
634
|
+
if (wanted && !create) {
|
|
635
|
+
chosen = projects.find((p) => p.ref === wanted) || projects.find((p) => p.name?.toLowerCase() === wanted.toLowerCase());
|
|
636
|
+
if (!chosen) return fail(`No Supabase project "${wanted}". Projects on this account: ${projects.map((p) => `${p.name} (${p.ref})`).join(', ') || 'none'}.`);
|
|
637
|
+
} else if (!create) {
|
|
638
|
+
chosen = state.supabase?.ref ? projects.find((p) => p.ref === state.supabase.ref) : null;
|
|
639
|
+
if (!chosen && projects.length) {
|
|
640
|
+
if (typeof ctx.ask !== 'function') {
|
|
641
|
+
return fail(`Ask the user which Supabase project to use, then call supabase_setup again with <project> set to its ref, or "new". Their projects: ${projects.map((p) => `${p.name} (${p.ref})`).join(', ')}.`);
|
|
642
|
+
}
|
|
643
|
+
const options = [...projects.slice(0, 4).map((p) => `${p.name} (${p.ref})`), 'Create a new project'];
|
|
644
|
+
const answer = await ctx.ask({ question: 'Which Supabase project should this app use?', options });
|
|
645
|
+
if (answer == null) return fail('The user did not pick a Supabase project. Stop and ask them which one to use.');
|
|
646
|
+
const ref = String(answer).match(/\(([a-z0-9]{6,})\)\s*$/i)?.[1];
|
|
647
|
+
chosen = ref ? projects.find((p) => p.ref === ref) : projects.find((p) => p.name?.toLowerCase() === String(answer).trim().toLowerCase());
|
|
648
|
+
if (!chosen) create = /new|create/i.test(String(answer));
|
|
649
|
+
if (!chosen && !create) return fail(`"${answer}" is not one of the Supabase projects. Ask again.`);
|
|
650
|
+
} else if (!chosen) create = true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let created = false;
|
|
654
|
+
if (create) {
|
|
655
|
+
const name = sanitizeProjectName(args.name || path.basename(root));
|
|
656
|
+
const orgs = await supabaseCall(token, 'GET', '/v1/organizations');
|
|
657
|
+
if (!orgs.ok) return fail(supabaseError(orgs, 'Listing your Supabase organizations').message);
|
|
658
|
+
if (!Array.isArray(orgs.body) || !orgs.body.length) return fail('This Supabase account has no organization yet. Create one at supabase.com/dashboard, then publish again.');
|
|
659
|
+
let org = orgs.body[0];
|
|
660
|
+
if (orgs.body.length > 1 && typeof ctx.ask === 'function') {
|
|
661
|
+
const answer = await ctx.ask({ question: 'Which Supabase organization should own the new project?', options: orgs.body.slice(0, 5).map((o) => o.name) });
|
|
662
|
+
org = orgs.body.find((o) => o.name === answer) || org;
|
|
663
|
+
}
|
|
664
|
+
const detail = await supabaseCall(token, 'GET', `/v1/organizations/${encodeURIComponent(org.slug || org.id)}`);
|
|
665
|
+
const plan = String((detail.ok && detail.body?.plan) || 'unknown').toLowerCase();
|
|
666
|
+
const region = String(args.region || 'us-east-1').trim();
|
|
667
|
+
const paid = plan !== 'free';
|
|
668
|
+
const verdict = await ctx.approve({
|
|
669
|
+
tool: 'supabase_setup',
|
|
670
|
+
title: `Create Supabase project "${name}"`,
|
|
671
|
+
detail: `Organization: ${org.name} (plan: ${plan}). Region: ${region}.\n` +
|
|
672
|
+
(paid ? 'This organization is on a paid plan (or its plan is unknown), so a new project can add to your Supabase bill.\n' : 'Free plan: no charge. A free organization can have two active projects.\n') +
|
|
673
|
+
'A strong database password is generated for you and not shown or saved in the app.',
|
|
674
|
+
danger: paid,
|
|
675
|
+
});
|
|
676
|
+
if (verdict === 'reject') return { ok: false, output: 'The user declined creating a Supabase project. Ask whether to use an existing project or publish without a database.', meta: { rejected: true, publish: card(ctx, { steps: { database: { status: 'skipped', detail: 'Declined' } } }) } };
|
|
677
|
+
try {
|
|
678
|
+
chosen = await oauth.supabaseCreateProject(token, {
|
|
679
|
+
name, organizationSlug: org.slug || org.id, region,
|
|
680
|
+
dbPass: crypto.randomBytes(24).toString('base64url'),
|
|
681
|
+
pollMs: Number(process.env.CODEPLY_PUBLISH_POLL_MS) || undefined,
|
|
682
|
+
});
|
|
683
|
+
created = true;
|
|
684
|
+
} catch (e) {
|
|
685
|
+
return fail(`Creating the Supabase project failed: ${e.message}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let keys;
|
|
690
|
+
try { keys = await getPublicKeys(token, chosen.ref); } catch (e) { return fail(e.message); }
|
|
691
|
+
let written;
|
|
692
|
+
try { written = writeSupabaseConfig(root, stack, keys); } catch (e) { return fail(`Could not write the Supabase settings into the app: ${e.message}`); }
|
|
693
|
+
saveState(root, { supabase: { ref: chosen.ref, name: chosen.name, url: keys.url, envFile: stack.configJs ? null : stack.envFile, configJs: stack.configJs || null, vars: written.vars.map((v) => v.key) } });
|
|
694
|
+
|
|
695
|
+
const readHow = stack.configJs
|
|
696
|
+
? `The pages now load ${stack.configJs} first, so the app code reads window.SUPABASE_URL and window.SUPABASE_ANON_KEY (for example supabase.createClient(window.SUPABASE_URL, window.SUPABASE_ANON_KEY)).`
|
|
697
|
+
: `The app code must read ${written.names.url} and ${written.names.key} (the way ${stack.label} exposes env vars to the browser). publish_deploy copies them to the Vercel project.`;
|
|
698
|
+
return {
|
|
699
|
+
ok: true,
|
|
700
|
+
output: `${created ? 'Created' : 'Using'} Supabase project "${chosen.name}" (ref ${chosen.ref}, ${keys.url}). Wrote its URL and anon key to ${written.files.join(', ')}. ` +
|
|
701
|
+
`${readHow} The anon key is public by design; Row Level Security protects the data. Never put the service role key in client code. ` +
|
|
702
|
+
'If the app needs tables, write the SQL and run it with supabase_schema (the user sees it and approves first). Check that the code uses these values before deploying.',
|
|
703
|
+
meta: { label: `${chosen.name} (${chosen.ref})`, files: written.files, publish: card(ctx, { steps: { database: { status: 'done', detail: `${chosen.name}${created ? ' (new)' : ''}`, url: `https://supabase.com/dashboard/project/${chosen.ref}` } } }) },
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function supabase_schema(args, ctx) {
|
|
708
|
+
const state = readState(ctx.cwd);
|
|
709
|
+
const ref = String(args.ref || state.supabase?.ref || '').trim();
|
|
710
|
+
if (!ref) return { ok: false, output: 'No Supabase project yet. Run supabase_setup first, or pass <ref>.' };
|
|
711
|
+
let sql = String(args.sql || '').trim();
|
|
712
|
+
if (!sql && args.path) {
|
|
713
|
+
try { sql = fs.readFileSync(resolveDir(args.path, ctx.cwd), 'utf8'); } catch (e) { return { ok: false, output: `Could not read ${args.path}: ${e.message}` }; }
|
|
714
|
+
}
|
|
715
|
+
if (!sql.trim()) return { ok: false, output: 'supabase_schema needs <sql> (or <path> to a .sql file).' };
|
|
716
|
+
const token = await getSupabaseToken();
|
|
717
|
+
if (!token) return notConnected('supabase', ctx);
|
|
718
|
+
const prep = prepareSchemaSql(sql);
|
|
719
|
+
const notes = [
|
|
720
|
+
prep.addedRls.length ? `-- Codeply added: row level security on ${prep.addedRls.join(', ')}` : '',
|
|
721
|
+
prep.noPolicy.length ? `-- Warning: no policy on ${prep.noPolicy.join(', ')}, so the app cannot read or write ${prep.noPolicy.length === 1 ? 'it' : 'them'} yet` : '',
|
|
722
|
+
].filter(Boolean);
|
|
723
|
+
const verdict = await ctx.approve({
|
|
724
|
+
tool: 'supabase_schema',
|
|
725
|
+
title: `Run SQL on Supabase project ${state.supabase?.name || ref}`,
|
|
726
|
+
detail: [...notes, prep.sql].join('\n').slice(0, 6000),
|
|
727
|
+
danger: DESTRUCTIVE_SQL.test(prep.sql),
|
|
728
|
+
});
|
|
729
|
+
if (verdict === 'reject') return { ok: false, output: 'The user declined running that SQL. Nothing changed in the database. Ask what they want changed.', meta: { rejected: true } };
|
|
730
|
+
let r;
|
|
731
|
+
try { r = await oauth.supabaseQuery(token, ref, prep.sql); } catch (e) { return { ok: false, output: `Running the SQL failed: ${e.message}` }; }
|
|
732
|
+
if (!r.ok) {
|
|
733
|
+
const msg = r.body?.message || r.body?.error || JSON.stringify(r.body).slice(0, 400);
|
|
734
|
+
return { ok: false, output: `Supabase rejected the SQL (HTTP ${r.status}): ${msg}. Fix the SQL and run supabase_schema again.`, meta: { label: 'SQL failed' } };
|
|
735
|
+
}
|
|
736
|
+
const warn = prep.noPolicy.length
|
|
737
|
+
? ` Warning: ${prep.noPolicy.join(', ')} ${prep.noPolicy.length === 1 ? 'has' : 'have'} RLS on but no policy, so the app can neither read nor write ${prep.noPolicy.length === 1 ? 'it' : 'them'}. Add policies (create policy ... on <table> for select/insert using (...)) with supabase_schema.`
|
|
738
|
+
: '';
|
|
739
|
+
return {
|
|
740
|
+
ok: true,
|
|
741
|
+
output: `Applied the SQL to ${ref}.${prep.tables.length ? ` Tables: ${prep.tables.join(', ')}.` : ''}${prep.addedRls.length ? ` Turned on row level security for ${prep.addedRls.join(', ')}.` : ''}${warn}`,
|
|
742
|
+
meta: { label: prep.tables.length ? `tables ${prep.tables.join(', ')}` : 'SQL applied', publish: card(ctx, { steps: { database: { status: 'done', detail: prep.tables.length ? `${prep.tables.length} table(s) ready` : 'Schema applied' } } }) },
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function publish_deploy(args, ctx) {
|
|
747
|
+
if (!wantsPublish(ctx.userMessage)) return { ok: false, output: NOT_ASKED };
|
|
748
|
+
const root = rootOf(args, ctx);
|
|
749
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return { ok: false, output: `${args.path || '.'} is not a folder.` };
|
|
750
|
+
const v = integration('vercel');
|
|
751
|
+
if (!v.accessToken) return notConnected('vercel', ctx);
|
|
752
|
+
let state = readState(ctx.cwd);
|
|
753
|
+
const stack = detectStack(root);
|
|
754
|
+
|
|
755
|
+
// Before the very first deploy: GitHub or not. Asked once per project.
|
|
756
|
+
if (!state.github?.choice && !state.vercel?.lastDeploymentId && typeof ctx.ask === 'function') {
|
|
757
|
+
const answer = await ctx.ask({ question: 'Connect GitHub so pushes deploy automatically?', options: ['Yes, connect GitHub', 'No, just deploy'] });
|
|
758
|
+
const yes = answer != null && /^\s*(yes|y|sure|ok)/i.test(String(answer));
|
|
759
|
+
state = saveState(ctx.cwd, { github: { choice: yes ? 'yes' : 'no' } });
|
|
760
|
+
if (yes) {
|
|
761
|
+
return {
|
|
762
|
+
ok: true,
|
|
763
|
+
output: 'The user wants GitHub. Run publish_github now (it connects GitHub if needed, creates or reuses the repo, pushes and links it to Vercel), then call publish_deploy again for the first deploy.',
|
|
764
|
+
meta: { label: 'GitHub first', publish: card(ctx, { steps: { github: { status: 'active', detail: 'Setting up GitHub' } } }) },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
card(ctx, { steps: { github: { status: 'skipped', detail: 'Direct deploy' } } });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const files = listProjectFiles(root, { builds: stack.builds });
|
|
771
|
+
if (!files.length) return { ok: false, output: 'There are no files to publish in this folder.' };
|
|
772
|
+
let bytes = 0;
|
|
773
|
+
for (const f of files) bytes += fs.statSync(f).size;
|
|
774
|
+
if (bytes > MAX_DEPLOY_BYTES) return { ok: false, output: `The folder is ${(bytes / 1e6).toFixed(0)}MB, over the ${MAX_DEPLOY_BYTES / 1e6}MB publish limit. Remove large files (videos, archives) or publish a smaller folder.` };
|
|
775
|
+
const name = sanitizeProjectName(args.name || state.vercel?.projectName || path.basename(root));
|
|
776
|
+
const env = publicEnvFor(ctx.cwd, state, stack);
|
|
777
|
+
|
|
778
|
+
const verdict = await ctx.approve({
|
|
779
|
+
tool: 'publish_deploy',
|
|
780
|
+
title: `Publish "${name}" to Vercel`,
|
|
781
|
+
detail: `${files.length} file(s), ${(bytes / 1024).toFixed(0)}KB, ${stack.label}.` +
|
|
782
|
+
(env.length ? `\nEnvironment variables: ${env.map((e) => e.key).join(', ')}` : '') +
|
|
783
|
+
'\nProduction deployment: it will be live on the internet.',
|
|
784
|
+
danger: true,
|
|
785
|
+
});
|
|
786
|
+
if (verdict === 'reject') return { ok: false, output: 'The user declined publishing. Nothing was deployed.', meta: { rejected: true, publish: card(ctx, { steps: { vercel: { status: 'skipped', detail: 'Declined' } } }) } };
|
|
787
|
+
|
|
788
|
+
const fail = (msg, extra = {}) => ({ ok: false, output: hideSecrets(msg, [v.accessToken]), meta: { label: 'publish failed', ...extra, publish: card(ctx, { steps: { vercel: { status: 'error', detail: hideSecrets(msg, [v.accessToken]).split('\n')[0].slice(0, 220) } } }) } });
|
|
789
|
+
let project, deployment, final;
|
|
790
|
+
try {
|
|
791
|
+
card(ctx, { steps: { vercel: { status: 'active', detail: 'Preparing the project' } } });
|
|
792
|
+
project = await ensureVercelProject(v, name, stack);
|
|
793
|
+
state = saveState(ctx.cwd, { vercel: { projectId: project.id, projectName: project.name } });
|
|
794
|
+
await setVercelEnv(v, project.id, env);
|
|
795
|
+
const uploaded = await uploadFiles(v, root, files);
|
|
796
|
+
deployment = await createDeployment(v, { name: project.name, projectId: project.id, files: uploaded, stack });
|
|
797
|
+
final = await waitForDeployment(v, deployment.id, ctx.signal);
|
|
798
|
+
} catch (e) {
|
|
799
|
+
return fail(`Publishing to Vercel failed: ${e.message}`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (final.readyState === 'READY') {
|
|
803
|
+
const url = await liveUrl(v, project, final).catch(() => `https://${project.name}.vercel.app`);
|
|
804
|
+
saveState(ctx.cwd, { vercel: { url, lastDeploymentId: deployment.id, lastPublishedAt: new Date().toISOString() } });
|
|
805
|
+
ctx.publishBuildFailures = 0;
|
|
806
|
+
const gh = readState(ctx.cwd).github;
|
|
807
|
+
return {
|
|
808
|
+
ok: true,
|
|
809
|
+
output: `Published. The site is live at ${url} (Vercel project "${project.name}", deployment ${deployment.id} is READY).` +
|
|
810
|
+
(gh?.linked ? ` GitHub ${gh.repo} is linked, so every push to ${gh.branch || 'the main branch'} deploys on its own.` : '') +
|
|
811
|
+
' Give the user this link.',
|
|
812
|
+
meta: { label: url, liveUrl: url, publish: card(ctx, { url, steps: { vercel: { status: 'done', detail: project.name, url: final.inspectorUrl || 'https://vercel.com/dashboard' }, live: { status: 'done', detail: url } } }) },
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (final.readyState === 'ERROR') {
|
|
816
|
+
ctx.publishBuildFailures = (ctx.publishBuildFailures || 0) + 1;
|
|
817
|
+
const log = hideSecrets(await buildLog(v, deployment.id).catch(() => ''), [v.accessToken]);
|
|
818
|
+
const first = ctx.publishBuildFailures === 1;
|
|
819
|
+
return fail(
|
|
820
|
+
`The build failed on Vercel (deployment ${deployment.id}).${log ? `\nBuild log, last lines:\n${log}` : ''}\n` +
|
|
821
|
+
(first
|
|
822
|
+
? 'Find the cause in the project (the log above names it), fix it, check it locally if you can, then call publish_deploy again. You get one retry.'
|
|
823
|
+
: 'It failed again after a fix. Stop retrying. Tell the user in plain words what failed and what you tried.'),
|
|
824
|
+
{ buildFailed: true },
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (final.readyState === 'TIMEOUT') return fail(`Vercel is still building deployment ${deployment.id} after ${Math.round(DEPLOY_TIMEOUT_MS() / 60000)} minutes. Check it with vercel_api GET /v13/deployments/${deployment.id} before telling the user anything.`);
|
|
828
|
+
return fail(`The deployment was canceled (${deployment.id}).`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function publish_github(args, ctx) {
|
|
832
|
+
if (!wantsPublish(ctx.userMessage) && !/github|repo|push/i.test(String(ctx.userMessage || ''))) return { ok: false, output: NOT_ASKED };
|
|
833
|
+
const root = rootOf(args, ctx);
|
|
834
|
+
const gh = integration('github');
|
|
835
|
+
if (!gh.accessToken) return notConnected('github', ctx);
|
|
836
|
+
const v = integration('vercel');
|
|
837
|
+
if (!v.accessToken) return notConnected('vercel', ctx);
|
|
838
|
+
const secrets = [gh.accessToken, v.accessToken];
|
|
839
|
+
const fail = (msg) => ({ ok: false, output: hideSecrets(msg, secrets), meta: { label: 'GitHub failed', publish: card(ctx, { steps: { github: { status: 'error', detail: hideSecrets(msg, secrets).slice(0, 220) } } }) } });
|
|
840
|
+
|
|
841
|
+
const isRepo = fs.existsSync(path.join(root, '.git'));
|
|
842
|
+
let remote = '';
|
|
843
|
+
if (isRepo) { try { remote = await git(['remote', 'get-url', 'origin'], root); } catch {} }
|
|
844
|
+
const existing = githubRepoFromUrl(remote);
|
|
845
|
+
const repoName = sanitizeRepoName(args.name || path.basename(root));
|
|
846
|
+
const isPrivate = !/^(false|no|public)$/i.test(String(args.private || ''));
|
|
847
|
+
const verdict = await ctx.approve({
|
|
848
|
+
tool: 'publish_github',
|
|
849
|
+
title: existing ? `Push to ${existing} and link it to Vercel` : `Create GitHub repo "${repoName}" and link it to Vercel`,
|
|
850
|
+
detail: (existing ? `Commits any changes and pushes to ${existing}.` : `Creates a ${isPrivate ? 'private' : 'public'} repo under ${gh.userName || 'your account'}, commits the project and pushes it.`) +
|
|
851
|
+
'\n.env files are kept out of git. Then the Vercel project is linked to the repo, so every push deploys on its own.',
|
|
852
|
+
danger: false,
|
|
853
|
+
});
|
|
854
|
+
if (verdict === 'reject') return { ok: false, output: 'The user declined the GitHub step. Deploy directly with publish_deploy.', meta: { rejected: true, publish: card(ctx, { steps: { github: { status: 'skipped', detail: 'Direct deploy' } } }) } };
|
|
855
|
+
|
|
856
|
+
card(ctx, { steps: { github: { status: 'active', detail: existing ? `Pushing to ${existing}` : 'Creating the repo' } } });
|
|
857
|
+
let full = existing;
|
|
858
|
+
let branch = 'main';
|
|
859
|
+
try {
|
|
860
|
+
ensureGitignore(root);
|
|
861
|
+
if (!isRepo) await git(['init', '-b', 'main'], root).catch(() => git(['init'], root));
|
|
862
|
+
const name = await git(['config', 'user.name'], root).catch(() => '');
|
|
863
|
+
const email = await git(['config', 'user.email'], root).catch(() => '');
|
|
864
|
+
const ident = name && email ? [] : ['-c', `user.name=${name || gh.userName || 'Codeply'}`, '-c', `user.email=${email || `${gh.userName || 'codeply'}@users.noreply.github.com`}`];
|
|
865
|
+
await git(['add', '-A'], root);
|
|
866
|
+
const status = await git(['status', '--porcelain'], root);
|
|
867
|
+
let hasHead = true;
|
|
868
|
+
try { await git(['rev-parse', 'HEAD'], root); } catch { hasHead = false; }
|
|
869
|
+
if (status || !hasHead) await git([...ident, 'commit', '-m', hasHead ? 'Publish with Codeply' : 'First commit', '--allow-empty'], root);
|
|
870
|
+
branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], root)) || 'main';
|
|
871
|
+
if (!existing) {
|
|
872
|
+
const made = await oauth.githubCreateRepo(gh.accessToken, repoName, { private: isPrivate });
|
|
873
|
+
full = made.full_name;
|
|
874
|
+
await git(['remote', remote ? 'set-url' : 'add', 'origin', made.clone_url], root);
|
|
875
|
+
}
|
|
876
|
+
// The token rides an HTTP header for this one push only, never the
|
|
877
|
+
// remote URL, so it is not left in .git/config.
|
|
878
|
+
const basic = Buffer.from(`x-access-token:${gh.accessToken}`).toString('base64');
|
|
879
|
+
await git(['-c', `http.extraHeader=Authorization: Basic ${basic}`, 'push', '-u', 'origin', branch], root);
|
|
880
|
+
} catch (e) {
|
|
881
|
+
const why = /already exists/i.test(e.message) ? `A repo named "${repoName}" already exists on your GitHub. Pick another <name>.` : (e.output || e.message);
|
|
882
|
+
return fail(`The GitHub step failed: ${String(why).slice(0, 600)}`);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let project, link;
|
|
886
|
+
try {
|
|
887
|
+
const stack = detectStack(root);
|
|
888
|
+
const state = readState(ctx.cwd);
|
|
889
|
+
project = await ensureVercelProject(v, sanitizeProjectName(state.vercel?.projectName || path.basename(root)), stack);
|
|
890
|
+
saveState(ctx.cwd, { vercel: { projectId: project.id, projectName: project.name } });
|
|
891
|
+
link = await linkGitRepo(v, project.id, full);
|
|
892
|
+
} catch (e) {
|
|
893
|
+
link = { linked: false, why: e.message };
|
|
894
|
+
}
|
|
895
|
+
saveState(ctx.cwd, { github: { choice: 'yes', repo: full, branch, linked: link.linked } });
|
|
896
|
+
if (!link.linked) {
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
output: `Pushed the code to ${full} (branch ${branch}), but linking it to Vercel did not work: ${link.why} Carry on with publish_deploy now; pushes will deploy on their own once the link works.`,
|
|
900
|
+
meta: { label: full, publish: card(ctx, { steps: { github: { status: 'error', detail: link.why.slice(0, 220), url: `https://github.com/${full}` } } }) },
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
ok: true,
|
|
905
|
+
output: `Pushed the code to ${full} (branch ${branch}) and linked it to the Vercel project "${project.name}". From now on every push to ${branch} deploys on its own. Now call publish_deploy for the first deploy.`,
|
|
906
|
+
meta: { label: full, publish: card(ctx, { steps: { github: { status: 'done', detail: `${full}, auto-deploy on`, url: `https://github.com/${full}` } } }) },
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return { publish_check, publish_connect, supabase_setup, supabase_schema, publish_deploy, publish_github };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
module.exports = {
|
|
914
|
+
makePublishTools, wantsPublish, detectStack, detectDatabaseNeed, envNames, writeSupabaseConfig, prepareSchemaSql,
|
|
915
|
+
listProjectFiles, readState, saveState, githubRepoFromUrl, ensureGitignore, STATE_FILE,
|
|
916
|
+
};
|