@podosoft/podokit 1.4.2 → 1.6.0

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 (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
package/dist/update.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
- import { type Tier } from "./lockfile";
1
+ import { type TemplateVars, type VfsTree } from "@podosoft/podokit-template-engine";
2
+ import { type Tier, type FilesLock, type ManifestModule, type PodokitManifest } from "./lockfile";
3
3
  import type { ProviderCapability } from "@podosoft/podokit-runtime";
4
4
  import type { Toolchain } from "./toolchain";
5
5
  import { type ApiPathResult } from "./module-api-paths";
@@ -9,7 +9,7 @@ import { type ApiPathResult } from "./module-api-paths";
9
9
  * working copy, classifying each file. Applying the plan (writing files, 3-way
10
10
  * merging) is a later step (see ADR-0009).
11
11
  */
12
- export type Action = "update" | "merge" | "conflict" | "add" | "move" | "remove" | "skip" | "up-to-date";
12
+ export type Action = "review" | "update" | "merge" | "conflict" | "add" | "move" | "remove" | "skip" | "up-to-date";
13
13
  export interface FileChange {
14
14
  path: string;
15
15
  /** Previous path when this change is an update migration move. */
@@ -36,10 +36,38 @@ export interface UpdatePlan {
36
36
  modules: string[];
37
37
  /** Modules the update adds, ordered ahead of the modules that need them. */
38
38
  addedModules: AddedModule[];
39
+ /**
40
+ * External modules whose installed package is older than the version the
41
+ * project last applied. Applying would write the older module's files, so
42
+ * `applyUpdate` refuses while this is not empty.
43
+ */
44
+ moduleDowngrades: ModuleDowngrade[];
39
45
  changes: FileChange[];
40
46
  /** Direct API paths that modules newly declare, per configuration file. */
41
47
  apiPaths: ApiPathResult;
48
+ /**
49
+ * Owned files that differ from this version's template while the lock has no
50
+ * record of the template they came from, so PodoKit cannot tell whether the
51
+ * template changed. Applying the update records it for the next one.
52
+ */
53
+ ownedWithoutTemplate: string[];
42
54
  }
55
+ /** An external module installed at an older version than the project last applied. */
56
+ export interface ModuleDowngrade {
57
+ name: string;
58
+ packageName: string;
59
+ /** Version recorded in the manifest. */
60
+ recorded: string;
61
+ /** Version installed in node_modules. */
62
+ installed: string;
63
+ }
64
+ /** Semantic version order of two exact versions: negative when `a` is older. */
65
+ export declare function compareVersions(a: string, b: string): number;
66
+ /** Owned paths the template and the installed modules declare, without the app's own. */
67
+ export declare function declaredOwnedGlobs(projectRoot: string, templatesDir: string, modules: {
68
+ name: string;
69
+ packageName?: string;
70
+ }[]): string[];
43
71
  /**
44
72
  * Build the update plan. `templatesDir` is the installed CLI's template set (the
45
73
  * new version); the lock records what PodoKit last wrote (to detect user edits).
@@ -52,6 +80,20 @@ export interface PlanOptions {
52
80
  /** Restrict planning to selected paths, used by focused conversions. */
53
81
  onlyPaths?: string[];
54
82
  }
83
+ /** The project as the installed CLI's templates would generate it. */
84
+ export interface TargetProject {
85
+ manifest: PodokitManifest;
86
+ lock: FilesLock;
87
+ /** Recorded modules plus any the target version requires, in configure order. */
88
+ modules: ManifestModule[];
89
+ addedModules: AddedModule[];
90
+ /** External modules installed at an older version than the project applied. */
91
+ moduleDowngrades: ModuleDowngrade[];
92
+ tree: VfsTree;
93
+ ownedGlobs: string[];
94
+ managedOverrides: string[];
95
+ }
96
+ export declare function targetProject(projectRoot: string, templatesDir: string, targetAnswers?: TemplateVars): TargetProject;
55
97
  export declare function planUpdate(projectRoot: string, templatesDir: string, options?: PlanOptions): UpdatePlan;
56
98
  export interface ApplyOptions {
57
99
  /** Templates for the version the project is currently on, for 3-way merges. */
@@ -80,9 +122,23 @@ export interface ApplyResult {
80
122
  merged: string[];
81
123
  /** Files written with conflict markers for manual resolution. */
82
124
  conflicts: string[];
125
+ /** Owned files whose template changed; they are left as they are. */
126
+ review: string[];
83
127
  /** Direct API paths added to `.podokit/dev.json` and deployment profiles. */
84
128
  apiPaths: ApiPathResult;
85
129
  }
130
+ export interface UpstreamChange {
131
+ path: string;
132
+ /** Unified diff from the previous template to the new one; empty when the
133
+ * previous templates did not generate the file. */
134
+ diff: string;
135
+ }
136
+ /**
137
+ * What the template changed between the project's version and this one for
138
+ * each owned file the plan marks for review, given the previous version's
139
+ * templates.
140
+ */
141
+ export declare function upstreamChanges(projectRoot: string, templatesDir: string, plan: UpdatePlan, oldTemplatesDir: string, oldExternalModulesRoot?: string): UpstreamChange[];
86
142
  /**
87
143
  * Apply an update to the working copy. Clean updates and additions are written,
88
144
  * upstream removals deleted, and user-edited files 3-way merged against the old
@@ -91,6 +147,8 @@ export interface ApplyResult {
91
147
  * stamps the new version.
92
148
  */
93
149
  export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
150
+ /** One line per external module installed at an older version than recorded. */
151
+ export declare function describeDowngrades(downgrades: ModuleDowngrade[]): string;
94
152
  /** Why an update adds a module, for the plan report. */
95
153
  export declare function addedModuleReason(module: AddedModule): string;
96
154
  /** Count actions for a one-line summary. */
package/dist/update.js CHANGED
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compareVersions = compareVersions;
4
+ exports.declaredOwnedGlobs = declaredOwnedGlobs;
5
+ exports.targetProject = targetProject;
3
6
  exports.planUpdate = planUpdate;
7
+ exports.upstreamChanges = upstreamChanges;
4
8
  exports.applyUpdate = applyUpdate;
9
+ exports.describeDowngrades = describeDowngrades;
5
10
  exports.addedModuleReason = addedModuleReason;
6
11
  exports.summarize = summarize;
7
12
  const node_child_process_1 = require("node:child_process");
@@ -10,6 +15,7 @@ const node_os_1 = require("node:os");
10
15
  const node_path_1 = require("node:path");
11
16
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
12
17
  const assemble_1 = require("./assemble");
18
+ const text_diff_1 = require("./text-diff");
13
19
  const lockfile_1 = require("./lockfile");
14
20
  const inspect_1 = require("./inspect");
15
21
  const add_1 = require("./add");
@@ -25,6 +31,31 @@ function diskContent(projectRoot, path) {
25
31
  const abs = (0, node_path_1.join)(projectRoot, path);
26
32
  return (0, node_fs_1.existsSync)(abs) ? (0, node_fs_1.readFileSync)(abs) : null;
27
33
  }
34
+ function versionParts(version) {
35
+ const [withoutBuild = ""] = version.split("+");
36
+ const dash = withoutBuild.indexOf("-");
37
+ const core = (dash === -1 ? withoutBuild : withoutBuild.slice(0, dash))
38
+ .split(".")
39
+ .map((part) => Number.parseInt(part, 10) || 0);
40
+ return { core, prerelease: dash === -1 ? null : withoutBuild.slice(dash + 1) };
41
+ }
42
+ /** Semantic version order of two exact versions: negative when `a` is older. */
43
+ function compareVersions(a, b) {
44
+ const left = versionParts(a);
45
+ const right = versionParts(b);
46
+ for (let index = 0; index < Math.max(left.core.length, right.core.length); index += 1) {
47
+ const difference = (left.core[index] ?? 0) - (right.core[index] ?? 0);
48
+ if (difference !== 0)
49
+ return difference;
50
+ }
51
+ if (left.prerelease === right.prerelease)
52
+ return 0;
53
+ if (left.prerelease === null)
54
+ return 1;
55
+ if (right.prerelease === null)
56
+ return -1;
57
+ return left.prerelease.localeCompare(right.prerelease, "en", { numeric: true });
58
+ }
28
59
  /**
29
60
  * Expand the recorded module list against the target manifests. A module may
30
61
  * gain a new requirement after it was first added to a project, either another
@@ -39,6 +70,7 @@ function targetModules(projectRoot, templatesDir, modules, providers) {
39
70
  const recorded = new Map(modules.map((module) => [module.name, module]));
40
71
  const ordered = [];
41
72
  const added = [];
73
+ const downgrades = [];
42
74
  const visiting = new Set();
43
75
  const visited = new Set();
44
76
  const visit = (name, requiredBy, capability) => {
@@ -54,6 +86,17 @@ function targetModules(projectRoot, templatesDir, modules, providers) {
54
86
  ? `Module "${requiredBy}" requires unknown module "${name}".`
55
87
  : `Cannot resolve module "${name}" while updating the project.`);
56
88
  }
89
+ if (existing?.moduleVersion &&
90
+ resolved.packageName &&
91
+ resolved.moduleVersion &&
92
+ compareVersions(resolved.moduleVersion, existing.moduleVersion) < 0) {
93
+ downgrades.push({
94
+ name: resolved.name,
95
+ packageName: resolved.packageName,
96
+ recorded: existing.moduleVersion,
97
+ installed: resolved.moduleVersion,
98
+ });
99
+ }
57
100
  visiting.add(name);
58
101
  const moduleManifest = (0, add_1.readModuleManifest)(resolved.dir);
59
102
  for (const required of moduleManifest.requires ?? [])
@@ -89,7 +132,7 @@ function targetModules(projectRoot, templatesDir, modules, providers) {
89
132
  for (const module of [...modules].sort((a, b) => a.order - b.order)) {
90
133
  visit(module.name);
91
134
  }
92
- return { modules: ordered, added };
135
+ return { modules: ordered, added, downgrades };
93
136
  }
94
137
  function targetApiPaths(projectRoot, templatesDir, modules) {
95
138
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
@@ -104,7 +147,7 @@ function targetApiPaths(projectRoot, templatesDir, modules) {
104
147
  return { declared, pending };
105
148
  }
106
149
  function targetManagedOverrides(projectRoot, templatesDir, modules, current = []) {
107
- const overrides = new Set(current);
150
+ const overrides = new Set([...lockfile_1.DEFAULT_MANAGED_OVERRIDES, ...current]);
108
151
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
109
152
  for (const module of modules) {
110
153
  const resolved = (0, add_1.resolveModule)(module.packageName ?? module.name, modulesDir, projectRoot);
@@ -127,21 +170,36 @@ function targetOwnedGlobs(projectRoot, templatesDir, modules, current) {
127
170
  }
128
171
  return [...owned];
129
172
  }
130
- function planUpdate(projectRoot, templatesDir, options = {}) {
173
+ /** Owned paths the template and the installed modules declare, without the app's own. */
174
+ function declaredOwnedGlobs(projectRoot, templatesDir, modules) {
175
+ return targetOwnedGlobs(projectRoot, templatesDir, modules, []);
176
+ }
177
+ function targetProject(projectRoot, templatesDir, targetAnswers) {
131
178
  const manifest = (0, lockfile_1.readManifest)(projectRoot);
132
179
  const lock = (0, lockfile_1.readFilesLock)(projectRoot);
133
180
  if (!manifest || !lock)
134
181
  throw new inspect_1.NotAProjectError();
135
- const { modules, added } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
136
- const newTree = (0, assemble_1.assembleProject)({
182
+ const { modules, added, downgrades } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
183
+ const tree = (0, assemble_1.assembleProject)({
137
184
  templatesDir,
138
185
  template: manifest.template,
139
- answers: options.targetAnswers ?? (0, lockfile_1.manifestTemplateVars)(manifest),
186
+ answers: targetAnswers ?? (0, lockfile_1.manifestTemplateVars)(manifest),
140
187
  modules,
141
188
  projectRoot,
142
189
  });
143
- const managedOverrides = targetManagedOverrides(projectRoot, templatesDir, modules, manifest.managedOverrides);
144
- const ownedGlobs = targetOwnedGlobs(projectRoot, templatesDir, modules, manifest.ownedGlobs);
190
+ return {
191
+ manifest,
192
+ lock,
193
+ modules,
194
+ addedModules: added,
195
+ moduleDowngrades: downgrades,
196
+ tree,
197
+ ownedGlobs: targetOwnedGlobs(projectRoot, templatesDir, modules, manifest.ownedGlobs),
198
+ managedOverrides: targetManagedOverrides(projectRoot, templatesDir, modules, manifest.managedOverrides),
199
+ };
200
+ }
201
+ function planUpdate(projectRoot, templatesDir, options = {}) {
202
+ const { manifest, lock, modules, addedModules: added, moduleDowngrades, tree: newTree, ownedGlobs, managedOverrides, } = targetProject(projectRoot, templatesDir, options.targetAnswers);
145
203
  const pendingMigrations = (0, module_migrations_1.collectPendingMigrations)(projectRoot, templatesDir, modules);
146
204
  const migrationPlan = (0, module_migrations_1.planModuleMigrations)(projectRoot, newTree, lock, pendingMigrations);
147
205
  const changes = migrationPlan.moves.map((move) => ({
@@ -159,6 +217,7 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
159
217
  note: conflict,
160
218
  });
161
219
  }
220
+ const ownedWithoutTemplate = [];
162
221
  const movedSources = new Set(migrationPlan.moves.map((move) => move.from));
163
222
  const movedTargets = new Set(migrationPlan.moves.map((move) => move.to));
164
223
  const paths = new Set([...newTree.keys(), ...Object.keys(lock.files)]);
@@ -192,6 +251,24 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
192
251
  changes.push({ path, tier, action: "add", note: "new owned seed in this version" });
193
252
  continue;
194
253
  }
254
+ if (newText !== null && disk !== null) {
255
+ const newHash = (0, podokit_template_engine_1.hashContent)(newText);
256
+ const recorded = (0, lockfile_1.recordedTemplateHash)(locked);
257
+ if ((0, podokit_template_engine_1.hashContent)(disk) !== newHash) {
258
+ if (recorded && recorded !== newHash) {
259
+ changes.push({
260
+ path,
261
+ tier,
262
+ action: "review",
263
+ note: `owned; the template changed, see podo diff ${path}`,
264
+ });
265
+ continue;
266
+ }
267
+ // The lock never records files under .podokit/, so only judge tracked files.
268
+ if (!recorded && locked)
269
+ ownedWithoutTemplate.push(path);
270
+ }
271
+ }
195
272
  const explicitlyOwned = ownedGlobs.some((glob) => !glob.includes("*") && (0, lockfile_1.matchGlob)(path, glob));
196
273
  changes.push({
197
274
  path,
@@ -251,8 +328,10 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
251
328
  template: manifest.template,
252
329
  modules: modules.map((module) => module.name),
253
330
  addedModules: added,
331
+ moduleDowngrades,
254
332
  changes,
255
333
  apiPaths,
334
+ ownedWithoutTemplate,
256
335
  };
257
336
  }
258
337
  function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
@@ -297,6 +376,48 @@ function previousExternalModules(projectRoot, templatesDir, modules, providedRoo
297
376
  }
298
377
  return { root, cleanup: true };
299
378
  }
379
+ /** The project as the templates of the version it is on generated it. */
380
+ function previousTree(projectRoot, manifest, oldTemplatesDir, oldExternalModulesRoot) {
381
+ const previousModules = previousExternalModules(projectRoot, oldTemplatesDir, manifest.modules, oldExternalModulesRoot);
382
+ try {
383
+ return (0, assemble_1.assembleProject)({
384
+ templatesDir: oldTemplatesDir,
385
+ template: manifest.template,
386
+ answers: (0, lockfile_1.manifestTemplateVars)(manifest),
387
+ modules: manifest.modules,
388
+ projectRoot: previousModules.root,
389
+ });
390
+ }
391
+ finally {
392
+ if (previousModules.cleanup)
393
+ (0, node_fs_1.rmSync)(previousModules.root, { recursive: true, force: true });
394
+ }
395
+ }
396
+ /**
397
+ * What the template changed between the project's version and this one for
398
+ * each owned file the plan marks for review, given the previous version's
399
+ * templates.
400
+ */
401
+ function upstreamChanges(projectRoot, templatesDir, plan, oldTemplatesDir, oldExternalModulesRoot) {
402
+ const review = plan.changes.filter((change) => change.action === "review");
403
+ if (!review.length)
404
+ return [];
405
+ const target = targetProject(projectRoot, templatesDir);
406
+ const oldTree = previousTree(projectRoot, target.manifest, oldTemplatesDir, oldExternalModulesRoot);
407
+ return review.map((change) => {
408
+ const before = treeText(oldTree, change.path);
409
+ const after = treeText(target.tree, change.path) ?? "";
410
+ return {
411
+ path: change.path,
412
+ diff: before === null
413
+ ? ""
414
+ : (0, text_diff_1.unifiedDiff)(before, after, {
415
+ fromLabel: `podokit-${plan.fromVersion}/${change.path}`,
416
+ toLabel: `podokit-${plan.toVersion}/${change.path}`,
417
+ }),
418
+ };
419
+ });
420
+ }
300
421
  function writeFile(projectRoot, path, content) {
301
422
  const abs = (0, node_path_1.join)(projectRoot, path);
302
423
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(abs), { recursive: true });
@@ -328,6 +449,8 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
328
449
  }
329
450
  entry.outHash = (0, podokit_template_engine_1.hashContent)(newText);
330
451
  }
452
+ if (entry.tier === "owned")
453
+ entry.templateHash = (0, podokit_template_engine_1.hashContent)(newText);
331
454
  continue;
332
455
  }
333
456
  if (entry.tier === "owned")
@@ -344,16 +467,19 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
344
467
  delete next.files[path];
345
468
  }
346
469
  }
347
- // Keep a tombstone for an owned seed that the application deleted or moved.
348
- // Without the previous entry, the next update would mistake the missing path
349
- // for a newly introduced seed and recreate it.
470
+ // Keep a tombstone for an owned seed that the application deleted or moved,
471
+ // including a file this update turns from managed into owned. Without the
472
+ // entry, the next update would mistake the missing path for a newly
473
+ // introduced seed and recreate it.
350
474
  for (const [path, entry] of Object.entries(previous.files)) {
351
- if (entry.tier !== "owned" || next.files[path])
475
+ if (next.files[path])
352
476
  continue;
353
477
  const newText = treeText(newTree, path);
354
478
  if (newText !== null &&
355
479
  (0, lockfile_1.classifyTier)(path, newText, ownedGlobs, managedOverrides) === "owned") {
356
- next.files[path] = entry;
480
+ next.files[path] = entry.tier === "owned"
481
+ ? entry
482
+ : { tier: "owned", outHash: entry.outHash, templateHash: (0, podokit_template_engine_1.hashContent)(newText) };
357
483
  }
358
484
  }
359
485
  return next;
@@ -375,6 +501,10 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
375
501
  forceManagedPaths: options.forceManagedPaths,
376
502
  onlyPaths: options.onlyPaths,
377
503
  });
504
+ if (plan.moduleDowngrades.length) {
505
+ throw new Error(`Refusing to downgrade external modules:\n${describeDowngrades(plan.moduleDowngrades)}\n` +
506
+ "Install at least the recorded version (for example bun install), then run podo update again.");
507
+ }
378
508
  const { modules } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
379
509
  const newTree = (0, assemble_1.assembleProject)({
380
510
  templatesDir,
@@ -401,20 +531,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
401
531
  const needsMergeBase = plan.changes.some((change) => change.action === "conflict");
402
532
  let oldTree = null;
403
533
  if (options.oldTemplatesDir && needsMergeBase) {
404
- const previousModules = previousExternalModules(projectRoot, options.oldTemplatesDir, manifest.modules, options.oldExternalModulesRoot);
405
- try {
406
- oldTree = (0, assemble_1.assembleProject)({
407
- templatesDir: options.oldTemplatesDir,
408
- template: manifest.template,
409
- answers: (0, lockfile_1.manifestTemplateVars)(manifest),
410
- modules: manifest.modules,
411
- projectRoot: previousModules.root,
412
- });
413
- }
414
- finally {
415
- if (previousModules.cleanup)
416
- (0, node_fs_1.rmSync)(previousModules.root, { recursive: true, force: true });
417
- }
534
+ oldTree = previousTree(projectRoot, manifest, options.oldTemplatesDir, options.oldExternalModulesRoot);
418
535
  }
419
536
  if (options.abortOnConflict) {
420
537
  const unresolved = [];
@@ -444,6 +561,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
444
561
  removed: [],
445
562
  merged: [],
446
563
  conflicts: [],
564
+ review: plan.changes.filter((change) => change.action === "review").map((change) => change.path),
447
565
  apiPaths: { changed: [], skipped: [] },
448
566
  };
449
567
  for (const change of plan.changes) {
@@ -523,6 +641,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
523
641
  });
524
642
  return result;
525
643
  }
644
+ /** One line per external module installed at an older version than recorded. */
645
+ function describeDowngrades(downgrades) {
646
+ return downgrades
647
+ .map((entry) => ` ${entry.packageName} ${entry.installed} is installed; the project applied ${entry.recorded}`)
648
+ .join("\n");
649
+ }
526
650
  /** Why an update adds a module, for the plan report. */
527
651
  function addedModuleReason(module) {
528
652
  return module.capability
@@ -532,6 +656,7 @@ function addedModuleReason(module) {
532
656
  /** Count actions for a one-line summary. */
533
657
  function summarize(plan) {
534
658
  const counts = {
659
+ review: 0,
535
660
  update: 0,
536
661
  merge: 0,
537
662
  conflict: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@podosoft/podokit-runtime": "^0.4.0",
48
+ "@podosoft/podokit-runtime": "^0.5.0",
49
49
  "@podosoft/podokit-template-engine": "^0.4.2"
50
50
  }
51
51
  }
@@ -1,75 +0,0 @@
1
- import { expect, test } from "@playwright/test";
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, utimesSync, writeFileSync } from "node:fs";
4
- import { rm } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { exclusive, lockPath } from "../helpers/exclusive";
7
-
8
- // Checks the lock that specs changing shared settings hold. Names are unique per
9
- // run so these never wait on, or release, a lock a real spec holds.
10
- const unique = (label: string): string => `self-test-${label}-${process.pid}-${Date.now()}`;
11
-
12
- test("exclusive releases its locks when the body fails", async () => {
13
- const first = unique("first");
14
- const second = unique("second");
15
- await expect(
16
- exclusive([second, first], async () => {
17
- expect(existsSync(lockPath(first))).toBe(true);
18
- expect(existsSync(lockPath(second))).toBe(true);
19
- throw new Error("body failed");
20
- }),
21
- ).rejects.toThrow("body failed");
22
- expect(existsSync(lockPath(first))).toBe(false);
23
- expect(existsSync(lockPath(second))).toBe(false);
24
-
25
- // The next holder gets the lock without waiting.
26
- const started = Date.now();
27
- await exclusive(first, async () => undefined);
28
- expect(Date.now() - started).toBeLessThan(1_000);
29
- });
30
-
31
- test("exclusive runs holders of one lock one at a time", async () => {
32
- const name = unique("serial");
33
- const events: string[] = [];
34
- const hold = (id: string): Promise<void> =>
35
- exclusive(name, async () => {
36
- events.push(`${id} start`);
37
- await new Promise((resolve) => setTimeout(resolve, 100));
38
- events.push(`${id} end`);
39
- });
40
- await Promise.all([hold("a"), hold("b")]);
41
- expect(events.map((event) => event.split(" ")[1])).toEqual(["start", "end", "start", "end"]);
42
- expect(existsSync(lockPath(name))).toBe(false);
43
- });
44
-
45
- test("exclusive takes over a lock left by a run that was killed", async () => {
46
- const name = unique("stale");
47
- mkdirSync(lockPath(name));
48
- const longAgo = new Date(Date.now() - 10 * 60_000);
49
- utimesSync(lockPath(name), longAgo, longAgo);
50
- await expect(exclusive(name, async () => "acquired")).resolves.toBe("acquired");
51
- expect(existsSync(lockPath(name))).toBe(false);
52
- });
53
-
54
- test("exclusive takes over a lock whose holder exited, such as after Ctrl-C", async () => {
55
- const name = unique("exited");
56
- const exited = spawnSync(process.execPath, ["-e", ""]).pid;
57
- mkdirSync(lockPath(name));
58
- writeFileSync(join(lockPath(name), "owner"), String(exited));
59
- const started = Date.now();
60
- await expect(exclusive(name, async () => "acquired")).resolves.toBe("acquired");
61
- expect(Date.now() - started).toBeLessThan(1_000);
62
- expect(existsSync(lockPath(name))).toBe(false);
63
- });
64
-
65
- test("exclusive waits for a lock whose holder is still running", async () => {
66
- const name = unique("alive");
67
- mkdirSync(lockPath(name));
68
- writeFileSync(join(lockPath(name), "owner"), String(process.pid));
69
- const waiting = exclusive(name, async () => "acquired");
70
- await new Promise((resolve) => setTimeout(resolve, 500));
71
- expect(existsSync(lockPath(name))).toBe(true);
72
- // The holder finishes; the waiter then gets the lock.
73
- await rm(lockPath(name), { recursive: true, force: true });
74
- await expect(waiting).resolves.toBe("acquired");
75
- });
@@ -1,80 +0,0 @@
1
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- // Locks for server-wide settings that specs in more than one file change. The
6
- // suite runs on one worker by default; with --workers above 1, specs of the api
7
- // and ui projects run at the same time, and two that flip the same setting
8
- // would undo each other mid-test:
9
- //
10
- // - `require2fa`, `magic-link`, `oidc-provider`: feature flags
11
- // - `session-idle-timeout`: automatic logout in the auth configuration
12
- // - `social-providers`: any social sign-in provider in the auth configuration
13
- // - `site-general`: the General settings form, which saves all of its fields
14
- // (name, footer, locale, maintenance mode, public sign-up, ...) at once
15
- // - `site-theme`: the Appearance settings
16
- //
17
- // A spec that holds a lock calls `test.slow()`, since waiting for another
18
- // holder counts against its timeout.
19
-
20
- // Longer than any spec holds a lock. A lock whose holder process has exited is
21
- // taken over at once; this bounds the wait when the holder cannot be checked.
22
- const STALE_MS = 120_000;
23
- const OWNER_FILE = "owner";
24
-
25
- /** The lock directory for `name`, shared by every worker on this machine. */
26
- export function lockPath(name: string): string {
27
- return join(tmpdir(), `podokit-e2e-${name}.lock`);
28
- }
29
-
30
- /** Whether the worker that holds `lock` has exited, such as after Ctrl-C. */
31
- async function holderExited(lock: string): Promise<boolean> {
32
- const pid = Number(await readFile(join(lock, OWNER_FILE), "utf8").catch(() => ""));
33
- if (!Number.isInteger(pid) || pid <= 0) return false;
34
- try {
35
- process.kill(pid, 0);
36
- return false;
37
- } catch (error) {
38
- return (error as NodeJS.ErrnoException).code === "ESRCH";
39
- }
40
- }
41
-
42
- async function acquire(lock: string): Promise<void> {
43
- for (;;) {
44
- const created = await mkdir(lock).then(
45
- () => true,
46
- (error: NodeJS.ErrnoException) => {
47
- if (error.code === "EEXIST") return false;
48
- throw error;
49
- },
50
- );
51
- if (created) {
52
- await writeFile(join(lock, OWNER_FILE), String(process.pid)).catch(async (error: unknown) => {
53
- await rm(lock, { recursive: true, force: true });
54
- throw error;
55
- });
56
- return;
57
- }
58
- const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
59
- if (age > STALE_MS || (await holderExited(lock))) await rm(lock, { recursive: true, force: true });
60
- else await new Promise((resolve) => setTimeout(resolve, 200));
61
- }
62
- }
63
-
64
- /** Runs `body` while no other Playwright worker on this machine holds any of
65
- * the named locks, and releases them however `body` ends. */
66
- export async function exclusive<T>(names: string | string[], body: () => Promise<T>): Promise<T> {
67
- // One acquisition order for every caller, so two specs that each need
68
- // several locks cannot wait on each other.
69
- const locks = [...new Set([names].flat())].sort().map(lockPath);
70
- const held: string[] = [];
71
- try {
72
- for (const lock of locks) {
73
- await acquire(lock);
74
- held.push(lock);
75
- }
76
- return await body();
77
- } finally {
78
- for (const lock of held.reverse()) await rm(lock, { recursive: true, force: true });
79
- }
80
- }