devpush 0.0.0-stage → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/README.md +6 -2
  2. package/agent-release.json +37 -0
  3. package/dist/package/LICENSE.md +21 -0
  4. package/dist/package/migrate.mjs +182 -0
  5. package/dist/package/plugins.mjs +125 -0
  6. package/dist/package/vite.mjs +439 -0
  7. package/package.json +170 -4
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/logo-email.png +0 -0
  11. package/public/robots.txt +3 -0
  12. package/public/social.png +0 -0
  13. package/src/cloudflare-runtime.d.ts +78 -0
  14. package/src/components/about-dialog.tsx +70 -0
  15. package/src/components/copy-button.tsx +74 -0
  16. package/src/components/logo-mark.tsx +20 -0
  17. package/src/components/pending-collection.tsx +103 -0
  18. package/src/components/resource-avatar.tsx +65 -0
  19. package/src/components/route-error-state.tsx +95 -0
  20. package/src/components/section-navigation.tsx +39 -0
  21. package/src/components/settings-layout.tsx +111 -0
  22. package/src/components/storage-capabilities.tsx +11 -0
  23. package/src/components/table-pager.tsx +101 -0
  24. package/src/components/ui/alert-dialog.tsx +180 -0
  25. package/src/components/ui/alert.tsx +78 -0
  26. package/src/components/ui/avatar.tsx +58 -0
  27. package/src/components/ui/badge.tsx +49 -0
  28. package/src/components/ui/button-group.tsx +38 -0
  29. package/src/components/ui/button.tsx +67 -0
  30. package/src/components/ui/card.tsx +82 -0
  31. package/src/components/ui/chart.tsx +307 -0
  32. package/src/components/ui/checkbox.tsx +30 -0
  33. package/src/components/ui/combobox.tsx +256 -0
  34. package/src/components/ui/dialog.tsx +163 -0
  35. package/src/components/ui/dropdown-menu.tsx +110 -0
  36. package/src/components/ui/empty.tsx +65 -0
  37. package/src/components/ui/field.tsx +192 -0
  38. package/src/components/ui/input-group.tsx +118 -0
  39. package/src/components/ui/input-otp.tsx +70 -0
  40. package/src/components/ui/input.tsx +19 -0
  41. package/src/components/ui/label.tsx +24 -0
  42. package/src/components/ui/pagination.tsx +34 -0
  43. package/src/components/ui/popover.tsx +86 -0
  44. package/src/components/ui/select.tsx +145 -0
  45. package/src/components/ui/skeleton.tsx +19 -0
  46. package/src/components/ui/sonner.tsx +47 -0
  47. package/src/components/ui/switch.tsx +31 -0
  48. package/src/components/ui/table.tsx +81 -0
  49. package/src/components/ui/textarea.tsx +18 -0
  50. package/src/components/ui/tooltip.tsx +55 -0
  51. package/src/features/admin/admin-settings.tsx +1048 -0
  52. package/src/features/auth/current-user-context-query.ts +11 -0
  53. package/src/features/auth/login-form.tsx +442 -0
  54. package/src/features/auth/sign-out-menu-item.tsx +42 -0
  55. package/src/features/deployments/deployment-actions.tsx +336 -0
  56. package/src/features/deployments/deployment-alias-badge.tsx +35 -0
  57. package/src/features/deployments/deployment-detail.tsx +596 -0
  58. package/src/features/deployments/deployment-filters.ts +66 -0
  59. package/src/features/deployments/deployment-list.tsx +465 -0
  60. package/src/features/deployments/deployment-visit-links.tsx +104 -0
  61. package/src/features/deployments/deployments-page.tsx +376 -0
  62. package/src/features/logs/log-scroll-area.tsx +69 -0
  63. package/src/features/notifications/email-preferences.tsx +232 -0
  64. package/src/features/onboarding/profile-onboarding.tsx +162 -0
  65. package/src/features/plugins/contributions.tsx +118 -0
  66. package/src/features/projects/backup-job-logs.tsx +143 -0
  67. package/src/features/projects/backup-policy-delete-dialog.tsx +82 -0
  68. package/src/features/projects/backup-restore-actions.tsx +573 -0
  69. package/src/features/projects/backup-status-icon.tsx +33 -0
  70. package/src/features/projects/backup-target-list.tsx +222 -0
  71. package/src/features/projects/backup-timestamp.tsx +32 -0
  72. package/src/features/projects/backup-volume-picker.tsx +158 -0
  73. package/src/features/projects/compose-service-input.tsx +64 -0
  74. package/src/features/projects/deploy-readiness.ts +28 -0
  75. package/src/features/projects/environment-dot.tsx +91 -0
  76. package/src/features/projects/log-keyword-filter.tsx +48 -0
  77. package/src/features/projects/manual-deploy-dialog.tsx +563 -0
  78. package/src/features/projects/pending-section.ts +26 -0
  79. package/src/features/projects/project-backup-details-pending.tsx +305 -0
  80. package/src/features/projects/project-backup-details.tsx +936 -0
  81. package/src/features/projects/project-backup-policy.tsx +644 -0
  82. package/src/features/projects/project-backup-utils.ts +190 -0
  83. package/src/features/projects/project-backups-pending.tsx +136 -0
  84. package/src/features/projects/project-backups.tsx +876 -0
  85. package/src/features/projects/project-environment-settings.tsx +1128 -0
  86. package/src/features/projects/project-heading.tsx +68 -0
  87. package/src/features/projects/project-layout.tsx +188 -0
  88. package/src/features/projects/project-logs.tsx +461 -0
  89. package/src/features/projects/project-overview.tsx +280 -0
  90. package/src/features/projects/project-route-pending.tsx +1064 -0
  91. package/src/features/projects/project-routing-settings.tsx +663 -0
  92. package/src/features/projects/project-settings-section.tsx +64 -0
  93. package/src/features/projects/project-settings-utils.ts +101 -0
  94. package/src/features/projects/project-settings.tsx +1472 -0
  95. package/src/features/projects/repository-picker-dialog.tsx +404 -0
  96. package/src/features/repositories/new-project-dialog-launcher.tsx +44 -0
  97. package/src/features/repositories/new-project-dialog.tsx +1204 -0
  98. package/src/features/repositories/new-project-pending.tsx +28 -0
  99. package/src/features/repositories/repository-list-pending.tsx +59 -0
  100. package/src/features/servers/server-availability-alert.tsx +44 -0
  101. package/src/features/servers/server-availability.ts +29 -0
  102. package/src/features/servers/server-avatar.tsx +52 -0
  103. package/src/features/servers/server-detail-pending.tsx +315 -0
  104. package/src/features/servers/server-detail.tsx +855 -0
  105. package/src/features/servers/server-metric-chart-pending.tsx +38 -0
  106. package/src/features/servers/server-metric-chart.tsx +182 -0
  107. package/src/features/servers/server-metric-format.ts +50 -0
  108. package/src/features/servers/server-metrics-pending.tsx +75 -0
  109. package/src/features/servers/server-metrics.tsx +136 -0
  110. package/src/features/servers/team-servers.tsx +432 -0
  111. package/src/features/settings/settings-route-pending.tsx +406 -0
  112. package/src/features/teams/backup-storage-settings.tsx +483 -0
  113. package/src/features/teams/navigation.ts +51 -0
  114. package/src/features/teams/new-team-dialog.tsx +132 -0
  115. package/src/features/teams/pending-section.ts +19 -0
  116. package/src/features/teams/team-header.tsx +1286 -0
  117. package/src/features/teams/team-heading.tsx +57 -0
  118. package/src/features/teams/team-home.tsx +391 -0
  119. package/src/features/teams/team-invite.tsx +155 -0
  120. package/src/features/teams/team-route-pending.tsx +505 -0
  121. package/src/features/teams/team-settings.tsx +1555 -0
  122. package/src/features/teams/use-workspace-updates.ts +126 -0
  123. package/src/features/users/api-keys-settings.tsx +544 -0
  124. package/src/features/users/api-scope-selection.tsx +269 -0
  125. package/src/features/users/connected-apps-settings.tsx +449 -0
  126. package/src/features/users/mcp-consent.tsx +220 -0
  127. package/src/features/users/profile-picture.tsx +171 -0
  128. package/src/features/users/user-settings.tsx +1064 -0
  129. package/src/lib/account-settings-schema.ts +78 -0
  130. package/src/lib/action-feedback.ts +81 -0
  131. package/src/lib/admin-search-schema.ts +8 -0
  132. package/src/lib/agent-release.d.mts +21 -0
  133. package/src/lib/agent-release.mjs +90 -0
  134. package/src/lib/allowlist-schema.ts +79 -0
  135. package/src/lib/api-key-schema.ts +176 -0
  136. package/src/lib/auth/client.ts +20 -0
  137. package/src/lib/avatar-schema.ts +11 -0
  138. package/src/lib/backup-cron.ts +118 -0
  139. package/src/lib/backup-database.ts +41 -0
  140. package/src/lib/backup-feature.ts +2 -0
  141. package/src/lib/backup-paths.ts +7 -0
  142. package/src/lib/backup-policy-schema.ts +85 -0
  143. package/src/lib/backup-resource-schema.ts +12 -0
  144. package/src/lib/backup-restore-schema.ts +15 -0
  145. package/src/lib/backup-storage-schema.ts +55 -0
  146. package/src/lib/deferred-module.ts +38 -0
  147. package/src/lib/deployment-logs-query.ts +63 -0
  148. package/src/lib/display-name.ts +7 -0
  149. package/src/lib/error-message.ts +119 -0
  150. package/src/lib/form-errors.ts +10 -0
  151. package/src/lib/internal-redirect.ts +25 -0
  152. package/src/lib/manual-deploy-options-query.ts +21 -0
  153. package/src/lib/notification-preferences.ts +43 -0
  154. package/src/lib/onboarding.ts +11 -0
  155. package/src/lib/operation-logs-query.ts +51 -0
  156. package/src/lib/page-head.ts +14 -0
  157. package/src/lib/project-context-query.ts +257 -0
  158. package/src/lib/project-creation-schema.ts +64 -0
  159. package/src/lib/project-env-schema.ts +23 -0
  160. package/src/lib/project-environments-schema.ts +56 -0
  161. package/src/lib/project-routing-schema.ts +94 -0
  162. package/src/lib/project-runtime-config.ts +131 -0
  163. package/src/lib/project-settings-schema.ts +75 -0
  164. package/src/lib/public-api-schema.ts +128 -0
  165. package/src/lib/public-error.ts +337 -0
  166. package/src/lib/query.ts +13 -0
  167. package/src/lib/repository-selection-query.ts +11 -0
  168. package/src/lib/resource-cache.ts +30 -0
  169. package/src/lib/runtime-id.ts +19 -0
  170. package/src/lib/search.ts +14 -0
  171. package/src/lib/server-context-query.ts +43 -0
  172. package/src/lib/server-name-schema.ts +11 -0
  173. package/src/lib/settings-conflict.ts +45 -0
  174. package/src/lib/settings-version.ts +7 -0
  175. package/src/lib/team-context-query.ts +126 -0
  176. package/src/lib/team-context-schema.ts +8 -0
  177. package/src/lib/team-forms-schema.ts +45 -0
  178. package/src/lib/team-role.ts +13 -0
  179. package/src/lib/team-settings-schema.ts +48 -0
  180. package/src/lib/urls.ts +3 -0
  181. package/src/lib/use-avatar-input.ts +61 -0
  182. package/src/lib/use-refresh-error.ts +18 -0
  183. package/src/lib/use-settings-version.ts +23 -0
  184. package/src/lib/utils.ts +6 -0
  185. package/src/lib/validation.ts +5 -0
  186. package/src/lib/workspace-updates.ts +53 -0
  187. package/src/package/config.ts +10 -0
  188. package/src/package/env.mjs +54 -0
  189. package/src/package/migrate-api.d.ts +5 -0
  190. package/src/package/migrate.ts +56 -0
  191. package/src/package/plugins.ts +141 -0
  192. package/src/package/sqlite.mjs +2 -0
  193. package/src/package/vite-api.d.ts +5 -0
  194. package/src/package/vite.ts +324 -0
  195. package/src/package/watch-guard.cjs +26 -0
  196. package/src/plugins/client.ts +57 -0
  197. package/src/plugins/contract.ts +188 -0
  198. package/src/plugins/default.server.ts +4 -0
  199. package/src/plugins/default.ui.ts +4 -0
  200. package/src/plugins/operations.ts +45 -0
  201. package/src/plugins/server.ts +197 -0
  202. package/src/plugins/version.ts +1 -0
  203. package/src/routeTree.gen.ts +1275 -0
  204. package/src/router.tsx +46 -0
  205. package/src/routes/$teamSlug.index.tsx +53 -0
  206. package/src/routes/$teamSlug.new-project.tsx +13 -0
  207. package/src/routes/$teamSlug.projects.$projectName.backups.$backupId.tsx +40 -0
  208. package/src/routes/$teamSlug.projects.$projectName.backups.tsx +82 -0
  209. package/src/routes/$teamSlug.projects.$projectName.deployments.$deploymentId.tsx +52 -0
  210. package/src/routes/$teamSlug.projects.$projectName.deployments.tsx +86 -0
  211. package/src/routes/$teamSlug.projects.$projectName.logs.tsx +55 -0
  212. package/src/routes/$teamSlug.projects.$projectName.settings.tsx +51 -0
  213. package/src/routes/$teamSlug.projects.$projectName.tsx +106 -0
  214. package/src/routes/$teamSlug.projects.tsx +40 -0
  215. package/src/routes/$teamSlug.servers.$serverId.tsx +31 -0
  216. package/src/routes/$teamSlug.servers.index.tsx +59 -0
  217. package/src/routes/$teamSlug.servers.tsx +7 -0
  218. package/src/routes/$teamSlug.settings.tsx +56 -0
  219. package/src/routes/$teamSlug.tsx +86 -0
  220. package/src/routes/[.]well-known.oauth-authorization-server.api.auth.ts +13 -0
  221. package/src/routes/[.]well-known.oauth-protected-resource.mcp.ts +6 -0
  222. package/src/routes/__root.tsx +155 -0
  223. package/src/routes/admin.tsx +55 -0
  224. package/src/routes/api/agents/certificates.ts +21 -0
  225. package/src/routes/api/agents/credentials/rotate.ts +22 -0
  226. package/src/routes/api/agents/heartbeat.ts +42 -0
  227. package/src/routes/api/agents/job-logs.ts +22 -0
  228. package/src/routes/api/agents/jobs/complete.ts +22 -0
  229. package/src/routes/api/agents/jobs/next.ts +29 -0
  230. package/src/routes/api/agents/jobs/progress.ts +32 -0
  231. package/src/routes/api/agents/jobs/status.ts +22 -0
  232. package/src/routes/api/agents/register.ts +45 -0
  233. package/src/routes/api/agents/runtime-logs.ts +22 -0
  234. package/src/routes/api/agents/switch.ts +25 -0
  235. package/src/routes/api/auth/$.ts +38 -0
  236. package/src/routes/api/cron/backups.ts +33 -0
  237. package/src/routes/api/cron/maintenance.ts +30 -0
  238. package/src/routes/api/github/install.callback.ts +51 -0
  239. package/src/routes/api/github/install.ts +41 -0
  240. package/src/routes/api/github/webhook.ts +11 -0
  241. package/src/routes/api/health.ts +19 -0
  242. package/src/routes/api/install/agent.$token.ts +50 -0
  243. package/src/routes/api/install/agent.binary.ts +83 -0
  244. package/src/routes/api/install/agent.checksum.ts +45 -0
  245. package/src/routes/api/install/agent.ts +26 -0
  246. package/src/routes/api/plugins/$.ts +11 -0
  247. package/src/routes/api/v1/$.ts +15 -0
  248. package/src/routes/api/workspaces/updates.ts +8 -0
  249. package/src/routes/auth.login.tsx +50 -0
  250. package/src/routes/deployments.$deploymentId.unavailable.tsx +21 -0
  251. package/src/routes/index.tsx +23 -0
  252. package/src/routes/invites.$token.tsx +35 -0
  253. package/src/routes/mcp.ts +13 -0
  254. package/src/routes/oauth.consent.tsx +54 -0
  255. package/src/routes/onboarding.tsx +37 -0
  256. package/src/routes/onboarding.workspace.tsx +44 -0
  257. package/src/routes/settings.tsx +30 -0
  258. package/src/routes/uploads/avatars/$fileName.ts +44 -0
  259. package/src/server/admin/access.ts +67 -0
  260. package/src/server/admin/functions.ts +122 -0
  261. package/src/server/admin/service.ts +260 -0
  262. package/src/server/agents/backup-state.ts +210 -0
  263. package/src/server/agents/certificates.ts +96 -0
  264. package/src/server/agents/claims.ts +116 -0
  265. package/src/server/agents/cleanup.ts +52 -0
  266. package/src/server/agents/compatibility.ts +116 -0
  267. package/src/server/agents/completion.ts +83 -0
  268. package/src/server/agents/container-cleanup-state.ts +51 -0
  269. package/src/server/agents/deployment-state.ts +111 -0
  270. package/src/server/agents/job-payloads.ts +159 -0
  271. package/src/server/agents/jobs.ts +1293 -0
  272. package/src/server/agents/protocol.ts +400 -0
  273. package/src/server/agents/reconciliation.ts +43 -0
  274. package/src/server/agents/runtime-logs.ts +337 -0
  275. package/src/server/agents/runtime-state.ts +125 -0
  276. package/src/server/api/admission.ts +12 -0
  277. package/src/server/api/authorization.ts +115 -0
  278. package/src/server/api/credentials.ts +569 -0
  279. package/src/server/api/cursors.ts +72 -0
  280. package/src/server/api/errors.ts +24 -0
  281. package/src/server/api/functions.ts +60 -0
  282. package/src/server/api/handler.ts +274 -0
  283. package/src/server/api/mutation-operations.ts +913 -0
  284. package/src/server/api/operation-context.ts +139 -0
  285. package/src/server/api/operation-contract.ts +1099 -0
  286. package/src/server/api/operations.ts +64 -0
  287. package/src/server/api/read-operations.ts +891 -0
  288. package/src/server/api/retention.ts +33 -0
  289. package/src/server/auth/access.ts +109 -0
  290. package/src/server/auth/auth-methods.ts +28 -0
  291. package/src/server/auth/better-auth.ts +518 -0
  292. package/src/server/auth/context.ts +28 -0
  293. package/src/server/auth/email-change.ts +47 -0
  294. package/src/server/auth/email-delivery.ts +51 -0
  295. package/src/server/auth/provider-identity.ts +19 -0
  296. package/src/server/auth/provider-tokens.ts +27 -0
  297. package/src/server/auth/session.server.ts +19 -0
  298. package/src/server/backups/database-commands.ts +40 -0
  299. package/src/server/backups/destination.ts +12 -0
  300. package/src/server/backups/feature.ts +6 -0
  301. package/src/server/backups/policy-validation.ts +51 -0
  302. package/src/server/backups/queue-state.ts +137 -0
  303. package/src/server/backups/service.ts +918 -0
  304. package/src/server/backups/storage-functions.ts +42 -0
  305. package/src/server/backups/storage-state.ts +67 -0
  306. package/src/server/backups/storage.ts +237 -0
  307. package/src/server/config.ts +274 -0
  308. package/src/server/db/atomic.ts +16 -0
  309. package/src/server/db/d1-connection.ts +83 -0
  310. package/src/server/db/errors.ts +17 -0
  311. package/src/server/db/index.cloudflare.ts +15 -0
  312. package/src/server/db/index.ts +35 -0
  313. package/src/server/db/libsql-connection.ts +72 -0
  314. package/src/server/db/log-schema.ts +102 -0
  315. package/src/server/db/migrate-libsql.ts +29 -0
  316. package/src/server/db/migrate-sqlite.ts +47 -0
  317. package/src/server/db/migration-history.ts +12 -0
  318. package/src/server/db/migrations-logs/0000_initial.sql +48 -0
  319. package/src/server/db/migrations-logs/0001_runtime_log_order.sql +2 -0
  320. package/src/server/db/migrations-logs/meta/0000_snapshot.json +327 -0
  321. package/src/server/db/migrations-logs/meta/0001_snapshot.json +329 -0
  322. package/src/server/db/migrations-logs/meta/_journal.json +20 -0
  323. package/src/server/db/migrations-sqlite/0000_initial.sql +894 -0
  324. package/src/server/db/migrations-sqlite/0001_agent_indexes.sql +5 -0
  325. package/src/server/db/migrations-sqlite/meta/0000_snapshot.json +5816 -0
  326. package/src/server/db/migrations-sqlite/meta/0001_snapshot.json +5841 -0
  327. package/src/server/db/migrations-sqlite/meta/_journal.json +20 -0
  328. package/src/server/db/oauth-schema.ts +216 -0
  329. package/src/server/db/remote-config.ts +47 -0
  330. package/src/server/db/schema.ts +1218 -0
  331. package/src/server/db/sqlite-connection.ts +94 -0
  332. package/src/server/db/sqlite-files.ts +27 -0
  333. package/src/server/db/sqlite-params.ts +19 -0
  334. package/src/server/db/worker-connection.ts +47 -0
  335. package/src/server/deleted-resources.ts +133 -0
  336. package/src/server/deletion-recovery.ts +272 -0
  337. package/src/server/email/layout.tsx +85 -0
  338. package/src/server/email/notification-template.tsx +62 -0
  339. package/src/server/email/otp-template.tsx +48 -0
  340. package/src/server/email/service.ts +251 -0
  341. package/src/server/email/team-invite-template.tsx +59 -0
  342. package/src/server/email/theme.ts +10 -0
  343. package/src/server/github/http.ts +14 -0
  344. package/src/server/github/install-state.ts +48 -0
  345. package/src/server/github/service.ts +831 -0
  346. package/src/server/github/webhook-handler.ts +198 -0
  347. package/src/server/github/webhooks.ts +205 -0
  348. package/src/server/http/auth.ts +11 -0
  349. package/src/server/http/error-diagnostics.ts +66 -0
  350. package/src/server/http/plugin-request.ts +15 -0
  351. package/src/server/http/proxy-request.ts +53 -0
  352. package/src/server/http/public-errors.ts +48 -0
  353. package/src/server/http/rate-limit.ts +59 -0
  354. package/src/server/http/request-body.ts +54 -0
  355. package/src/server/http/request-ip.ts +54 -0
  356. package/src/server/http/security-headers.ts +36 -0
  357. package/src/server/install/agent-binary.ts +230 -0
  358. package/src/server/install/agent-script.ts +565 -0
  359. package/src/server/install/release-binary.ts +65 -0
  360. package/src/server/logs/budget.ts +72 -0
  361. package/src/server/logs/cleanup.ts +64 -0
  362. package/src/server/logs/deployments.ts +98 -0
  363. package/src/server/logs/operations.ts +145 -0
  364. package/src/server/logs/retention.ts +39 -0
  365. package/src/server/maintenance.ts +61 -0
  366. package/src/server/mcp/connections.ts +358 -0
  367. package/src/server/mcp/functions.ts +63 -0
  368. package/src/server/mcp/handler.ts +295 -0
  369. package/src/server/mcp/oauth-discovery.cloudflare.ts +6 -0
  370. package/src/server/mcp/oauth-discovery.ts +8 -0
  371. package/src/server/mcp/oauth-provider.ts +220 -0
  372. package/src/server/mcp/urls.ts +20 -0
  373. package/src/server/notifications/functions.ts +51 -0
  374. package/src/server/notifications/observe.ts +303 -0
  375. package/src/server/notifications/outbox.ts +335 -0
  376. package/src/server/notifications/preferences.ts +131 -0
  377. package/src/server/notifications/query.ts +16 -0
  378. package/src/server/plugins/scope.ts +17 -0
  379. package/src/server/plugins/server-context.ts +157 -0
  380. package/src/server/plugins/services.server.ts +10 -0
  381. package/src/server/plugins/workspace-access.ts +74 -0
  382. package/src/server/projects/backup-policy-state.ts +68 -0
  383. package/src/server/projects/backups.ts +219 -0
  384. package/src/server/projects/compose-metadata.ts +174 -0
  385. package/src/server/projects/configuration-state.ts +90 -0
  386. package/src/server/projects/context.ts +909 -0
  387. package/src/server/projects/creation-state.ts +46 -0
  388. package/src/server/projects/deletion-state.ts +69 -0
  389. package/src/server/projects/deployment-config.ts +139 -0
  390. package/src/server/projects/deployment-logs.ts +97 -0
  391. package/src/server/projects/deployments.ts +1123 -0
  392. package/src/server/projects/domain-state.ts +71 -0
  393. package/src/server/projects/environment-branches.ts +75 -0
  394. package/src/server/projects/environment-state.ts +158 -0
  395. package/src/server/projects/environments.ts +424 -0
  396. package/src/server/projects/functions.ts +748 -0
  397. package/src/server/projects/generated-hostname.ts +77 -0
  398. package/src/server/projects/hostnames.ts +244 -0
  399. package/src/server/projects/mappers.ts +457 -0
  400. package/src/server/projects/names.ts +41 -0
  401. package/src/server/projects/project-env-vars.ts +28 -0
  402. package/src/server/projects/queue-state.ts +118 -0
  403. package/src/server/projects/repository.ts +368 -0
  404. package/src/server/projects/route-state.ts +37 -0
  405. package/src/server/projects/runtime-logs.ts +97 -0
  406. package/src/server/projects/settings-context.ts +58 -0
  407. package/src/server/projects/settings.ts +738 -0
  408. package/src/server/projects/types.ts +355 -0
  409. package/src/server/realtime/agent-events.ts +32 -0
  410. package/src/server/realtime/node-broadcaster.ts +71 -0
  411. package/src/server/realtime/node-handler.ts +55 -0
  412. package/src/server/realtime/service.ts +125 -0
  413. package/src/server/realtime/workspace-object.cloudflare.ts +62 -0
  414. package/src/server/realtime/workspace-object.ts +3 -0
  415. package/src/server/resources/color.ts +13 -0
  416. package/src/server/runtime/background.ts +21 -0
  417. package/src/server/runtime/node-asset-cache-plugin.ts +12 -0
  418. package/src/server/runtime/node-scheduler-plugin.ts +17 -0
  419. package/src/server/runtime/node-scheduler.ts +51 -0
  420. package/src/server/runtime/platform-env.cloudflare.ts +17 -0
  421. package/src/server/runtime/platform-env.ts +19 -0
  422. package/src/server/runtime/scheduled-task-error.ts +9 -0
  423. package/src/server/scheduled-tasks.ts +28 -0
  424. package/src/server/secrets/crypto.ts +56 -0
  425. package/src/server/servers/functions.ts +233 -0
  426. package/src/server/servers/lifecycle.ts +122 -0
  427. package/src/server/servers/metric-history-schema.ts +24 -0
  428. package/src/server/servers/metric-history.ts +55 -0
  429. package/src/server/servers/monitoring.ts +592 -0
  430. package/src/server/servers/service.ts +1211 -0
  431. package/src/server/servers/status.ts +32 -0
  432. package/src/server/servers/switch.ts +96 -0
  433. package/src/server/settings-version.ts +43 -0
  434. package/src/server/storage/capabilities.ts +6 -0
  435. package/src/server/storage/local-object-store.cloudflare.ts +7 -0
  436. package/src/server/storage/local-object-store.ts +50 -0
  437. package/src/server/storage/object-store.ts +160 -0
  438. package/src/server/teams/creation.ts +51 -0
  439. package/src/server/teams/delete-policy.ts +13 -0
  440. package/src/server/teams/deletion-state.ts +80 -0
  441. package/src/server/teams/functions.ts +324 -0
  442. package/src/server/teams/invitations.ts +38 -0
  443. package/src/server/teams/membership.ts +86 -0
  444. package/src/server/teams/queries.ts +261 -0
  445. package/src/server/teams/service.ts +1127 -0
  446. package/src/server/teams/types.ts +101 -0
  447. package/src/server/uploads/avatar.ts +133 -0
  448. package/src/server/users/deletion-state.ts +93 -0
  449. package/src/server/users/functions.ts +160 -0
  450. package/src/server/users/onboarding.ts +17 -0
  451. package/src/server/users/service.ts +644 -0
  452. package/src/server.ts +55 -0
  453. package/src/styles/shadcn.css +653 -0
  454. package/src/styles/theme.css +76 -0
  455. package/src/styles.css +177 -0
  456. package/wrangler.template.jsonc +37 -0
@@ -0,0 +1,1472 @@
1
+ import { NotificationMute } from '#devpush/features/notifications/email-preferences'
2
+ import { useImageUploads } from '#devpush/components/storage-capabilities'
3
+ import {
4
+ mutationErrorOptions,
5
+ afterMutation,
6
+ } from '#devpush/lib/action-feedback'
7
+ import { showSettingsConflict } from '#devpush/lib/settings-conflict'
8
+ import { CopyButton } from '#devpush/components/copy-button'
9
+ import { invalidateResourceQueries } from '#devpush/lib/resource-cache'
10
+ import {
11
+ projectRoutesSchema,
12
+ projectDomainsSchema,
13
+ } from '#devpush/lib/project-routing-schema'
14
+ import { formErrors } from '#devpush/lib/form-errors'
15
+ import {
16
+ projectSettingsSchema,
17
+ projectValidationErrors,
18
+ } from '#devpush/lib/project-settings-schema'
19
+ import type { FormErrors } from '#devpush/lib/form-errors'
20
+ import { useAvatarInput } from '#devpush/lib/use-avatar-input'
21
+ import { useParams, useRouter } from '@tanstack/react-router'
22
+ import { useQueryClient } from '@tanstack/react-query'
23
+ import {
24
+ ArrowUpRightIcon,
25
+ ChevronsUpDownIcon,
26
+ LoaderIcon,
27
+ Trash2Icon,
28
+ UploadIcon,
29
+ } from 'lucide-react'
30
+ import { useRef, useState } from 'react'
31
+ import { useSettingsVersion } from '#devpush/lib/use-settings-version'
32
+ import { toast } from 'sonner'
33
+
34
+ import {
35
+ AlertDialog,
36
+ AlertDialogAction,
37
+ AlertDialogCancel,
38
+ AlertDialogContent,
39
+ AlertDialogDescription,
40
+ AlertDialogFooter,
41
+ AlertDialogHeader,
42
+ AlertDialogTitle,
43
+ AlertDialogTrigger,
44
+ } from '#devpush/components/ui/alert-dialog'
45
+ import { Button } from '#devpush/components/ui/button'
46
+ import { Checkbox } from '#devpush/components/ui/checkbox'
47
+ import {
48
+ Field,
49
+ FieldContent,
50
+ FieldDescription,
51
+ FieldError,
52
+ FieldGroup,
53
+ FieldLabel,
54
+ } from '#devpush/components/ui/field'
55
+ import { Input } from '#devpush/components/ui/input'
56
+ import { Textarea } from '#devpush/components/ui/textarea'
57
+ import {
58
+ InputGroup,
59
+ InputGroupAddon,
60
+ InputGroupInput,
61
+ } from '#devpush/components/ui/input-group'
62
+ import {
63
+ Tooltip,
64
+ TooltipContent,
65
+ TooltipTrigger,
66
+ } from '#devpush/components/ui/tooltip'
67
+ import {
68
+ SettingsLayout,
69
+ SettingsSection,
70
+ } from '#devpush/components/settings-layout'
71
+ import { ResourceAvatar } from '#devpush/components/resource-avatar'
72
+ import { getPublicErrorMessage } from '#devpush/lib/public-error'
73
+ import { setProjectContextQueryData } from '#devpush/lib/project-context-query'
74
+ import { canManageTeamSettings } from '#devpush/lib/team-role'
75
+ import {
76
+ deleteProjectAction,
77
+ saveProjectDomains,
78
+ saveProjectRoutes,
79
+ saveProjectSettings,
80
+ verifyProjectDomainAction,
81
+ } from '#devpush/server/projects/functions'
82
+ import type {
83
+ ProjectContext,
84
+ ProjectDomain,
85
+ } from '#devpush/server/projects/types'
86
+ import {
87
+ ProjectSettingsActionTooltip as IconActionTooltip,
88
+ ProjectSettingsActions as SettingsActions,
89
+ } from './project-settings-section'
90
+ import {
91
+ getEffectiveProjectRoutes as getEffectiveRouteDefinitions,
92
+ projectSettingsNavigationItems,
93
+ } from './project-settings-utils'
94
+ import { RepositoryPickerDialog } from './repository-picker-dialog'
95
+ import type { ProjectRepository } from './repository-picker-dialog'
96
+ import {
97
+ DomainSettingsTable,
98
+ ManagedIngressWarnings,
99
+ RouteSettingsTable,
100
+ } from './project-routing-settings'
101
+ import type {
102
+ ProjectDomainAlias,
103
+ ProjectRouteDefinition,
104
+ } from './project-routing-settings'
105
+ import { ProjectHeading } from './project-heading'
106
+ import {
107
+ EnvironmentServersSettingsSection,
108
+ EnvironmentSettingsSection,
109
+ EnvironmentVariablesSettingsSection,
110
+ } from './project-environment-settings'
111
+
112
+ const managedComposeCommand = 'docker compose up -d --no-build'
113
+
114
+ export function ProjectSettings({ context }: { context: ProjectContext }) {
115
+ const router = useRouter()
116
+ const imageUploads = useImageUploads()
117
+ const queryClient = useQueryClient()
118
+ const params = useParams({ strict: false })
119
+ const { project } = context
120
+ const canManageLifecycleCommands = canManageTeamSettings(context.team.role)
121
+ const runtimeConfig = project.runtimeConfig
122
+ const initialRoutes = context.routes
123
+ const initialDomains = context.domains.map(mapProjectDomainAlias)
124
+ const [projectName, setProjectName] = useState(project.name)
125
+ const [selectedRepo, setSelectedRepo] = useState<ProjectRepository>({
126
+ fullName: project.githubRepo,
127
+ id: project.githubRepoId,
128
+ installationId: project.githubInstallationId,
129
+ })
130
+ const [savedRepo, setSavedRepo] = useState<ProjectRepository>({
131
+ fullName: project.githubRepo,
132
+ id: project.githubRepoId,
133
+ installationId: project.githubInstallationId,
134
+ })
135
+ const [savedAvatarUrl, setSavedAvatarUrl] = useState(project.avatarUrl)
136
+ const {
137
+ dataUrl: avatarDataUrl,
138
+ setDataUrl: setAvatarDataUrl,
139
+ read: readAvatar,
140
+ cancelRead: cancelAvatarRead,
141
+ pending: isReadingAvatar,
142
+ } = useAvatarInput()
143
+ const [deleteAvatar, setDeleteAvatar] = useState(false)
144
+ const avatarInputRef = useRef<HTMLInputElement>(null)
145
+ const [composePath, setComposePath] = useState(runtimeConfig.path ?? '')
146
+ const [releaseCommand, setReleaseCommand] = useState(
147
+ runtimeConfig.releaseCommand ?? '',
148
+ )
149
+ const [releaseService, setReleaseService] = useState(
150
+ runtimeConfig.releaseService ?? '',
151
+ )
152
+ const [readinessPath, setReadinessPath] = useState(
153
+ runtimeConfig.readinessPath ?? '',
154
+ )
155
+ const [deployCommand, setDeployCommand] = useState(
156
+ runtimeConfig.deployCommand ?? '',
157
+ )
158
+ const [rollbackCommand, setRollbackCommand] = useState(
159
+ runtimeConfig.rollbackCommand ?? '',
160
+ )
161
+ const [deployTimeoutSeconds, setDeployTimeoutSeconds] = useState(
162
+ String(runtimeConfig.timeoutSeconds),
163
+ )
164
+ const [routes, setRoutes] =
165
+ useState<Array<ProjectRouteDefinition>>(initialRoutes)
166
+ const [domains, setDomains] =
167
+ useState<Array<ProjectDomainAlias>>(initialDomains)
168
+ const [savedRoutes, setSavedRoutes] =
169
+ useState<Array<ProjectRouteDefinition>>(initialRoutes)
170
+ const [savedDomains, setSavedDomains] =
171
+ useState<Array<ProjectDomainAlias>>(initialDomains)
172
+ const defaultReleaseService =
173
+ routes.find((route) => route.service.trim())?.service.trim() ??
174
+ (context.composeMetadata?.services.length === 1
175
+ ? context.composeMetadata.services[0]
176
+ : null)
177
+ const [savedSettings, setSavedSettings] = useState({
178
+ projectName: project.name,
179
+ composePath: runtimeConfig.path ?? '',
180
+ releaseCommand: runtimeConfig.releaseCommand ?? '',
181
+ releaseService: runtimeConfig.releaseService ?? '',
182
+ readinessPath: runtimeConfig.readinessPath ?? '',
183
+ deployCommand: runtimeConfig.deployCommand ?? '',
184
+ rollbackCommand: runtimeConfig.rollbackCommand ?? '',
185
+ deployTimeoutSeconds: String(runtimeConfig.timeoutSeconds),
186
+ })
187
+ const [generalErrors, setGeneralErrors] = useState<FormErrors>({})
188
+ const [buildErrors, setBuildErrors] = useState<FormErrors>({})
189
+ const [isSaving, setIsSaving] = useState(false)
190
+ const [verifyingDomainId, setVerifyingDomainId] = useState<string | null>(
191
+ null,
192
+ )
193
+ const [isDeletingProject, setIsDeletingProject] = useState(false)
194
+ const [deleteOpen, setDeleteOpen] = useState(false)
195
+ const [projectDeleted, setProjectDeleted] = useState(false)
196
+ const [deleteProjectConfirmation, setDeleteProjectConfirmation] = useState('')
197
+ const [removeProjectDeployments, setRemoveProjectDeployments] = useState(true)
198
+ const hasGeneralChanges =
199
+ projectName !== savedSettings.projectName ||
200
+ selectedRepo.fullName !== savedRepo.fullName ||
201
+ selectedRepo.installationId !== savedRepo.installationId ||
202
+ Boolean(avatarDataUrl) ||
203
+ deleteAvatar
204
+ const hasBuildChanges =
205
+ composePath !== savedSettings.composePath ||
206
+ releaseCommand !== savedSettings.releaseCommand ||
207
+ releaseService !== savedSettings.releaseService ||
208
+ readinessPath !== savedSettings.readinessPath ||
209
+ deployCommand !== savedSettings.deployCommand ||
210
+ rollbackCommand !== savedSettings.rollbackCommand ||
211
+ deployTimeoutSeconds !== savedSettings.deployTimeoutSeconds
212
+ const hasRouteChanges = JSON.stringify(routes) !== JSON.stringify(savedRoutes)
213
+ const hasDomainChanges =
214
+ JSON.stringify(domains) !== JSON.stringify(savedDomains)
215
+
216
+ const generalVersion = useSettingsVersion({
217
+ version: project.settingsVersion,
218
+ dirty: hasGeneralChanges || isReadingAvatar,
219
+ saving: isSaving,
220
+ refresh: () => {
221
+ const repo = {
222
+ fullName: project.githubRepo,
223
+ id: project.githubRepoId,
224
+ installationId: project.githubInstallationId,
225
+ }
226
+ setProjectName(project.name)
227
+ setSelectedRepo(repo)
228
+ setSavedRepo(repo)
229
+ setSavedAvatarUrl(project.avatarUrl)
230
+ setSavedSettings((current) => ({ ...current, projectName: project.name }))
231
+ setGeneralErrors({})
232
+ },
233
+ })
234
+ const buildVersion = useSettingsVersion({
235
+ version: project.settingsVersion,
236
+ dirty: hasBuildChanges,
237
+ saving: isSaving,
238
+ refresh: () => {
239
+ const build = {
240
+ composePath: runtimeConfig.path ?? '',
241
+ releaseCommand: runtimeConfig.releaseCommand ?? '',
242
+ releaseService: runtimeConfig.releaseService ?? '',
243
+ readinessPath: runtimeConfig.readinessPath ?? '',
244
+ deployCommand: runtimeConfig.deployCommand ?? '',
245
+ rollbackCommand: runtimeConfig.rollbackCommand ?? '',
246
+ deployTimeoutSeconds: String(runtimeConfig.timeoutSeconds),
247
+ }
248
+ setComposePath(build.composePath)
249
+ setReleaseCommand(build.releaseCommand)
250
+ setReleaseService(build.releaseService)
251
+ setReadinessPath(build.readinessPath)
252
+ setDeployCommand(build.deployCommand)
253
+ setRollbackCommand(build.rollbackCommand)
254
+ setDeployTimeoutSeconds(build.deployTimeoutSeconds)
255
+ setSavedSettings((current) => ({ ...current, ...build }))
256
+ setBuildErrors({})
257
+ },
258
+ })
259
+ const routesVersion = useSettingsVersion({
260
+ version: project.settingsVersion,
261
+ dirty: hasRouteChanges,
262
+ saving: isSaving,
263
+ refresh: () => {
264
+ setRoutes(initialRoutes)
265
+ setSavedRoutes(initialRoutes)
266
+ },
267
+ })
268
+ const domainsVersion = useSettingsVersion({
269
+ version: project.settingsVersion,
270
+ dirty: hasDomainChanges,
271
+ saving: isSaving,
272
+ refresh: () => {
273
+ setDomains(initialDomains)
274
+ setSavedDomains(initialDomains)
275
+ },
276
+ })
277
+
278
+ function resetGeneralSettings() {
279
+ setGeneralErrors({})
280
+ setProjectName(savedSettings.projectName)
281
+ setSelectedRepo(savedRepo)
282
+ setAvatarDataUrl(null)
283
+ setDeleteAvatar(false)
284
+ }
285
+
286
+ function resetBuildSettings() {
287
+ setBuildErrors({})
288
+ setComposePath(savedSettings.composePath)
289
+ setReleaseCommand(savedSettings.releaseCommand)
290
+ setReleaseService(savedSettings.releaseService)
291
+ setReadinessPath(savedSettings.readinessPath)
292
+ setDeployCommand(savedSettings.deployCommand)
293
+ setRollbackCommand(savedSettings.rollbackCommand)
294
+ setDeployTimeoutSeconds(savedSettings.deployTimeoutSeconds)
295
+ }
296
+
297
+ const [routeErrors, setRouteErrors] = useState<FormErrors>({})
298
+ const [domainErrors, setDomainErrors] = useState<FormErrors>({})
299
+
300
+ function resetRoutes() {
301
+ setRouteErrors({})
302
+ setRoutes(savedRoutes)
303
+ }
304
+
305
+ function resetDomains() {
306
+ setDomainErrors({})
307
+ setDomains(savedDomains)
308
+ }
309
+
310
+ async function saveSettings({
311
+ onSuccess,
312
+ message = 'Project settings saved.',
313
+ includeGeneralChanges = false,
314
+ includeBuildChanges = false,
315
+ }: {
316
+ onSuccess?: (nextContext: ProjectContext) => void | Promise<void>
317
+ message?: string
318
+ includeGeneralChanges?: boolean
319
+ includeBuildChanges?: boolean
320
+ } = {}) {
321
+ if (isSaving || (includeGeneralChanges && isReadingAvatar)) return
322
+ const setErrors = includeGeneralChanges ? setGeneralErrors : setBuildErrors
323
+ setErrors({})
324
+
325
+ try {
326
+ const settings = {
327
+ projectName: includeGeneralChanges
328
+ ? projectName
329
+ : savedSettings.projectName,
330
+ composePath: includeBuildChanges
331
+ ? composePath
332
+ : savedSettings.composePath,
333
+ releaseCommand: includeBuildChanges
334
+ ? releaseCommand
335
+ : savedSettings.releaseCommand,
336
+ releaseService: includeBuildChanges
337
+ ? releaseService
338
+ : savedSettings.releaseService,
339
+ readinessPath: includeBuildChanges
340
+ ? readinessPath
341
+ : savedSettings.readinessPath,
342
+ deployCommand: includeBuildChanges
343
+ ? deployCommand
344
+ : savedSettings.deployCommand,
345
+ rollbackCommand: includeBuildChanges
346
+ ? rollbackCommand
347
+ : savedSettings.rollbackCommand,
348
+ deployTimeoutSeconds: includeBuildChanges
349
+ ? deployTimeoutSeconds
350
+ : savedSettings.deployTimeoutSeconds,
351
+ }
352
+ const repo = includeGeneralChanges ? selectedRepo : savedRepo
353
+
354
+ const parsed = projectSettingsSchema.safeParse({
355
+ projectId: project.id,
356
+ section: includeGeneralChanges ? 'general' : 'build',
357
+ settingsVersion: (includeGeneralChanges ? generalVersion : buildVersion)
358
+ .current,
359
+ teamSlug: typeof params.teamSlug === 'string' ? params.teamSlug : '',
360
+ currentProjectName: project.name,
361
+ projectName: settings.projectName,
362
+ repoFullName: repo.fullName,
363
+ installationId: repo.installationId,
364
+ avatarDataUrl: includeGeneralChanges ? avatarDataUrl : null,
365
+ deleteAvatar: includeGeneralChanges ? deleteAvatar : false,
366
+ runtimeConfig: {
367
+ type: 'compose',
368
+ path: settings.composePath.trim() || null,
369
+ releaseCommand: settings.releaseCommand.trim() || null,
370
+ releaseService:
371
+ !settings.releaseCommand.trim() || !settings.releaseService.trim()
372
+ ? null
373
+ : settings.releaseService.trim(),
374
+ readinessPath: settings.readinessPath.trim() || null,
375
+ deployCommand: settings.deployCommand.trim() || null,
376
+ rollbackCommand: settings.rollbackCommand.trim() || null,
377
+ timeoutSeconds: Number(settings.deployTimeoutSeconds),
378
+ },
379
+ })
380
+ if (!parsed.success) {
381
+ const errors = projectValidationErrors(parsed.error.issues)
382
+ if (errors.form) toast.error(errors.form)
383
+ setErrors(errors)
384
+ const id = projectSettingsFieldIds[Object.keys(errors)[0] ?? '']
385
+ if (id) document.getElementById(id)?.focus()
386
+ return
387
+ }
388
+ setIsSaving(true)
389
+ const result = await saveProjectSettings({ data: parsed.data })
390
+ if (result.fieldErrors) {
391
+ if (result.fieldErrors.form) toast.error(result.fieldErrors.form)
392
+ setErrors(result.fieldErrors)
393
+ return
394
+ }
395
+ const nextContext = result.context
396
+ const savedVersion = includeGeneralChanges ? generalVersion : buildVersion
397
+ savedVersion.current = nextContext.project.settingsVersion
398
+ await setProjectContextQueryData(queryClient, nextContext, 'settings')
399
+ await onSuccess?.(nextContext)
400
+ toast.success(message)
401
+ } catch (error) {
402
+ if (showSettingsConflict(error)) return
403
+ toast.error(
404
+ getPublicErrorMessage(
405
+ error,
406
+ 'Unable to save settings. Please try again.',
407
+ ),
408
+ ...mutationErrorOptions(error),
409
+ )
410
+ } finally {
411
+ setIsSaving(false)
412
+ }
413
+ }
414
+
415
+ async function saveGeneralSettings() {
416
+ if (!hasGeneralChanges) return
417
+ await saveSettings({
418
+ includeGeneralChanges: true,
419
+ onSuccess: async (nextContext) => {
420
+ setSavedSettings((current) => ({
421
+ ...current,
422
+ projectName: nextContext.project.name,
423
+ }))
424
+ setProjectName(nextContext.project.name)
425
+ setSavedRepo(selectedRepo)
426
+ setSavedAvatarUrl(nextContext.project.avatarUrl)
427
+ setAvatarDataUrl(null)
428
+ setDeleteAvatar(false)
429
+ if (nextContext.project.name !== project.name) {
430
+ await afterMutation(
431
+ () =>
432
+ router.navigate({
433
+ to: '/$teamSlug/projects/$projectName/settings',
434
+ params: {
435
+ teamSlug: nextContext.team.slug,
436
+ projectName: nextContext.project.name,
437
+ },
438
+ }),
439
+ 'The action completed, but opening the next page failed. Please reload.',
440
+ )
441
+ }
442
+ },
443
+ })
444
+ }
445
+
446
+ async function saveBuildSettings() {
447
+ if (!hasBuildChanges) return
448
+ await saveSettings({
449
+ includeBuildChanges: true,
450
+ onSuccess: async (nextContext) => {
451
+ const nextBuildSettings = {
452
+ composePath: nextContext.project.runtimeConfig.path ?? '',
453
+ releaseCommand:
454
+ nextContext.project.runtimeConfig.releaseCommand ?? '',
455
+ releaseService:
456
+ nextContext.project.runtimeConfig.releaseService ?? '',
457
+ readinessPath: nextContext.project.runtimeConfig.readinessPath ?? '',
458
+ deployCommand: nextContext.project.runtimeConfig.deployCommand ?? '',
459
+ rollbackCommand:
460
+ nextContext.project.runtimeConfig.rollbackCommand ?? '',
461
+ deployTimeoutSeconds: String(
462
+ nextContext.project.runtimeConfig.timeoutSeconds,
463
+ ),
464
+ }
465
+
466
+ setSavedSettings((current) => ({
467
+ ...current,
468
+ ...nextBuildSettings,
469
+ }))
470
+ setComposePath(nextBuildSettings.composePath)
471
+ setReleaseCommand(nextBuildSettings.releaseCommand)
472
+ setReleaseService(nextBuildSettings.releaseService)
473
+ setReadinessPath(nextBuildSettings.readinessPath)
474
+ setDeployCommand(nextBuildSettings.deployCommand)
475
+ setRollbackCommand(nextBuildSettings.rollbackCommand)
476
+ setDeployTimeoutSeconds(nextBuildSettings.deployTimeoutSeconds)
477
+ },
478
+ })
479
+ }
480
+
481
+ async function deleteCurrentProject() {
482
+ if (
483
+ isDeletingProject ||
484
+ projectDeleted ||
485
+ deleteProjectConfirmation !== project.name ||
486
+ !['owner', 'admin'].includes(context.team.role)
487
+ )
488
+ return
489
+
490
+ const teamSlug = typeof params.teamSlug === 'string' ? params.teamSlug : ''
491
+ setIsDeletingProject(true)
492
+
493
+ try {
494
+ await deleteProjectAction({
495
+ data: {
496
+ teamSlug,
497
+ projectName: project.name,
498
+ confirmationName: deleteProjectConfirmation,
499
+ removeDeployments: removeProjectDeployments,
500
+ },
501
+ })
502
+ await invalidateResourceQueries(queryClient, teamSlug)
503
+ setProjectDeleted(true)
504
+ setDeleteOpen(false)
505
+ toast.success('Project deleted.')
506
+ await afterMutation(
507
+ () =>
508
+ router.navigate({
509
+ to: '/$teamSlug/projects',
510
+ params: { teamSlug },
511
+ }),
512
+ 'The action completed, but opening the next page failed. Please reload.',
513
+ )
514
+ } catch (error) {
515
+ if (showSettingsConflict(error)) return
516
+ toast.error(
517
+ getPublicErrorMessage(
518
+ error,
519
+ 'Unable to delete project. Please try again.',
520
+ ),
521
+ ...mutationErrorOptions(error),
522
+ )
523
+ } finally {
524
+ setIsDeletingProject(false)
525
+ }
526
+ }
527
+
528
+ async function chooseAvatar(file: File | undefined) {
529
+ if (!file) return
530
+ cancelAvatarRead()
531
+ setGeneralErrors((current) => ({ ...current, avatarDataUrl: undefined }))
532
+
533
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
534
+ setGeneralErrors((current) => ({
535
+ ...current,
536
+ avatarDataUrl: 'Upload a PNG, JPEG, or WebP image.',
537
+ }))
538
+ return
539
+ }
540
+
541
+ if (file.size > 2 * 1024 * 1024) {
542
+ setGeneralErrors((current) => ({
543
+ ...current,
544
+ avatarDataUrl: 'Avatars must be 2 MB or smaller.',
545
+ }))
546
+ return
547
+ }
548
+
549
+ try {
550
+ const dataUrl = await readAvatar(file)
551
+ if (dataUrl === null) return
552
+ setAvatarDataUrl(dataUrl)
553
+ setDeleteAvatar(false)
554
+ } catch {
555
+ setGeneralErrors((current) => ({
556
+ ...current,
557
+ avatarDataUrl:
558
+ 'Unable to read this image. Please choose it again or try another file.',
559
+ }))
560
+ }
561
+ }
562
+
563
+ const avatarUrl = deleteAvatar
564
+ ? undefined
565
+ : (avatarDataUrl ?? savedAvatarUrl ?? undefined)
566
+ const selectedRepoOwner =
567
+ selectedRepo.fullName.split('/')[0] ?? selectedRepo.fullName
568
+
569
+ async function saveRoutes() {
570
+ if (isSaving || verifyingDomainId || !hasRouteChanges) return
571
+ const parsed = projectRoutesSchema.safeParse({
572
+ projectId: project.id,
573
+ settingsVersion: routesVersion.current,
574
+ teamSlug: params.teamSlug,
575
+ projectName: project.name,
576
+ routes,
577
+ })
578
+ const errors: FormErrors = parsed.success
579
+ ? {}
580
+ : formErrors(parsed.error.issues)
581
+ routes.forEach((route, index) => {
582
+ if (
583
+ route.environmentId &&
584
+ !context.environments.some((env) => env.id === route.environmentId)
585
+ )
586
+ errors[`routes.${index}.environmentId`] = 'Select a valid environment.'
587
+ })
588
+ if (errors.form) toast.error(errors.form)
589
+ setRouteErrors(errors)
590
+ if (!parsed.success || Object.keys(errors).length) {
591
+ const firstField = Object.keys(errors)[0]
592
+ if (firstField) document.getElementById(firstField)?.focus()
593
+ return
594
+ }
595
+ setIsSaving(true)
596
+
597
+ try {
598
+ const nextContext = await saveProjectRoutes({
599
+ data: {
600
+ projectId: project.id,
601
+ settingsVersion: routesVersion.current,
602
+ teamSlug: typeof params.teamSlug === 'string' ? params.teamSlug : '',
603
+ projectName: project.name,
604
+ routes: parsed.data.routes,
605
+ },
606
+ })
607
+ routesVersion.current = nextContext.project.settingsVersion
608
+ await setProjectContextQueryData(queryClient, nextContext, 'settings')
609
+ const aliases = nextContext.domains.map(mapProjectDomainAlias)
610
+ setSavedRoutes(nextContext.routes)
611
+ setRoutes(nextContext.routes)
612
+ setSavedDomains(aliases)
613
+ setDomains(
614
+ hasDomainChanges
615
+ ? normalizeDomainsForRoutes(domains, nextContext.routes)
616
+ : aliases,
617
+ )
618
+ toast.success('Routes saved.')
619
+ } catch (error) {
620
+ if (showSettingsConflict(error)) return
621
+ toast.error(
622
+ getPublicErrorMessage(
623
+ error,
624
+ 'Unable to save routes. Please try again.',
625
+ ),
626
+ ...mutationErrorOptions(error),
627
+ )
628
+ } finally {
629
+ setIsSaving(false)
630
+ }
631
+ }
632
+
633
+ async function saveDomains() {
634
+ if (isSaving || verifyingDomainId || !hasDomainChanges) return
635
+ const nextDomains = normalizeDomainsForRoutes(domains, savedRoutes)
636
+ const parsed = projectDomainsSchema.safeParse({
637
+ projectId: project.id,
638
+ settingsVersion: domainsVersion.current,
639
+ teamSlug: params.teamSlug,
640
+ projectName: project.name,
641
+ domains: nextDomains.map((domain) => ({
642
+ host: domain.host,
643
+ environmentId: domain.environment,
644
+ basePath: domain.baseRoute,
645
+ https: domain.https,
646
+ })),
647
+ })
648
+ const errors: FormErrors = parsed.success
649
+ ? {}
650
+ : formErrors(parsed.error.issues)
651
+ domains.forEach((domain, index) => {
652
+ if (!context.environments.some((env) => env.id === domain.environment))
653
+ errors[`domains.${index}.environmentId`] = 'Select a valid environment.'
654
+ })
655
+ if (errors.form) toast.error(errors.form)
656
+ setDomainErrors(errors)
657
+ if (!parsed.success || Object.keys(errors).length) {
658
+ const firstField = Object.keys(errors)[0]
659
+ if (firstField) document.getElementById(firstField)?.focus()
660
+ return
661
+ }
662
+ setIsSaving(true)
663
+
664
+ try {
665
+ const nextContext = await saveProjectDomains({
666
+ data: {
667
+ projectId: project.id,
668
+ settingsVersion: domainsVersion.current,
669
+ teamSlug: typeof params.teamSlug === 'string' ? params.teamSlug : '',
670
+ projectName: project.name,
671
+ domains: parsed.data.domains,
672
+ },
673
+ })
674
+ domainsVersion.current = nextContext.project.settingsVersion
675
+ await setProjectContextQueryData(queryClient, nextContext, 'settings')
676
+ const aliases = nextContext.domains.map(mapProjectDomainAlias)
677
+ setSavedDomains(aliases)
678
+ setDomains(aliases)
679
+ toast.success('Domains saved.')
680
+ } catch (error) {
681
+ if (showSettingsConflict(error)) return
682
+ toast.error(
683
+ getPublicErrorMessage(
684
+ error,
685
+ 'Unable to save domains. Please try again.',
686
+ ),
687
+ ...mutationErrorOptions(error),
688
+ )
689
+ } finally {
690
+ setIsSaving(false)
691
+ }
692
+ }
693
+
694
+ async function verifyDomain(domainId: string) {
695
+ if (isSaving || verifyingDomainId || hasDomainChanges) return
696
+ setDomainErrors({})
697
+ setVerifyingDomainId(domainId)
698
+
699
+ try {
700
+ const result = await verifyProjectDomainAction({
701
+ data: {
702
+ domainId,
703
+ teamSlug: typeof params.teamSlug === 'string' ? params.teamSlug : '',
704
+ projectName: project.name,
705
+ },
706
+ })
707
+ const nextContext = result.context
708
+ await setProjectContextQueryData(queryClient, nextContext, 'settings')
709
+ const aliases = nextContext.domains.map(mapProjectDomainAlias)
710
+ setSavedDomains(aliases)
711
+ setDomains(aliases)
712
+ if (result.verified) {
713
+ toast.success('Domain verified.')
714
+ } else {
715
+ toast.error(result.message ?? 'Domain verification failed.')
716
+ }
717
+ } catch (error) {
718
+ if (showSettingsConflict(error)) return
719
+ toast.error(
720
+ getPublicErrorMessage(
721
+ error,
722
+ 'Unable to verify domain. Please try again.',
723
+ ),
724
+ ...mutationErrorOptions(error),
725
+ )
726
+ } finally {
727
+ setVerifyingDomainId(null)
728
+ }
729
+ }
730
+
731
+ function mapProjectDomainAlias(domain: ProjectDomain): ProjectDomainAlias {
732
+ return {
733
+ id: domain.id,
734
+ host: domain.host,
735
+ environment: domain.environmentId,
736
+ baseRoute: domain.basePath ?? '',
737
+ https: domain.https,
738
+ status: domain.status,
739
+ message: domain.message,
740
+ }
741
+ }
742
+
743
+ return (
744
+ <div className="space-y-8">
745
+ <ProjectHeading
746
+ teamSlug={typeof params.teamSlug === 'string' ? params.teamSlug : ''}
747
+ project={{ ...project, avatarUrl }}
748
+ crumbs={[<span>Project settings</span>]}
749
+ />
750
+
751
+ <SettingsLayout navigation={projectSettingsNavigationItems}>
752
+ <SettingsSection
753
+ id="general"
754
+ title="General"
755
+ description="General settings for the project including the Git repository."
756
+ footer={
757
+ <SettingsActions
758
+ form="project-general-form"
759
+ hasChanges={hasGeneralChanges || isReadingAvatar}
760
+ saveDisabled={isReadingAvatar}
761
+ isSaving={isSaving}
762
+ onCancel={resetGeneralSettings}
763
+ onSave={saveGeneralSettings}
764
+ />
765
+ }
766
+ >
767
+ <form
768
+ id="project-general-form"
769
+ noValidate
770
+ onSubmit={(event) => {
771
+ event.preventDefault()
772
+ void saveGeneralSettings()
773
+ }}
774
+ >
775
+ <fieldset disabled={isSaving} className="space-y-8">
776
+ <Field>
777
+ <FieldLabel>Avatar</FieldLabel>
778
+ <div className="flex items-center gap-x-2">
779
+ <ResourceAvatar
780
+ name={project.name}
781
+ src={avatarUrl}
782
+ color={project.color}
783
+ size="md"
784
+ />
785
+ {imageUploads ? (
786
+ <input
787
+ ref={avatarInputRef}
788
+ aria-label="Avatar image"
789
+ aria-invalid={Boolean(generalErrors.avatarDataUrl)}
790
+ aria-describedby="project-avatar-error"
791
+ className="sr-only"
792
+ type="file"
793
+ disabled={isSaving}
794
+ accept="image/png,image/jpeg,image/webp"
795
+ onChange={(event) => {
796
+ void chooseAvatar(event.target.files?.[0])
797
+ event.target.value = ''
798
+ }}
799
+ />
800
+ ) : null}
801
+ {imageUploads ? (
802
+ <Button
803
+ variant="outline"
804
+ size="sm"
805
+ type="button"
806
+ id="project-avatar-button"
807
+ aria-invalid={Boolean(generalErrors.avatarDataUrl)}
808
+ aria-describedby="project-avatar-error"
809
+ disabled={isSaving}
810
+ onClick={() => avatarInputRef.current?.click()}
811
+ >
812
+ <UploadIcon data-icon="inline-start" />
813
+ Upload image
814
+ </Button>
815
+ ) : null}
816
+ {avatarUrl ? (
817
+ <Tooltip>
818
+ <TooltipTrigger asChild>
819
+ <Button
820
+ variant="destructive"
821
+ size="icon-sm"
822
+ type="button"
823
+ aria-label="Remove image"
824
+ disabled={isSaving}
825
+ onClick={() => {
826
+ setAvatarDataUrl(null)
827
+ setDeleteAvatar(true)
828
+ setGeneralErrors((current) => ({
829
+ ...current,
830
+ avatarDataUrl: undefined,
831
+ }))
832
+ }}
833
+ >
834
+ <Trash2Icon />
835
+ </Button>
836
+ </TooltipTrigger>
837
+ <TooltipContent>Remove image</TooltipContent>
838
+ </Tooltip>
839
+ ) : null}
840
+ </div>
841
+ <FieldError id="project-avatar-error">
842
+ {generalErrors.avatarDataUrl}
843
+ </FieldError>
844
+ {isReadingAvatar ? (
845
+ <p role="status" className="text-sm text-muted-foreground">
846
+ Reading image…
847
+ </p>
848
+ ) : null}
849
+ </Field>
850
+
851
+ <Field data-invalid={Boolean(generalErrors['projectName'])}>
852
+ <FieldLabel htmlFor="project-name">Project name</FieldLabel>
853
+ <Input
854
+ id="project-name"
855
+ required
856
+ maxLength={100}
857
+ aria-invalid={Boolean(generalErrors['projectName'])}
858
+ aria-describedby="project-name-error"
859
+ value={projectName}
860
+ onChange={(event) => {
861
+ setProjectName(event.target.value)
862
+ setGeneralErrors((current) => ({
863
+ ...current,
864
+ projectName: undefined,
865
+ }))
866
+ }}
867
+ />
868
+ <FieldError id="project-name-error">
869
+ {generalErrors['projectName']}
870
+ </FieldError>
871
+ </Field>
872
+ <Field>
873
+ <FieldLabel>Git repository</FieldLabel>
874
+ <div className="flex items-center">
875
+ <RepositoryPickerDialog
876
+ teamSlug={
877
+ typeof params.teamSlug === 'string' ? params.teamSlug : ''
878
+ }
879
+ currentRepo={selectedRepo}
880
+ onSelect={(repo) => {
881
+ setSelectedRepo(repo)
882
+ setGeneralErrors((current) => ({
883
+ ...current,
884
+ repoFullName: undefined,
885
+ }))
886
+ }}
887
+ >
888
+ <Button
889
+ variant="outline"
890
+ type="button"
891
+ id="project-repository"
892
+ aria-invalid={Boolean(generalErrors.repoFullName)}
893
+ aria-describedby="project-repository-error"
894
+ className="rounded-r-none border-r-0 font-normal"
895
+ >
896
+ <img
897
+ data-icon="inline-start"
898
+ src={`https://github.com/${selectedRepoOwner}.png`}
899
+ alt=""
900
+ className="size-5 rounded"
901
+ />
902
+ {selectedRepo.fullName}
903
+ <ChevronsUpDownIcon
904
+ data-icon="inline-end"
905
+ className="text-muted-foreground group-hover/button:text-muted-foreground"
906
+ />
907
+ </Button>
908
+ </RepositoryPickerDialog>
909
+ <IconActionTooltip label="View on GitHub">
910
+ <Button
911
+ variant="outline"
912
+ size="icon"
913
+ className="rounded-l-none text-muted-foreground hover:text-foreground"
914
+ asChild
915
+ >
916
+ <a
917
+ href={`https://github.com/${selectedRepo.fullName}`}
918
+ target="_blank"
919
+ rel="noreferrer"
920
+ aria-label="View on GitHub"
921
+ >
922
+ <ArrowUpRightIcon />
923
+ </a>
924
+ </Button>
925
+ </IconActionTooltip>
926
+ </div>
927
+ </Field>
928
+ <FieldError id="project-repository-error">
929
+ {generalErrors.repoFullName}
930
+ </FieldError>
931
+ <Field>
932
+ <FieldLabel htmlFor="project-id">Project ID</FieldLabel>
933
+ <InputGroup>
934
+ <InputGroupInput
935
+ id="project-id"
936
+ className="font-mono text-[13px] text-muted-foreground"
937
+ value={project.id}
938
+ readOnly
939
+ />
940
+ <InputGroupAddon align="inline-end">
941
+ <CopyButton
942
+ value={project.id}
943
+ label="Copy project ID"
944
+ inputGroup
945
+ />
946
+ </InputGroupAddon>
947
+ </InputGroup>
948
+ <FieldDescription>
949
+ A unique identifier used in API, environment variables, etc.
950
+ </FieldDescription>
951
+ </Field>
952
+ </fieldset>
953
+ </form>
954
+ </SettingsSection>
955
+
956
+ <SettingsSection
957
+ id="build-and-deploy"
958
+ title="Build & Deploy"
959
+ description="Configure the Compose file, deployment commands, and health checks."
960
+ footer={
961
+ <SettingsActions
962
+ form="project-build-form"
963
+ hasChanges={hasBuildChanges}
964
+ isSaving={isSaving}
965
+ onCancel={resetBuildSettings}
966
+ onSave={saveBuildSettings}
967
+ />
968
+ }
969
+ >
970
+ <form
971
+ id="project-build-form"
972
+ noValidate
973
+ onSubmit={(event) => {
974
+ event.preventDefault()
975
+ void saveBuildSettings()
976
+ }}
977
+ >
978
+ <fieldset disabled={isSaving} className="space-y-4">
979
+ <FieldGroup>
980
+ <Field
981
+ data-invalid={Boolean(buildErrors['runtimeConfig.path'])}
982
+ >
983
+ <FieldLabel htmlFor="compose-path">
984
+ Compose file path
985
+ </FieldLabel>
986
+ <Input
987
+ id="compose-path"
988
+ maxLength={255}
989
+ aria-invalid={Boolean(buildErrors['runtimeConfig.path'])}
990
+ aria-describedby="compose-path-error"
991
+ placeholder="compose.yml"
992
+ value={composePath}
993
+ onChange={(event) => {
994
+ setComposePath(event.target.value)
995
+ setBuildErrors((current) => ({
996
+ ...current,
997
+ 'runtimeConfig.path': undefined,
998
+ }))
999
+ }}
1000
+ />
1001
+ <FieldError id="compose-path-error">
1002
+ {buildErrors['runtimeConfig.path']}
1003
+ </FieldError>
1004
+ <FieldDescription>
1005
+ Repository-relative path to the Compose file. Defaults to
1006
+ compose.yml.
1007
+ </FieldDescription>
1008
+ </Field>
1009
+ <div className="grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(14rem,1fr)]">
1010
+ <Field
1011
+ data-invalid={Boolean(
1012
+ buildErrors['runtimeConfig.releaseCommand'],
1013
+ )}
1014
+ >
1015
+ <FieldLabel htmlFor="release-command">
1016
+ Pre-deploy command
1017
+ </FieldLabel>
1018
+ <Textarea
1019
+ id="release-command"
1020
+ maxLength={16384}
1021
+ aria-invalid={Boolean(
1022
+ buildErrors['runtimeConfig.releaseCommand'],
1023
+ )}
1024
+ aria-describedby="release-command-error"
1025
+ className="min-h-8 resize-none overflow-hidden py-1.5 font-mono"
1026
+ rows={1}
1027
+ value={releaseCommand}
1028
+ onChange={(event) => {
1029
+ setReleaseCommand(event.target.value)
1030
+ setBuildErrors((current) => ({
1031
+ ...current,
1032
+ 'runtimeConfig.releaseCommand': undefined,
1033
+ }))
1034
+ }}
1035
+ />
1036
+ <FieldError id="release-command-error">
1037
+ {buildErrors['runtimeConfig.releaseCommand']}
1038
+ </FieldError>
1039
+ <FieldDescription>
1040
+ Runs once before services are updated. For example,{' '}
1041
+ <code>php artisan migrate --force</code>. Leave empty to
1042
+ skip.
1043
+ </FieldDescription>
1044
+ </Field>
1045
+ <Field
1046
+ data-invalid={Boolean(
1047
+ buildErrors['runtimeConfig.releaseService'],
1048
+ )}
1049
+ >
1050
+ <FieldLabel htmlFor="release-service">
1051
+ Pre-deploy service
1052
+ </FieldLabel>
1053
+ <Input
1054
+ id="release-service"
1055
+ aria-invalid={Boolean(
1056
+ buildErrors['runtimeConfig.releaseService'],
1057
+ )}
1058
+ aria-describedby="release-service-error"
1059
+ value={releaseService}
1060
+ placeholder={
1061
+ defaultReleaseService
1062
+ ? `Default (${defaultReleaseService})`
1063
+ : 'Select a service'
1064
+ }
1065
+ onChange={(event) => {
1066
+ setReleaseService(event.target.value)
1067
+ setBuildErrors((current) => ({
1068
+ ...current,
1069
+ 'runtimeConfig.releaseService': undefined,
1070
+ }))
1071
+ }}
1072
+ />
1073
+ <FieldError id="release-service-error">
1074
+ {buildErrors['runtimeConfig.releaseService']}
1075
+ </FieldError>
1076
+ <FieldDescription>
1077
+ Service to run the pre-deploy command.
1078
+ </FieldDescription>
1079
+ </Field>
1080
+ </div>
1081
+ <Field
1082
+ data-invalid={Boolean(
1083
+ buildErrors['runtimeConfig.deployCommand'],
1084
+ )}
1085
+ >
1086
+ <FieldLabel htmlFor="deploy-command">
1087
+ Deploy command
1088
+ </FieldLabel>
1089
+ <Textarea
1090
+ id="deploy-command"
1091
+ maxLength={16384}
1092
+ aria-invalid={Boolean(
1093
+ buildErrors['runtimeConfig.deployCommand'],
1094
+ )}
1095
+ aria-describedby="deploy-command-error"
1096
+ className="min-h-8 resize-none overflow-hidden py-1.5 font-mono"
1097
+ rows={1}
1098
+ disabled={!canManageLifecycleCommands}
1099
+ value={deployCommand}
1100
+ placeholder={managedComposeCommand}
1101
+ onChange={(event) => {
1102
+ setDeployCommand(event.target.value)
1103
+ setBuildErrors((current) => ({
1104
+ ...current,
1105
+ 'runtimeConfig.deployCommand': undefined,
1106
+ }))
1107
+ }}
1108
+ />
1109
+ <FieldError id="deploy-command-error">
1110
+ {buildErrors['runtimeConfig.deployCommand']}
1111
+ </FieldError>
1112
+ <FieldDescription>
1113
+ Runs on the server to update services after images and the
1114
+ pre-deploy command are ready. When empty, uses{' '}
1115
+ <code>{managedComposeCommand}</code>.
1116
+ </FieldDescription>
1117
+ </Field>
1118
+ <Field
1119
+ data-invalid={Boolean(
1120
+ buildErrors['runtimeConfig.rollbackCommand'],
1121
+ )}
1122
+ >
1123
+ <FieldLabel htmlFor="rollback-command">
1124
+ Rollback command
1125
+ </FieldLabel>
1126
+ <Textarea
1127
+ id="rollback-command"
1128
+ maxLength={16384}
1129
+ aria-invalid={Boolean(
1130
+ buildErrors['runtimeConfig.rollbackCommand'],
1131
+ )}
1132
+ aria-describedby="rollback-command-error"
1133
+ className="min-h-8 resize-none overflow-hidden py-1.5 font-mono"
1134
+ rows={1}
1135
+ disabled={!canManageLifecycleCommands}
1136
+ value={rollbackCommand}
1137
+ placeholder={deployCommand.trim() || managedComposeCommand}
1138
+ onChange={(event) => {
1139
+ setRollbackCommand(event.target.value)
1140
+ setBuildErrors((current) => ({
1141
+ ...current,
1142
+ 'runtimeConfig.rollbackCommand': undefined,
1143
+ }))
1144
+ }}
1145
+ />
1146
+ <FieldError id="rollback-command-error">
1147
+ {buildErrors['runtimeConfig.rollbackCommand']}
1148
+ </FieldError>
1149
+ <FieldDescription>
1150
+ Runs on the server when deploying a previous revision. When
1151
+ empty, uses the deploy command.
1152
+ </FieldDescription>
1153
+ </Field>
1154
+ <Field
1155
+ data-invalid={Boolean(
1156
+ buildErrors['runtimeConfig.timeoutSeconds'],
1157
+ )}
1158
+ >
1159
+ <FieldLabel htmlFor="deploy-timeout">
1160
+ Command timeout
1161
+ </FieldLabel>
1162
+ <Input
1163
+ id="deploy-timeout"
1164
+ aria-invalid={Boolean(
1165
+ buildErrors['runtimeConfig.timeoutSeconds'],
1166
+ )}
1167
+ aria-describedby="deploy-timeout-error"
1168
+ type="number"
1169
+ min={60}
1170
+ max={7200}
1171
+ step={1}
1172
+ required
1173
+ value={deployTimeoutSeconds}
1174
+ onChange={(event) => {
1175
+ setDeployTimeoutSeconds(event.target.value)
1176
+ setBuildErrors((current) => ({
1177
+ ...current,
1178
+ 'runtimeConfig.timeoutSeconds': undefined,
1179
+ }))
1180
+ }}
1181
+ />
1182
+ <FieldError id="deploy-timeout-error">
1183
+ {buildErrors['runtimeConfig.timeoutSeconds']}
1184
+ </FieldError>
1185
+ <FieldDescription>
1186
+ Maximum runtime for either command: 60–7,200 seconds.
1187
+ </FieldDescription>
1188
+ </Field>
1189
+ <Field
1190
+ data-invalid={Boolean(
1191
+ buildErrors['runtimeConfig.readinessPath'],
1192
+ )}
1193
+ >
1194
+ <FieldLabel htmlFor="readiness">Health check path</FieldLabel>
1195
+ <Input
1196
+ id="readiness"
1197
+ aria-invalid={Boolean(
1198
+ buildErrors['runtimeConfig.readinessPath'],
1199
+ )}
1200
+ aria-describedby="readiness-error"
1201
+ value={readinessPath}
1202
+ placeholder="/"
1203
+ onChange={(event) => {
1204
+ setReadinessPath(event.target.value)
1205
+ setBuildErrors((current) => ({
1206
+ ...current,
1207
+ 'runtimeConfig.readinessPath': undefined,
1208
+ }))
1209
+ }}
1210
+ />
1211
+ <FieldError id="readiness-error">
1212
+ {buildErrors['runtimeConfig.readinessPath']}
1213
+ </FieldError>
1214
+ <FieldDescription>
1215
+ Path requested after deployment to confirm the service is
1216
+ ready.
1217
+ </FieldDescription>
1218
+ </Field>
1219
+ </FieldGroup>
1220
+ </fieldset>
1221
+ </form>
1222
+ </SettingsSection>
1223
+
1224
+ <EnvironmentSettingsSection context={context} />
1225
+
1226
+ <EnvironmentVariablesSettingsSection context={context} />
1227
+
1228
+ <EnvironmentServersSettingsSection context={context} />
1229
+
1230
+ <SettingsSection
1231
+ id="routes"
1232
+ title="Routes"
1233
+ description="Default routes apply to every environment. Environment routes add paths or override the default target for the same path."
1234
+ footer={
1235
+ <SettingsActions
1236
+ hasChanges={hasRouteChanges}
1237
+ isSaving={isSaving || verifyingDomainId !== null}
1238
+ onCancel={resetRoutes}
1239
+ onSave={saveRoutes}
1240
+ form="project-routes-form"
1241
+ />
1242
+ }
1243
+ >
1244
+ <form
1245
+ id="project-routes-form"
1246
+ noValidate
1247
+ onSubmit={(event) => {
1248
+ event.preventDefault()
1249
+ void saveRoutes()
1250
+ }}
1251
+ >
1252
+ <fieldset
1253
+ disabled={isSaving || verifyingDomainId !== null}
1254
+ className="space-y-4"
1255
+ >
1256
+ <ManagedIngressWarnings
1257
+ warnings={context.composeMetadata?.warnings ?? []}
1258
+ />
1259
+ <RouteSettingsTable
1260
+ services={context.composeMetadata?.services ?? []}
1261
+ routes={routes}
1262
+ environments={context.environments}
1263
+ errors={routeErrors}
1264
+ onChange={(next) => {
1265
+ setRoutes(next)
1266
+ setRouteErrors({})
1267
+ }}
1268
+ />
1269
+ </fieldset>
1270
+ </form>
1271
+ </SettingsSection>
1272
+
1273
+ <SettingsSection
1274
+ id="domains"
1275
+ title="Domains"
1276
+ description="Custom domain names associated with this project's environments."
1277
+ footer={
1278
+ <SettingsActions
1279
+ hasChanges={hasDomainChanges}
1280
+ isSaving={isSaving || verifyingDomainId !== null}
1281
+ onCancel={resetDomains}
1282
+ onSave={saveDomains}
1283
+ form="project-domains-form"
1284
+ />
1285
+ }
1286
+ >
1287
+ <form
1288
+ id="project-domains-form"
1289
+ noValidate
1290
+ onSubmit={(event) => {
1291
+ event.preventDefault()
1292
+ void saveDomains()
1293
+ }}
1294
+ >
1295
+ <fieldset
1296
+ disabled={isSaving || verifyingDomainId !== null}
1297
+ className="space-y-4"
1298
+ >
1299
+ <DomainSettingsTable
1300
+ servers={context.servers}
1301
+ domains={domains}
1302
+ environments={context.environments}
1303
+ routes={savedRoutes}
1304
+ errors={domainErrors}
1305
+ onChange={(next) => {
1306
+ setDomains(next)
1307
+ setDomainErrors({})
1308
+ }}
1309
+ onVerify={verifyDomain}
1310
+ verifyingDomainId={verifyingDomainId}
1311
+ verificationDisabled={hasDomainChanges || isSaving}
1312
+ />
1313
+ </fieldset>
1314
+ </form>
1315
+ </SettingsSection>
1316
+
1317
+ <NotificationMute scope={{ kind: 'project', id: project.id }} />
1318
+ <SettingsSection
1319
+ id="danger"
1320
+ title="Danger"
1321
+ description="Permanently delete the project and all of its configuration, environments and deployments."
1322
+ >
1323
+ {context.team.role === 'owner' || context.team.role === 'admin' ? (
1324
+ <AlertDialog
1325
+ open={deleteOpen}
1326
+ onOpenChange={(next) => {
1327
+ if (!isDeletingProject && !projectDeleted) {
1328
+ setDeleteOpen(next)
1329
+ }
1330
+ }}
1331
+ >
1332
+ <AlertDialogTrigger asChild>
1333
+ <Button
1334
+ variant="destructive"
1335
+ disabled={isDeletingProject || projectDeleted}
1336
+ onClick={() => {
1337
+ setDeleteProjectConfirmation('')
1338
+ setRemoveProjectDeployments(true)
1339
+ }}
1340
+ >
1341
+ {isDeletingProject ? (
1342
+ <LoaderIcon
1343
+ data-icon="inline-start"
1344
+ className="animate-spin"
1345
+ />
1346
+ ) : (
1347
+ <Trash2Icon data-icon="inline-start" />
1348
+ )}
1349
+ Delete project
1350
+ </Button>
1351
+ </AlertDialogTrigger>
1352
+ <AlertDialogContent>
1353
+ <AlertDialogHeader>
1354
+ <AlertDialogTitle>Delete this project?</AlertDialogTitle>
1355
+ <AlertDialogDescription>
1356
+ This permanently deletes the project, its environments,
1357
+ routes, domains, variables, backups, and deployments. This
1358
+ cannot be undone. {`Type "${project.name}" to confirm.`}
1359
+ </AlertDialogDescription>
1360
+ </AlertDialogHeader>
1361
+ <div className="grid gap-2">
1362
+ <Field
1363
+ orientation="horizontal"
1364
+ className="rounded-md border p-3"
1365
+ >
1366
+ <Checkbox
1367
+ id="delete-project-remove-deployments"
1368
+ disabled={isDeletingProject}
1369
+ checked={removeProjectDeployments}
1370
+ onCheckedChange={(checked) =>
1371
+ setRemoveProjectDeployments(checked === true)
1372
+ }
1373
+ />
1374
+ <FieldContent>
1375
+ <FieldLabel htmlFor="delete-project-remove-deployments">
1376
+ Remove deployed Docker Compose runtimes
1377
+ </FieldLabel>
1378
+ <FieldDescription>
1379
+ Persistent volumes are retained for 7 days. If
1380
+ unchecked, deployments may keep running on connected
1381
+ servers, but devpush will stop managing them. Their
1382
+ hostnames remain reserved.
1383
+ </FieldDescription>
1384
+ </FieldContent>
1385
+ </Field>
1386
+ <Field>
1387
+ <Input
1388
+ aria-label={`Type ${project.name} to confirm`}
1389
+ id="delete-project-confirmation"
1390
+ required
1391
+ disabled={isDeletingProject}
1392
+ placeholder={project.name}
1393
+ value={deleteProjectConfirmation}
1394
+ onChange={(event) =>
1395
+ setDeleteProjectConfirmation(event.target.value)
1396
+ }
1397
+ onKeyDown={(event) => {
1398
+ if (
1399
+ event.key === 'Enter' &&
1400
+ !isDeletingProject &&
1401
+ deleteProjectConfirmation === project.name
1402
+ ) {
1403
+ event.preventDefault()
1404
+ void deleteCurrentProject()
1405
+ }
1406
+ }}
1407
+ />
1408
+ </Field>
1409
+ </div>
1410
+ <AlertDialogFooter>
1411
+ <AlertDialogCancel
1412
+ disabled={isDeletingProject || projectDeleted}
1413
+ >
1414
+ Cancel
1415
+ </AlertDialogCancel>
1416
+ <AlertDialogAction
1417
+ variant="destructive"
1418
+ disabled={
1419
+ isDeletingProject ||
1420
+ projectDeleted ||
1421
+ deleteProjectConfirmation !== project.name
1422
+ }
1423
+ onClick={(event) => {
1424
+ event.preventDefault()
1425
+ void deleteCurrentProject()
1426
+ }}
1427
+ >
1428
+ {isDeletingProject ? (
1429
+ <LoaderIcon className="animate-spin" />
1430
+ ) : null}
1431
+ Delete project
1432
+ </AlertDialogAction>
1433
+ </AlertDialogFooter>
1434
+ </AlertDialogContent>
1435
+ </AlertDialog>
1436
+ ) : (
1437
+ <p className="text-sm text-muted-foreground">
1438
+ Only team owners and admins can delete projects.
1439
+ </p>
1440
+ )}
1441
+ </SettingsSection>
1442
+ </SettingsLayout>
1443
+ </div>
1444
+ )
1445
+ }
1446
+
1447
+ function normalizeDomainsForRoutes(
1448
+ domains: Array<ProjectDomainAlias>,
1449
+ routes: Array<ProjectRouteDefinition>,
1450
+ ) {
1451
+ return domains.map((domain) => ({
1452
+ ...domain,
1453
+ baseRoute: getEffectiveRouteDefinitions(routes, domain.environment).some(
1454
+ (route) => route.path === domain.baseRoute,
1455
+ )
1456
+ ? domain.baseRoute
1457
+ : '',
1458
+ }))
1459
+ }
1460
+
1461
+ const projectSettingsFieldIds: Record<string, string> = {
1462
+ projectName: 'project-name',
1463
+ 'runtimeConfig.path': 'compose-path',
1464
+ 'runtimeConfig.releaseCommand': 'release-command',
1465
+ 'runtimeConfig.releaseService': 'release-service',
1466
+ 'runtimeConfig.deployCommand': 'deploy-command',
1467
+ 'runtimeConfig.rollbackCommand': 'rollback-command',
1468
+ 'runtimeConfig.timeoutSeconds': 'deploy-timeout',
1469
+ 'runtimeConfig.readinessPath': 'readiness',
1470
+ avatarDataUrl: 'project-avatar-button',
1471
+ repoFullName: 'project-repository',
1472
+ }