devpush 0.0.0-stage → 1.0.0-beta.4

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 +22 -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 +168 -3
  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,1286 @@
1
+ import { mutationErrorOptions, afterMutation } from '#devpush/lib/action-feedback'
2
+ import { WorkspacePluginContributions } from '#devpush/features/plugins/contributions'
3
+ import { LogoMark } from '#devpush/components/logo-mark'
4
+ import { invalidateResourceQueries } from '#devpush/lib/resource-cache'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ } from '#devpush/components/ui/dialog'
13
+ import { AboutDialog } from '#devpush/components/about-dialog'
14
+ import { NewTeamDialogLauncher } from './new-team-dialog'
15
+ import { SignOutMenuItem } from '#devpush/features/auth/sign-out-menu-item'
16
+ import {
17
+ Link,
18
+ useParams,
19
+ useHydrated,
20
+ useRouter,
21
+ useRouterState,
22
+ } from '@tanstack/react-router'
23
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
24
+ import { pluginContextQueryOptions } from '#devpush/lib/project-context-query'
25
+ import { useTheme } from 'next-themes'
26
+ import {
27
+ ArrowUpRightIcon,
28
+ BellIcon,
29
+ CheckIcon,
30
+ ChevronsUpDownIcon,
31
+ CircleCheckIcon,
32
+ CircleXIcon,
33
+ Clock3Icon,
34
+ LoaderIcon,
35
+ MoonIcon,
36
+ SunIcon,
37
+ CircleAlertIcon,
38
+ } from 'lucide-react'
39
+ import type { ReactNode } from 'react'
40
+ import { useState } from 'react'
41
+ import { toast } from 'sonner'
42
+ import { getCachedTeamContexts } from '#devpush/lib/team-context-query'
43
+
44
+ import {
45
+ Avatar,
46
+ AvatarFallback,
47
+ AvatarImage,
48
+ } from '#devpush/components/ui/avatar'
49
+ import { ResourceAvatar } from '#devpush/components/resource-avatar'
50
+ import { Button } from '#devpush/components/ui/button'
51
+ import {
52
+ Tooltip,
53
+ TooltipContent,
54
+ TooltipTrigger,
55
+ } from '#devpush/components/ui/tooltip'
56
+ import { Skeleton } from '#devpush/components/ui/skeleton'
57
+ import { NewProjectDialogLauncher } from '#devpush/features/repositories/new-project-dialog-launcher'
58
+ import { getServerAvailabilityNotice } from '#devpush/features/servers/server-availability'
59
+ import { ServerAvailabilityAlert } from '#devpush/features/servers/server-availability-alert'
60
+ import { getPublicErrorMessage } from '#devpush/lib/public-error'
61
+ import { displayNameFromSlug } from '#devpush/lib/display-name'
62
+ import { cn } from '#devpush/lib/utils'
63
+ import {
64
+ DropdownMenu,
65
+ DropdownMenuContent,
66
+ DropdownMenuItem,
67
+ DropdownMenuLabel,
68
+ DropdownMenuSeparator,
69
+ DropdownMenuTrigger,
70
+ } from '#devpush/components/ui/dropdown-menu'
71
+ import type { AuthUserContext } from '#devpush/server/auth/context'
72
+ import type { ProjectContext } from '#devpush/server/projects/types'
73
+ import type {
74
+ ProjectSummary,
75
+ ServerSummary,
76
+ TeamSummary,
77
+ } from '#devpush/server/teams/types'
78
+ import {
79
+ acceptUserTeamInviteAction,
80
+ declineUserTeamInviteAction,
81
+ } from '#devpush/server/teams/functions'
82
+ import {
83
+ isProjectTabActive,
84
+ isTeamTabActive,
85
+ projectTabs,
86
+ teamTabs,
87
+ } from './navigation'
88
+
89
+ type ProjectNavigationSummary = Pick<
90
+ ProjectSummary,
91
+ 'avatarUrl' | 'color' | 'id' | 'name'
92
+ >
93
+
94
+ type TeamHeaderProps = {
95
+ currentProject?: boolean
96
+ currentProjectContext?: ProjectNavigationSummary
97
+ currentProjects?: Array<ProjectNavigationSummary>
98
+ currentDeployment?: {
99
+ id: string
100
+ status: ProjectContext['deployments'][number]['status']
101
+ }
102
+ currentDeployments?: ProjectContext['deployments']
103
+ userContext?: AuthUserContext
104
+ currentTeam?: TeamSummary
105
+ }
106
+
107
+ function TeamHeader({
108
+ currentProject = false,
109
+ currentProjectContext,
110
+ currentProjects = [],
111
+ currentDeployment,
112
+ currentDeployments = [],
113
+ userContext,
114
+ currentTeam,
115
+ }: TeamHeaderProps) {
116
+ const queryClient = useQueryClient()
117
+ const pathname = useRouterState({
118
+ select: (state) => (state.resolvedLocation ?? state.location).pathname,
119
+ })
120
+ const params = useParams({ strict: false })
121
+ const teamSlug =
122
+ typeof params.teamSlug === 'string'
123
+ ? params.teamSlug
124
+ : (currentTeam?.slug ?? userContext?.defaultTeam.slug ?? '')
125
+ const hydrated = useHydrated()
126
+ // A client loader can populate the cache before hydration finishes. Keep the
127
+ // first client header identical to SSR, then reveal cached navigation.
128
+ const cachedTeamContexts = hydrated
129
+ ? getCachedTeamContexts(queryClient, teamSlug)
130
+ : []
131
+ const cachedTeamContext = cachedTeamContexts.at(0)
132
+ const activeUserContext = userContext ?? cachedTeamContext?.userContext
133
+ const activeTeam =
134
+ currentTeam ?? cachedTeamContext?.team ?? activeUserContext?.defaultTeam
135
+ const routeProjectName =
136
+ typeof params.projectName === 'string' ? params.projectName : undefined
137
+ const routeDeploymentId =
138
+ typeof params.deploymentId === 'string' ? params.deploymentId : undefined
139
+ // Observe the detail route's query so both the current status and menu rows
140
+ // receive the same workspace notifications, without a second request.
141
+ const { data: pluginContext } = useQuery({
142
+ ...pluginContextQueryOptions(
143
+ {
144
+ teamSlug,
145
+ projectName: routeProjectName ?? '',
146
+ deploymentId: routeDeploymentId ?? '',
147
+ },
148
+ queryClient,
149
+ ),
150
+ enabled: false,
151
+ })
152
+ const activeDeployment =
153
+ currentDeployment ??
154
+ (hydrated ? pluginContext?.deployment : undefined) ??
155
+ (routeDeploymentId
156
+ ? { id: routeDeploymentId, status: undefined }
157
+ : undefined)
158
+ const activeDeployments =
159
+ hydrated && pluginContext
160
+ ? pluginContext.latestDeployments
161
+ : currentDeployments
162
+ const cachedProject = routeProjectName
163
+ ? cachedTeamContexts
164
+ .flatMap((context) => context.projects)
165
+ .find((project) => project.name === routeProjectName)
166
+ : undefined
167
+ const activeProject = currentProjectContext ?? cachedProject
168
+ const activeProjects =
169
+ currentProjects.length > 0
170
+ ? currentProjects
171
+ : cachedTeamContexts.flatMap((context) => context.projects)
172
+ const tabs = currentProject ? projectTabs : teamTabs
173
+
174
+ return (
175
+ <header className="sticky top-0 z-20 flex flex-col gap-y-2 border-b bg-background px-3 sm:px-8">
176
+ <div className="flex h-16 min-w-0 items-center justify-between gap-x-2 sm:gap-x-4">
177
+ <nav className="flex min-w-0 flex-1 items-center gap-x-0.5 sm:gap-x-1.5">
178
+ <Button
179
+ asChild
180
+ variant="ghost"
181
+ size="icon-sm"
182
+ className={cn(
183
+ 'group mr-1 shrink-0',
184
+ currentProject && 'max-sm:hidden',
185
+ )}
186
+ >
187
+ <Link to="/" aria-label="devpush">
188
+ <LogoMark className="size-4.5 shrink-0 transition-colors duration-200 group-hover:text-orange-600 group-focus-visible:text-orange-600" />
189
+ </Link>
190
+ </Button>
191
+ <span
192
+ className={cn(
193
+ 'shrink-0 text-xl text-muted-foreground/25',
194
+ currentProject && 'max-sm:hidden',
195
+ )}
196
+ >
197
+ /
198
+ </span>
199
+ {activeTeam && activeUserContext ? (
200
+ <TeamSwitcher
201
+ currentTeam={activeTeam}
202
+ teams={activeUserContext.teams}
203
+ collapsed={currentProject}
204
+ />
205
+ ) : (
206
+ <PendingResourceSwitcher
207
+ label={displayNameFromSlug(teamSlug)}
208
+ name={teamSlug}
209
+ ariaLabel="Team loading"
210
+ collapsed={currentProject}
211
+ teamSlug={teamSlug}
212
+ />
213
+ )}
214
+ {currentProject ? (
215
+ <>
216
+ <span className="shrink-0 text-xl text-muted-foreground/25">
217
+ /
218
+ </span>
219
+ {activeProject ? (
220
+ <ProjectSwitcher
221
+ teamSlug={teamSlug}
222
+ project={activeProject}
223
+ projects={activeProjects}
224
+ collapsed={Boolean(activeDeployment)}
225
+ />
226
+ ) : (
227
+ <PendingResourceSwitcher
228
+ label={
229
+ typeof params.projectName === 'string'
230
+ ? params.projectName
231
+ : 'Project'
232
+ }
233
+ name={
234
+ typeof params.projectName === 'string'
235
+ ? params.projectName
236
+ : 'Project'
237
+ }
238
+ ariaLabel="Project loading"
239
+ collapsed={Boolean(activeDeployment)}
240
+ teamSlug={teamSlug}
241
+ projectName={
242
+ typeof params.projectName === 'string'
243
+ ? params.projectName
244
+ : 'project'
245
+ }
246
+ />
247
+ )}
248
+ </>
249
+ ) : null}
250
+ {activeDeployment ? (
251
+ <>
252
+ <span className="shrink-0 text-xl text-muted-foreground/25">
253
+ /
254
+ </span>
255
+ <DeploymentSwitcher
256
+ deployment={activeDeployment}
257
+ deployments={activeDeployments}
258
+ projectName={
259
+ typeof params.projectName === 'string'
260
+ ? params.projectName
261
+ : (currentProjectContext?.name ?? '')
262
+ }
263
+ teamSlug={teamSlug}
264
+ />
265
+ </>
266
+ ) : null}
267
+ {activeTeam && activeTeam.slug === teamSlug ? (
268
+ <>
269
+ <WorkspacePluginContributions
270
+ place="header"
271
+ teamSlug={teamSlug}
272
+ role={activeTeam.role}
273
+ />
274
+ </>
275
+ ) : null}
276
+ </nav>
277
+
278
+ <nav
279
+ className="flex shrink-0 items-center gap-2"
280
+ aria-label="User menu"
281
+ >
282
+ {activeUserContext ? (
283
+ <UserMenu userContext={activeUserContext} />
284
+ ) : (
285
+ <PendingUserNavigation />
286
+ )}
287
+ </nav>
288
+ </div>
289
+
290
+ <nav
291
+ className="scrollbar -mt-2 -mb-[1px] overflow-x-auto"
292
+ aria-label="Secondary navigation"
293
+ >
294
+ <ul
295
+ className="flex w-max min-w-full items-center gap-x-1"
296
+ role="tablist"
297
+ >
298
+ {tabs.map((tab) => {
299
+ const active = currentProject
300
+ ? isProjectTabActive(pathname, tab.label)
301
+ : isTeamTabActive(pathname, tab.label)
302
+ const tabParams = currentProject
303
+ ? {
304
+ teamSlug,
305
+ projectName:
306
+ typeof params.projectName === 'string'
307
+ ? params.projectName
308
+ : (currentProjectContext?.name ?? ''),
309
+ }
310
+ : { teamSlug }
311
+
312
+ return (
313
+ <li
314
+ key={tab.label}
315
+ role="presentation"
316
+ className={`shrink-0 border-b-2 pb-1.5 ${
317
+ active ? 'border-primary' : 'border-transparent'
318
+ }`}
319
+ >
320
+ <Button
321
+ asChild
322
+ variant="ghost"
323
+ size="sm"
324
+ className={
325
+ active ? ' text-foreground' : ' text-muted-foreground'
326
+ }
327
+ >
328
+ <Link
329
+ to={tab.to}
330
+ params={tabParams}
331
+ activeOptions={{ exact: tab.label === 'Overview' }}
332
+ aria-selected={active}
333
+ role="tab"
334
+ >
335
+ {tab.label}
336
+ </Link>
337
+ </Button>
338
+ </li>
339
+ )
340
+ })}
341
+ </ul>
342
+ </nav>
343
+ </header>
344
+ )
345
+ }
346
+
347
+ export function TeamPage({
348
+ children,
349
+ contentClassName,
350
+ userContext,
351
+ currentTeam,
352
+ currentProject,
353
+ currentProjects = [],
354
+ projectNavigation = false,
355
+ pending = false,
356
+ servers = [],
357
+ }: {
358
+ children: ReactNode
359
+ contentClassName?: string
360
+ userContext?: AuthUserContext
361
+ currentTeam?: TeamSummary
362
+ currentProject?: ProjectNavigationSummary
363
+ currentProjects?: Array<ProjectNavigationSummary>
364
+ projectNavigation?: boolean
365
+ pending?: boolean
366
+ servers?: Array<ServerSummary>
367
+ }) {
368
+ const serverNotice = getServerAvailabilityNotice(servers)
369
+ const queryClient = useQueryClient()
370
+ const params = useParams({ strict: false })
371
+ const hydrated = useHydrated()
372
+ const teamSlug =
373
+ typeof params.teamSlug === 'string'
374
+ ? params.teamSlug
375
+ : (currentTeam?.slug ?? '')
376
+ const noticeTeam =
377
+ currentTeam ??
378
+ (hydrated
379
+ ? getCachedTeamContexts(queryClient, teamSlug).at(0)?.team
380
+ : undefined)
381
+
382
+ return (
383
+ <main className="min-h-screen min-w-0 bg-background text-foreground">
384
+ <TeamHeader
385
+ currentProject={projectNavigation || Boolean(currentProject)}
386
+ currentProjectContext={currentProject}
387
+ currentProjects={currentProjects}
388
+ userContext={userContext}
389
+ currentTeam={currentTeam}
390
+ />
391
+ <div
392
+ className={cn(
393
+ 'mx-auto w-full min-w-0 max-w-screen-xl p-4 sm:p-8',
394
+ contentClassName,
395
+ )}
396
+ aria-busy={pending || undefined}
397
+ >
398
+ {noticeTeam ? (
399
+ <>
400
+ <WorkspacePluginContributions
401
+ place="notice"
402
+ teamSlug={noticeTeam.slug}
403
+ role={noticeTeam.role}
404
+ />
405
+ </>
406
+ ) : null}
407
+ {serverNotice && currentTeam && !currentProject ? (
408
+ <ServerAvailabilityAlert notice={serverNotice} team={currentTeam} />
409
+ ) : null}
410
+ {children}
411
+ </div>
412
+ </main>
413
+ )
414
+ }
415
+
416
+ function PendingResourceSwitcher({
417
+ collapsed = false,
418
+ ariaLabel,
419
+ label,
420
+ name,
421
+ projectName,
422
+ teamSlug,
423
+ }: {
424
+ collapsed?: boolean
425
+ ariaLabel: string
426
+ label: string
427
+ name: string
428
+ projectName?: string
429
+ teamSlug: string
430
+ }) {
431
+ const content = (
432
+ <>
433
+ <ResourceAvatar data-icon="inline-start" name={name} size="xs" />
434
+ <span className={cn('max-w-48 truncate', collapsed && 'max-sm:hidden')}>
435
+ {label}
436
+ </span>
437
+ </>
438
+ )
439
+
440
+ return (
441
+ <div
442
+ className={cn(
443
+ 'flex min-w-0 items-center',
444
+ collapsed ? 'max-sm:shrink-0' : 'flex-1 sm:flex-initial',
445
+ )}
446
+ aria-label={ariaLabel}
447
+ >
448
+ <Button
449
+ asChild
450
+ variant="ghost"
451
+ size="sm"
452
+ className="min-w-0 shrink gap-1.5 px-1 max-sm:gap-1 sm:px-1.5"
453
+ >
454
+ {projectName ? (
455
+ <Link
456
+ to="/$teamSlug/projects/$projectName"
457
+ params={{ teamSlug, projectName }}
458
+ >
459
+ {content}
460
+ </Link>
461
+ ) : (
462
+ <Link to="/$teamSlug" params={{ teamSlug }}>
463
+ {content}
464
+ </Link>
465
+ )}
466
+ </Button>
467
+ <Button
468
+ type="button"
469
+ variant="ghost"
470
+ size="icon-sm"
471
+ className="pointer-events-none shrink-0 text-muted-foreground max-sm:size-5"
472
+ aria-hidden="true"
473
+ tabIndex={-1}
474
+ >
475
+ <ChevronsUpDownIcon />
476
+ </Button>
477
+ </div>
478
+ )
479
+ }
480
+
481
+ function PendingUserNavigation() {
482
+ return (
483
+ <>
484
+ <Button
485
+ type="button"
486
+ variant="ghost"
487
+ size="icon-sm"
488
+ aria-label="Notifications loading"
489
+ disabled
490
+ >
491
+ <BellIcon />
492
+ </Button>
493
+ <Button
494
+ type="button"
495
+ variant="ghost"
496
+ size="icon-sm"
497
+ className="relative rounded-full border-0"
498
+ aria-label="User menu loading"
499
+ aria-busy="true"
500
+ disabled
501
+ >
502
+ <Skeleton className="size-6 shrink-0 rounded-full" />
503
+ </Button>
504
+ </>
505
+ )
506
+ }
507
+
508
+ function TeamSwitcher({
509
+ collapsed = false,
510
+ currentTeam,
511
+ teams,
512
+ }: {
513
+ collapsed?: boolean
514
+ currentTeam: TeamSummary
515
+ teams: Array<TeamSummary>
516
+ }) {
517
+ const router = useRouter()
518
+ const queryClient = useQueryClient()
519
+
520
+ return (
521
+ <div
522
+ className={cn(
523
+ 'flex min-w-0 items-center',
524
+ collapsed ? 'max-sm:shrink-0' : 'flex-1 sm:flex-initial',
525
+ )}
526
+ >
527
+ <Button
528
+ asChild
529
+ variant="ghost"
530
+ size="sm"
531
+ className="min-w-0 shrink gap-1.5 px-1 max-sm:gap-1 sm:px-1.5"
532
+ >
533
+ <Link
534
+ to="/$teamSlug"
535
+ params={{ teamSlug: currentTeam.slug }}
536
+ aria-label={currentTeam.name}
537
+ >
538
+ <ResourceAvatar
539
+ data-icon="inline-start"
540
+ name={currentTeam.name}
541
+ src={currentTeam.image ?? undefined}
542
+ color={currentTeam.color}
543
+ size="xs"
544
+ />
545
+ <span
546
+ className={cn('max-w-48 truncate', collapsed && 'max-sm:hidden')}
547
+ >
548
+ {currentTeam.name}
549
+ </span>
550
+ </Link>
551
+ </Button>
552
+ <DropdownMenu>
553
+ <DropdownMenuTrigger asChild>
554
+ <Button
555
+ variant="ghost"
556
+ size="icon-sm"
557
+ className="shrink-0 text-muted-foreground max-sm:size-5"
558
+ aria-label="Switch team"
559
+ >
560
+ <ChevronsUpDownIcon />
561
+ </Button>
562
+ </DropdownMenuTrigger>
563
+ <DropdownMenuContent
564
+ align="start"
565
+ side="bottom"
566
+ className="w-auto max-w-[calc(100vw-2rem)]"
567
+ >
568
+ <DropdownMenuItem className="gap-1.5 whitespace-nowrap">
569
+ <ResourceAvatar
570
+ name={currentTeam.name}
571
+ src={currentTeam.image ?? undefined}
572
+ color={currentTeam.color}
573
+ size="xs"
574
+ />
575
+ <span className="min-w-0 truncate">{currentTeam.name}</span>
576
+ <CheckIcon className="ml-auto text-muted-foreground" />
577
+ </DropdownMenuItem>
578
+ {teams
579
+ .filter((teamOption) => teamOption.id !== currentTeam.id)
580
+ .slice(0, 5)
581
+ .map((teamOption) => (
582
+ <DropdownMenuItem
583
+ asChild
584
+ className="gap-1.5 whitespace-nowrap"
585
+ key={teamOption.id}
586
+ >
587
+ <Link to="/$teamSlug" params={{ teamSlug: teamOption.slug }}>
588
+ <ResourceAvatar
589
+ name={teamOption.name}
590
+ src={teamOption.image ?? undefined}
591
+ color={teamOption.color}
592
+ size="xs"
593
+ />
594
+ <span className="min-w-0 truncate">{teamOption.name}</span>
595
+ <span className="ml-auto size-4" />
596
+ </Link>
597
+ </DropdownMenuItem>
598
+ ))}
599
+ <DropdownMenuSeparator />
600
+ <NewTeamDialogLauncher
601
+ onCreated={async (teamSlug) => {
602
+ await afterMutation(
603
+ () => invalidateResourceQueries(queryClient),
604
+ 'The action completed, but refreshing the data failed. Please reload.',
605
+ )
606
+ await afterMutation(
607
+ () => router.invalidate(),
608
+ 'The action completed, but refreshing the page failed. Please reload.',
609
+ )
610
+ await afterMutation(
611
+ () =>
612
+ router.navigate({
613
+ to: '/$teamSlug',
614
+ params: { teamSlug },
615
+ }),
616
+ 'The action completed, but opening the next page failed. Please reload.',
617
+ )
618
+ }}
619
+ >
620
+ {(open) => (
621
+ <DropdownMenuItem
622
+ className="whitespace-nowrap"
623
+ onSelect={(event) => {
624
+ event.preventDefault()
625
+ open()
626
+ }}
627
+ >
628
+ Add new team
629
+ </DropdownMenuItem>
630
+ )}
631
+ </NewTeamDialogLauncher>
632
+ </DropdownMenuContent>
633
+ </DropdownMenu>
634
+ </div>
635
+ )
636
+ }
637
+
638
+ function ProjectSwitcher({
639
+ collapsed = false,
640
+ teamSlug,
641
+ project,
642
+ projects,
643
+ }: {
644
+ collapsed?: boolean
645
+ teamSlug: string
646
+ project: ProjectNavigationSummary
647
+ projects: Array<ProjectNavigationSummary>
648
+ }) {
649
+ const params = useParams({ strict: false })
650
+ const projectName =
651
+ typeof params.projectName === 'string' ? params.projectName : project.name
652
+
653
+ return (
654
+ <div
655
+ className={cn(
656
+ 'flex min-w-0 items-center',
657
+ collapsed ? 'max-sm:shrink-0' : 'flex-1 sm:flex-initial',
658
+ )}
659
+ >
660
+ <Button
661
+ asChild
662
+ variant="ghost"
663
+ size="sm"
664
+ className="min-w-0 shrink gap-1.5 px-1 max-sm:gap-1 sm:px-1.5"
665
+ >
666
+ <Link
667
+ to="/$teamSlug/projects/$projectName"
668
+ params={{ teamSlug, projectName }}
669
+ aria-label={project.name}
670
+ >
671
+ <ResourceAvatar
672
+ data-icon="inline-start"
673
+ name={project.name}
674
+ src={project.avatarUrl ?? undefined}
675
+ color={project.color}
676
+ size="xs"
677
+ />
678
+ <span
679
+ className={cn('max-w-48 truncate', collapsed && 'max-sm:hidden')}
680
+ >
681
+ {project.name}
682
+ </span>
683
+ </Link>
684
+ </Button>
685
+ <DropdownMenu>
686
+ <DropdownMenuTrigger asChild>
687
+ <Button
688
+ variant="ghost"
689
+ size="icon-sm"
690
+ className="shrink-0 text-muted-foreground max-sm:size-5"
691
+ aria-label="Switch project"
692
+ >
693
+ <ChevronsUpDownIcon />
694
+ </Button>
695
+ </DropdownMenuTrigger>
696
+ <DropdownMenuContent
697
+ align="start"
698
+ side="bottom"
699
+ className="w-auto max-w-[calc(100vw-2rem)]"
700
+ >
701
+ <DropdownMenuItem asChild className="gap-1.5 whitespace-nowrap">
702
+ <Link
703
+ to="/$teamSlug/projects/$projectName"
704
+ params={{ teamSlug, projectName }}
705
+ >
706
+ <ResourceAvatar
707
+ name={project.name}
708
+ src={project.avatarUrl ?? undefined}
709
+ color={project.color}
710
+ size="xs"
711
+ />
712
+ <span className="min-w-0 truncate">{project.name}</span>
713
+ <CheckIcon className="ml-auto" />
714
+ </Link>
715
+ </DropdownMenuItem>
716
+ {projects
717
+ .filter((projectOption) => projectOption.id !== project.id)
718
+ .slice(0, 5)
719
+ .map((projectOption) => (
720
+ <DropdownMenuItem
721
+ asChild
722
+ className="gap-1.5 whitespace-nowrap"
723
+ key={projectOption.id}
724
+ >
725
+ <Link
726
+ to="/$teamSlug/projects/$projectName"
727
+ params={{ teamSlug, projectName: projectOption.name }}
728
+ >
729
+ <ResourceAvatar
730
+ name={projectOption.name}
731
+ src={projectOption.avatarUrl ?? undefined}
732
+ color={projectOption.color}
733
+ size="xs"
734
+ />
735
+ <span className="min-w-0 truncate">{projectOption.name}</span>
736
+ <span className="ml-auto size-4" />
737
+ </Link>
738
+ </DropdownMenuItem>
739
+ ))}
740
+ <DropdownMenuSeparator />
741
+ <NewProjectDialogLauncher teamSlug={teamSlug}>
742
+ {(open) => (
743
+ <DropdownMenuItem
744
+ className="whitespace-nowrap"
745
+ onSelect={(event) => {
746
+ event.preventDefault()
747
+ open()
748
+ }}
749
+ >
750
+ Add new project
751
+ </DropdownMenuItem>
752
+ )}
753
+ </NewProjectDialogLauncher>
754
+ </DropdownMenuContent>
755
+ </DropdownMenu>
756
+ </div>
757
+ )
758
+ }
759
+
760
+ function DeploymentSwitcher({
761
+ deployment,
762
+ deployments,
763
+ projectName,
764
+ teamSlug,
765
+ }: {
766
+ deployment: {
767
+ id: string
768
+ status?: ProjectContext['deployments'][number]['status']
769
+ }
770
+ deployments: ProjectContext['deployments']
771
+ projectName: string
772
+ teamSlug: string
773
+ }) {
774
+ return (
775
+ <div className="flex min-w-0 flex-1 items-center sm:flex-initial">
776
+ <Button
777
+ asChild
778
+ variant="ghost"
779
+ size="sm"
780
+ className="min-w-0 shrink px-1 sm:px-1.5"
781
+ >
782
+ <Link
783
+ to="/$teamSlug/projects/$projectName/deployments/$deploymentId"
784
+ params={{ teamSlug, projectName, deploymentId: deployment.id }}
785
+ aria-label={`Deployment ${shortId(deployment.id)}: ${deployment.status ?? 'loading'}`}
786
+ >
787
+ <DeploymentStatusIcon
788
+ data-icon="inline-start"
789
+ status={deployment.status}
790
+ />
791
+ <span className="truncate font-mono text-[13px]">
792
+ {shortId(deployment.id)}
793
+ </span>
794
+ </Link>
795
+ </Button>
796
+ <DropdownMenu>
797
+ <DropdownMenuTrigger asChild>
798
+ <Button
799
+ variant="ghost"
800
+ size="icon-sm"
801
+ className="shrink-0 text-muted-foreground max-sm:size-5"
802
+ aria-label="Switch deployment"
803
+ disabled={deployment.status === undefined}
804
+ >
805
+ <ChevronsUpDownIcon />
806
+ </Button>
807
+ </DropdownMenuTrigger>
808
+ <DropdownMenuContent
809
+ align="start"
810
+ side="bottom"
811
+ className="w-auto max-w-[calc(100vw-2rem)]"
812
+ >
813
+ <DropdownMenuItem className="gap-x-2 whitespace-nowrap">
814
+ <DeploymentStatusIcon status={deployment.status} />
815
+ <span className="font-mono text-[13px]">
816
+ {shortId(deployment.id)}
817
+ </span>
818
+ <CheckIcon className="ml-auto" />
819
+ </DropdownMenuItem>
820
+ {deployments
821
+ .filter((deploymentOption) => deploymentOption.id !== deployment.id)
822
+ .slice(0, 5)
823
+ .map((deploymentOption) => (
824
+ <DropdownMenuItem
825
+ asChild
826
+ className="gap-x-2 whitespace-nowrap"
827
+ key={deploymentOption.id}
828
+ >
829
+ <Link
830
+ to="/$teamSlug/projects/$projectName/deployments/$deploymentId"
831
+ params={{
832
+ teamSlug,
833
+ projectName,
834
+ deploymentId: deploymentOption.id,
835
+ }}
836
+ aria-label={`Deployment ${shortId(deploymentOption.id)}: ${deploymentOption.status}`}
837
+ >
838
+ <DeploymentStatusIcon status={deploymentOption.status} />
839
+ <span className="font-mono text-[13px]">
840
+ {shortId(deploymentOption.id)}
841
+ </span>
842
+ <span className="ml-auto size-4" />
843
+ </Link>
844
+ </DropdownMenuItem>
845
+ ))}
846
+ <DropdownMenuSeparator />
847
+ <DropdownMenuItem asChild className="whitespace-nowrap">
848
+ <Link
849
+ to="/$teamSlug/projects/$projectName/deployments"
850
+ params={{ teamSlug, projectName }}
851
+ >
852
+ All deployments
853
+ </Link>
854
+ </DropdownMenuItem>
855
+ </DropdownMenuContent>
856
+ </DropdownMenu>
857
+ </div>
858
+ )
859
+ }
860
+
861
+ function NotificationsMenu({
862
+ invites,
863
+ }: {
864
+ invites: AuthUserContext['invites']
865
+ }) {
866
+ const router = useRouter()
867
+ const queryClient = useQueryClient()
868
+ const [selectedInvite, setSelectedInvite] = useState<
869
+ AuthUserContext['invites'][number] | null
870
+ >(null)
871
+ const [busyInviteId, setBusyInviteId] = useState<string | null>(null)
872
+ const [busyInviteAction, setBusyInviteAction] = useState<
873
+ 'accept' | 'decline' | null
874
+ >(null)
875
+ const invitationLabel =
876
+ invites.length === 1 ? '1 team invitation' : `${invites.length} invitations`
877
+
878
+ async function acceptInvite() {
879
+ if (!selectedInvite || busyInviteId) {
880
+ return
881
+ }
882
+
883
+ setBusyInviteId(selectedInvite.id)
884
+ setBusyInviteAction('accept')
885
+
886
+ try {
887
+ const teamSlug = await acceptUserTeamInviteAction({
888
+ data: { inviteId: selectedInvite.id },
889
+ })
890
+ toast.success('Invitation accepted.')
891
+ setSelectedInvite(null)
892
+ await afterMutation(
893
+ () => invalidateResourceQueries(queryClient),
894
+ 'The action completed, but refreshing the data failed. Please reload.',
895
+ )
896
+ await afterMutation(
897
+ () => router.invalidate(),
898
+ 'The action completed, but refreshing the page failed. Please reload.',
899
+ )
900
+ await afterMutation(
901
+ () => router.navigate({ to: '/$teamSlug', params: { teamSlug } }),
902
+ 'The action completed, but opening the next page failed. Please reload.',
903
+ )
904
+ } catch (error) {
905
+ toast.error(
906
+ getPublicErrorMessage(error, 'Unable to accept invitation.'),
907
+ ...mutationErrorOptions(error),
908
+ )
909
+ } finally {
910
+ setBusyInviteId(null)
911
+ setBusyInviteAction(null)
912
+ }
913
+ }
914
+
915
+ async function declineInvite() {
916
+ if (!selectedInvite || busyInviteId) return
917
+ setBusyInviteId(selectedInvite.id)
918
+ setBusyInviteAction('decline')
919
+ try {
920
+ await declineUserTeamInviteAction({
921
+ data: { inviteId: selectedInvite.id },
922
+ })
923
+ toast.success('Invitation declined.')
924
+ setSelectedInvite(null)
925
+ await afterMutation(async () => {
926
+ await invalidateResourceQueries(queryClient)
927
+ await router.invalidate()
928
+ }, 'Invitation declined, but refreshing the page failed. Please reload.')
929
+ } catch (error) {
930
+ toast.error(
931
+ getPublicErrorMessage(error, 'Unable to decline invitation.'),
932
+ ...mutationErrorOptions(error),
933
+ )
934
+ } finally {
935
+ setBusyInviteId(null)
936
+ setBusyInviteAction(null)
937
+ }
938
+ }
939
+
940
+ return (
941
+ <>
942
+ <DropdownMenu>
943
+ <Tooltip>
944
+ <TooltipTrigger asChild>
945
+ <DropdownMenuTrigger asChild>
946
+ <Button
947
+ variant="ghost"
948
+ size="icon-sm"
949
+ className="relative"
950
+ aria-label="Notifications"
951
+ aria-description={
952
+ invites.length > 0 ? invitationLabel : undefined
953
+ }
954
+ >
955
+ <BellIcon />
956
+ {invites.length > 0 ? (
957
+ <span
958
+ aria-hidden="true"
959
+ className="absolute top-0.5 right-0.5 size-2 rounded-full bg-destructive ring-2 ring-background"
960
+ />
961
+ ) : null}
962
+ </Button>
963
+ </DropdownMenuTrigger>
964
+ </TooltipTrigger>
965
+ <TooltipContent>Notifications</TooltipContent>
966
+ </Tooltip>
967
+ <DropdownMenuContent
968
+ align="end"
969
+ className="w-72 max-w-[calc(100vw-2rem)]"
970
+ >
971
+ {invites.length === 0 ? (
972
+ <p className="flex items-center justify-center px-2 py-1.5 text-sm text-muted-foreground">
973
+ No new notifications
974
+ </p>
975
+ ) : (
976
+ <>
977
+ <DropdownMenuLabel>{invitationLabel}</DropdownMenuLabel>
978
+ <DropdownMenuSeparator />
979
+ {invites.map((invite) => (
980
+ <DropdownMenuItem
981
+ key={invite.id}
982
+ className="whitespace-normal"
983
+ onSelect={() => setSelectedInvite(invite)}
984
+ >
985
+ <span className="flex min-w-0 max-w-64 flex-col gap-1 text-left">
986
+ <span className="flex min-w-0 items-center gap-2">
987
+ <ResourceAvatar
988
+ name={invite.team.name}
989
+ src={invite.team.image ?? undefined}
990
+ color={invite.team.color}
991
+ size="xs"
992
+ />
993
+ <span className="truncate">{invite.team.name}</span>
994
+ </span>
995
+ <span className="truncate pl-7 text-muted-foreground">
996
+ <time dateTime={invite.createdAt}>
997
+ {formatInviteAge(invite.createdAt)}
998
+ </time>
999
+ {invite.inviter
1000
+ ? ` by ${invite.inviter.name || invite.inviter.email}`
1001
+ : ''}
1002
+ </span>
1003
+ </span>
1004
+ </DropdownMenuItem>
1005
+ ))}
1006
+ </>
1007
+ )}
1008
+ </DropdownMenuContent>
1009
+ </DropdownMenu>
1010
+
1011
+ <Dialog
1012
+ open={selectedInvite !== null}
1013
+ onOpenChange={(open) => {
1014
+ if (!open && !busyInviteId) {
1015
+ setSelectedInvite(null)
1016
+ }
1017
+ }}
1018
+ >
1019
+ <DialogContent>
1020
+ <DialogHeader>
1021
+ <DialogTitle>
1022
+ {selectedInvite
1023
+ ? `Invitation to "${selectedInvite.team.name}"`
1024
+ : 'Team invitation'}
1025
+ </DialogTitle>
1026
+ {selectedInvite ? (
1027
+ <DialogDescription>
1028
+ {selectedInvite.inviter ? (
1029
+ <>
1030
+ <strong className="font-medium">
1031
+ {selectedInvite.inviter.name ||
1032
+ selectedInvite.inviter.email}
1033
+ </strong>{' '}
1034
+ invited you
1035
+ </>
1036
+ ) : (
1037
+ 'You have been invited'
1038
+ )}{' '}
1039
+ to join the "
1040
+ <strong className="font-medium">
1041
+ {selectedInvite.team.name}
1042
+ </strong>
1043
+ " team. This invitation will expire on{' '}
1044
+ <strong className="font-medium">
1045
+ {formatInvitationExpiry(selectedInvite.expiresAt)}
1046
+ </strong>
1047
+ .
1048
+ </DialogDescription>
1049
+ ) : null}
1050
+ </DialogHeader>
1051
+ {selectedInvite ? (
1052
+ <dl className="divide-y rounded-lg border text-sm">
1053
+ <div className="grid grid-cols-[6rem_minmax(0,1fr)] items-center gap-x-4 px-4 py-2">
1054
+ <dt className="text-muted-foreground">Team</dt>
1055
+ <dd className="flex min-w-0 items-center gap-2">
1056
+ <ResourceAvatar
1057
+ name={selectedInvite.team.name}
1058
+ src={selectedInvite.team.image ?? undefined}
1059
+ color={selectedInvite.team.color}
1060
+ size="xs"
1061
+ />
1062
+ <span className="min-w-0 truncate">
1063
+ {selectedInvite.team.name}
1064
+ </span>
1065
+ </dd>
1066
+ </div>
1067
+ <div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-4 px-4 py-2">
1068
+ <dt className="text-muted-foreground">Role</dt>
1069
+ <dd>{selectedInvite.role === 'admin' ? 'Admin' : 'Member'}</dd>
1070
+ </div>
1071
+ </dl>
1072
+ ) : null}
1073
+
1074
+ <DialogFooter>
1075
+ <Button
1076
+ type="button"
1077
+ variant="outline"
1078
+ disabled={busyInviteId !== null}
1079
+ onClick={() => void declineInvite()}
1080
+ >
1081
+ {busyInviteAction === 'decline' ? (
1082
+ <LoaderIcon data-icon="inline-start" className="animate-spin" />
1083
+ ) : null}
1084
+ Decline invitation
1085
+ </Button>
1086
+ <Button
1087
+ type="button"
1088
+ variant="ghost"
1089
+ disabled={busyInviteId !== null}
1090
+ onClick={() => setSelectedInvite(null)}
1091
+ >
1092
+ Cancel
1093
+ </Button>
1094
+ <Button
1095
+ type="button"
1096
+ disabled={busyInviteId !== null}
1097
+ onClick={acceptInvite}
1098
+ >
1099
+ {busyInviteAction === 'accept' ? (
1100
+ <LoaderIcon data-icon="inline-start" className="animate-spin" />
1101
+ ) : null}
1102
+ Accept invitation
1103
+ </Button>
1104
+ </DialogFooter>
1105
+ </DialogContent>
1106
+ </Dialog>
1107
+ </>
1108
+ )
1109
+ }
1110
+
1111
+ function UserMenu({ userContext }: { userContext: AuthUserContext }) {
1112
+ const [aboutOpen, setAboutOpen] = useState(false)
1113
+ const { resolvedTheme, setTheme } = useTheme()
1114
+ const { user } = userContext
1115
+ const label = user.name || user.email
1116
+ const initial = label.trim().charAt(0).toLocaleUpperCase() || '?'
1117
+
1118
+ return (
1119
+ <>
1120
+ <NotificationsMenu invites={userContext.invites} />
1121
+ <DropdownMenu>
1122
+ <DropdownMenuTrigger asChild>
1123
+ <Button
1124
+ variant="ghost"
1125
+ size="icon-sm"
1126
+ className="relative rounded-full border-0"
1127
+ aria-label="User menu"
1128
+ >
1129
+ <Avatar className="size-6">
1130
+ {user.image ? (
1131
+ <AvatarImage
1132
+ src={user.image}
1133
+ alt={user.email}
1134
+ referrerPolicy="no-referrer"
1135
+ />
1136
+ ) : null}
1137
+ <AvatarFallback>{initial}</AvatarFallback>
1138
+ </Avatar>
1139
+ </Button>
1140
+ </DropdownMenuTrigger>
1141
+ <DropdownMenuContent
1142
+ align="end"
1143
+ className="w-auto max-w-[calc(100vw-2rem)]"
1144
+ >
1145
+ <DropdownMenuLabel className="px-2.5 py-1.5 text-sm text-foreground">
1146
+ <span className="block max-w-64 truncate font-medium">{label}</span>
1147
+ <span className="block max-w-64 truncate text-muted-foreground">
1148
+ {user.email}
1149
+ </span>
1150
+ </DropdownMenuLabel>
1151
+ <DropdownMenuSeparator />
1152
+ {user.isInstallationAdmin ? (
1153
+ <DropdownMenuItem asChild>
1154
+ <Link
1155
+ to="/admin"
1156
+ search={{
1157
+ allowlistQuery: '',
1158
+ allowlistPage: 1,
1159
+ userQuery: '',
1160
+ userPage: 1,
1161
+ }}
1162
+ >
1163
+ Admin
1164
+ </Link>
1165
+ </DropdownMenuItem>
1166
+ ) : null}
1167
+ <DropdownMenuItem asChild className="whitespace-nowrap">
1168
+ <Link to="/settings">Account</Link>
1169
+ </DropdownMenuItem>
1170
+ <DropdownMenuItem
1171
+ className="whitespace-nowrap"
1172
+ onClick={() =>
1173
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
1174
+ }
1175
+ >
1176
+ <span className="block dark:hidden">Theme: Dark</span>
1177
+ <span className="hidden dark:block">Theme: Light</span>
1178
+ <MoonIcon className="ml-auto block text-muted-foreground dark:hidden" />
1179
+ <SunIcon className="ml-auto hidden text-muted-foreground dark:block" />
1180
+ </DropdownMenuItem>
1181
+ <DropdownMenuSeparator />
1182
+ <DropdownMenuItem asChild>
1183
+ <a
1184
+ href="https://devpu.sh/docs"
1185
+ target="_blank"
1186
+ rel="noreferrer noopener"
1187
+ >
1188
+ Help
1189
+ <ArrowUpRightIcon className="ml-auto opacity-50" />
1190
+ </a>
1191
+ </DropdownMenuItem>
1192
+ <DropdownMenuItem onSelect={() => setAboutOpen(true)}>
1193
+ About devpush
1194
+ </DropdownMenuItem>
1195
+ <DropdownMenuSeparator />
1196
+ <SignOutMenuItem />
1197
+ </DropdownMenuContent>
1198
+ </DropdownMenu>
1199
+ <AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} />
1200
+ </>
1201
+ )
1202
+ }
1203
+
1204
+ function DeploymentStatusIcon({
1205
+ 'data-icon': dataIcon,
1206
+ status,
1207
+ }: {
1208
+ 'data-icon'?: 'inline-start' | 'inline-end'
1209
+ status?: ProjectContext['deployments'][number]['status']
1210
+ }) {
1211
+ if (status === undefined) return <Skeleton className="size-4 shrink-0" />
1212
+ if (status === 'succeeded') {
1213
+ return (
1214
+ <CircleCheckIcon
1215
+ data-icon={dataIcon}
1216
+ className="size-4 shrink-0 text-success"
1217
+ />
1218
+ )
1219
+ }
1220
+
1221
+ if (status === 'failed') {
1222
+ return (
1223
+ <CircleAlertIcon
1224
+ data-icon={dataIcon}
1225
+ className="size-4 shrink-0 text-destructive"
1226
+ />
1227
+ )
1228
+ }
1229
+
1230
+ if (status === 'canceled') {
1231
+ return (
1232
+ <CircleXIcon
1233
+ data-icon={dataIcon}
1234
+ className="size-4 shrink-0 text-muted-foreground"
1235
+ />
1236
+ )
1237
+ }
1238
+
1239
+ if (status === 'queued') {
1240
+ return (
1241
+ <Clock3Icon
1242
+ data-icon={dataIcon}
1243
+ className="size-4 shrink-0 text-muted-foreground"
1244
+ />
1245
+ )
1246
+ }
1247
+
1248
+ return (
1249
+ <LoaderIcon
1250
+ data-icon={dataIcon}
1251
+ className="size-4 shrink-0 animate-spin text-muted-foreground"
1252
+ />
1253
+ )
1254
+ }
1255
+
1256
+ function shortId(id: string) {
1257
+ return id.slice(0, 7)
1258
+ }
1259
+
1260
+ function formatInvitationExpiry(value: string) {
1261
+ return new Intl.DateTimeFormat('en-US', {
1262
+ month: 'long',
1263
+ day: 'numeric',
1264
+ year: 'numeric',
1265
+ }).format(new Date(value))
1266
+ }
1267
+
1268
+ function formatInviteAge(value: string) {
1269
+ const seconds = Math.max(
1270
+ 0,
1271
+ Math.floor((Date.now() - new Date(value).getTime()) / 1000),
1272
+ )
1273
+ if (seconds < 60) return 'just now'
1274
+ const formatter = new Intl.RelativeTimeFormat('en-US', { numeric: 'always' })
1275
+ for (const [unit, duration] of [
1276
+ ['year', 31536000],
1277
+ ['month', 2592000],
1278
+ ['day', 86400],
1279
+ ['hour', 3600],
1280
+ ['minute', 60],
1281
+ ] as const) {
1282
+ if (seconds >= duration)
1283
+ return formatter.format(-Math.floor(seconds / duration), unit)
1284
+ }
1285
+ return 'just now'
1286
+ }