kempo 4.8.0 → 4.9.1

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 (251) hide show
  1. package/README.md +7 -3
  2. package/bin/cli.js +23 -1
  3. package/dist/admin/component-overrides.js +1 -1
  4. package/dist/admin/components/AddGroupControl.js +1 -1
  5. package/dist/admin/components/AdminTableControl.js +1 -1
  6. package/dist/admin/components/ClearExpiredSessionsControl.js +1 -1
  7. package/dist/admin/components/Dashboard.js +1 -1
  8. package/dist/admin/components/EmailTemplates.js +1 -1
  9. package/dist/admin/components/Emails.js +1 -1
  10. package/dist/admin/components/FragmentEditor.js +1 -1
  11. package/dist/admin/components/GlobalContentEditor.js +1 -1
  12. package/dist/admin/components/GroupDeleteSelected.js +1 -1
  13. package/dist/admin/components/GroupRemoveRecord.js +1 -1
  14. package/dist/admin/components/Menus.js +1 -1
  15. package/dist/admin/components/Monaco.js +1 -1
  16. package/dist/admin/components/PageEditor.js +1 -1
  17. package/dist/admin/components/TableRowDelete.js +1 -1
  18. package/dist/admin/components/TableRowLink.js +1 -1
  19. package/dist/admin/components/TemplateEditor.js +1 -1
  20. package/dist/admin/components/Updates.js +117 -0
  21. package/dist/admin/components/Updates.js.map +1 -0
  22. package/dist/admin/components/ValueEditor.js +1 -1
  23. package/dist/admin/components/dashboardLayout.js +1 -1
  24. package/dist/admin/components/settings/AdminSettings.js +1 -1
  25. package/dist/admin/components/settings/BooleanSetting.js +1 -1
  26. package/dist/admin/components/settings/JsonSetting.js +1 -1
  27. package/dist/admin/components/settings/NumberSetting.js +1 -1
  28. package/dist/admin/components/settings/SecretSetting.js +1 -1
  29. package/dist/admin/components/settings/Setting.js +1 -1
  30. package/dist/admin/components/settings/SettingEditor.js +1 -1
  31. package/dist/admin/components/settings/SettingsPanel.js +1 -1
  32. package/dist/admin/components/settings/StringSetting.js +1 -1
  33. package/dist/admin/components/settings/humanize.js +1 -1
  34. package/dist/admin/components/tableControls/AddMember.js +1 -1
  35. package/dist/admin/components/tableControls/AddPermission.js +1 -1
  36. package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
  37. package/dist/admin/components/tableControls/InstallExtension.js +1 -1
  38. package/dist/admin/components/tableControls/ToggleExtension.js +1 -1
  39. package/dist/admin/components/tableControls/UninstallExtension.js +1 -1
  40. package/dist/admin/components/widgets/Content.js +1 -1
  41. package/dist/admin/components/widgets/Extensions.js +1 -1
  42. package/dist/admin/components/widgets/Notifications.js +1 -1
  43. package/dist/admin/components/widgets/Pages.js +1 -1
  44. package/dist/admin/components/widgets/Users.js +1 -1
  45. package/dist/admin/init.js +1 -1
  46. package/dist/admin/nav.fragment.html +6 -0
  47. package/dist/admin/updates/index.page.html +14 -0
  48. package/dist/kempo/api/admin-globals/DELETE.js +1 -1
  49. package/dist/kempo/api/admin-globals/GET.js +1 -1
  50. package/dist/kempo/api/admin-globals/POST.js +1 -1
  51. package/dist/kempo/api/admin-globals/[id]/GET.js +1 -1
  52. package/dist/kempo/api/admin-globals/[id]/PUT.js +1 -1
  53. package/dist/kempo/api/admin-globals/[id]/disable/PUT.js +1 -1
  54. package/dist/kempo/api/admin-globals/[id]/enable/PUT.js +1 -1
  55. package/dist/kempo/api/auth/change-password/POST.js +1 -1
  56. package/dist/kempo/api/auth/login/email/POST.js +1 -1
  57. package/dist/kempo/api/auth/logout/POST.js +1 -1
  58. package/dist/kempo/api/auth/register/email/POST.js +1 -1
  59. package/dist/kempo/api/auth/reset-password/POST.js +1 -1
  60. package/dist/kempo/api/auth/session/GET.js +1 -1
  61. package/dist/kempo/api/auth/verify-email/POST.js +1 -1
  62. package/dist/kempo/api/dashboard/widgets/GET.js +1 -1
  63. package/dist/kempo/api/email-templates/GET.js +1 -1
  64. package/dist/kempo/api/email-templates/file/DELETE.js +1 -1
  65. package/dist/kempo/api/email-templates/file/GET.js +1 -1
  66. package/dist/kempo/api/email-templates/file/PUT.js +1 -1
  67. package/dist/kempo/api/email-templates/preview/GET.js +1 -1
  68. package/dist/kempo/api/emails/GET.js +1 -1
  69. package/dist/kempo/api/emails/[id]/GET.js +1 -1
  70. package/dist/kempo/api/emails/[id]/retry/POST.js +1 -1
  71. package/dist/kempo/api/emails/test/POST.js +1 -1
  72. package/dist/kempo/api/extensions/DELETE.js +1 -1
  73. package/dist/kempo/api/extensions/GET.js +1 -1
  74. package/dist/kempo/api/extensions/POST.js +1 -1
  75. package/dist/kempo/api/extensions/[name]/dependents/GET.js +1 -1
  76. package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
  77. package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
  78. package/dist/kempo/api/extensions/[name]/requirements/GET.js +1 -1
  79. package/dist/kempo/api/extensions/[name]/update/POST.js +1 -1
  80. package/dist/kempo/api/extensions/[name]/update-check/GET.js +1 -1
  81. package/dist/kempo/api/extensions/available/GET.js +1 -1
  82. package/dist/kempo/api/extensions/known/GET.js +1 -1
  83. package/dist/kempo/api/extensions/rating/PUT.js +1 -1
  84. package/dist/kempo/api/forgot-password/POST.js +1 -1
  85. package/dist/kempo/api/fragments/DELETE.js +1 -1
  86. package/dist/kempo/api/fragments/GET.js +1 -1
  87. package/dist/kempo/api/fragments/POST.js +1 -1
  88. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  89. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  90. package/dist/kempo/api/fragments/file/GET.js +1 -1
  91. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  92. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  93. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  94. package/dist/kempo/api/globals/DELETE.js +1 -1
  95. package/dist/kempo/api/globals/GET.js +1 -1
  96. package/dist/kempo/api/globals/POST.js +1 -1
  97. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  98. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  99. package/dist/kempo/api/globals/entry/GET.js +1 -1
  100. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  101. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  102. package/dist/kempo/api/groups/DELETE.js +1 -1
  103. package/dist/kempo/api/groups/GET.js +1 -1
  104. package/dist/kempo/api/groups/PATCH.js +1 -1
  105. package/dist/kempo/api/groups/POST.js +1 -1
  106. package/dist/kempo/api/groups/[name]/GET.js +1 -1
  107. package/dist/kempo/api/groups/[name]/members/DELETE.js +1 -1
  108. package/dist/kempo/api/groups/[name]/members/GET.js +1 -1
  109. package/dist/kempo/api/groups/[name]/members/POST.js +1 -1
  110. package/dist/kempo/api/groups/[name]/permissions/DELETE.js +1 -1
  111. package/dist/kempo/api/groups/[name]/permissions/GET.js +1 -1
  112. package/dist/kempo/api/groups/[name]/permissions/POST.js +1 -1
  113. package/dist/kempo/api/menus/DELETE.js +1 -1
  114. package/dist/kempo/api/menus/GET.js +1 -1
  115. package/dist/kempo/api/menus/POST.js +1 -1
  116. package/dist/kempo/api/menus/adopt/PUT.js +1 -1
  117. package/dist/kempo/api/menus/items/DELETE.js +1 -1
  118. package/dist/kempo/api/menus/items/POST.js +1 -1
  119. package/dist/kempo/api/menus/items/enabled/PUT.js +1 -1
  120. package/dist/kempo/api/menus/items/order/PUT.js +1 -1
  121. package/dist/kempo/api/menus/location/PUT.js +1 -1
  122. package/dist/kempo/api/notifications/GET.js +1 -1
  123. package/dist/kempo/api/notifications/[id]/DELETE.js +1 -1
  124. package/dist/kempo/api/notifications/[id]/handled/POST.js +1 -1
  125. package/dist/kempo/api/notifications/[id]/read/POST.js +1 -1
  126. package/dist/kempo/api/notifications/count/GET.js +1 -1
  127. package/dist/kempo/api/notifications/read-all/POST.js +1 -1
  128. package/dist/kempo/api/pages/DELETE.js +1 -1
  129. package/dist/kempo/api/pages/GET.js +1 -1
  130. package/dist/kempo/api/pages/POST.js +1 -1
  131. package/dist/kempo/api/pages/directories/GET.js +1 -1
  132. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  133. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  134. package/dist/kempo/api/pages/file/GET.js +1 -1
  135. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  136. package/dist/kempo/api/pages/file/PUT.js +1 -1
  137. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  138. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  139. package/dist/kempo/api/pages/search/GET.js +1 -1
  140. package/dist/kempo/api/pages/templates/GET.js +1 -1
  141. package/dist/kempo/api/permissions/DELETE.js +1 -1
  142. package/dist/kempo/api/permissions/GET.js +1 -1
  143. package/dist/kempo/api/permissions/PATCH.js +1 -1
  144. package/dist/kempo/api/permissions/POST.js +1 -1
  145. package/dist/kempo/api/realtime/WS.js +1 -1
  146. package/dist/kempo/api/realtime/connections/GET.js +1 -1
  147. package/dist/kempo/api/settings/[owner]/GET.js +1 -1
  148. package/dist/kempo/api/settings/[owner]/[name]/DELETE.js +1 -1
  149. package/dist/kempo/api/settings/[owner]/[name]/GET.js +1 -1
  150. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  151. package/dist/kempo/api/settings/public/GET.js +1 -1
  152. package/dist/kempo/api/templates/DELETE.js +1 -1
  153. package/dist/kempo/api/templates/GET.js +1 -1
  154. package/dist/kempo/api/templates/POST.js +1 -1
  155. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  156. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  157. package/dist/kempo/api/templates/file/GET.js +1 -1
  158. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  159. package/dist/kempo/api/templates/file/PUT.js +1 -1
  160. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  161. package/dist/kempo/api/updates/GET.js +2 -0
  162. package/dist/kempo/api/updates/GET.js.map +1 -0
  163. package/dist/kempo/api/updates/apply/POST.js +2 -0
  164. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  165. package/dist/kempo/api/updates/check/POST.js +2 -0
  166. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  167. package/dist/kempo/api/updates/discard/POST.js +2 -0
  168. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  169. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  170. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  171. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  172. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  173. package/dist/kempo/api/user/DELETE.js +1 -1
  174. package/dist/kempo/api/user/GET.js +1 -1
  175. package/dist/kempo/api/user/PATCH.js +1 -1
  176. package/dist/kempo/api/user/[userid]/GET.js +1 -1
  177. package/dist/kempo/api/user/[userid]/groups/DELETE.js +1 -1
  178. package/dist/kempo/api/user/[userid]/groups/GET.js +1 -1
  179. package/dist/kempo/api/user/[userid]/groups/POST.js +1 -1
  180. package/dist/kempo/api/user/[userid]/name/GET.js +1 -1
  181. package/dist/kempo/api/user/[userid]/permissions/GET.js +1 -1
  182. package/dist/kempo/api/user/[userid]/permissions/all/GET.js +1 -1
  183. package/dist/kempo/api/user/[userid]/permissions/list/GET.js +1 -1
  184. package/dist/kempo/api/user/[userid]/permissions/some/GET.js +1 -1
  185. package/dist/kempo/api/user/[userid]/sessions/DELETE.js +1 -1
  186. package/dist/kempo/api/user/[userid]/sessions/GET.js +1 -1
  187. package/dist/kempo/api/user/[userid]/sessions/expired/DELETE.js +1 -1
  188. package/dist/kempo/api/user/create/POST.js +1 -1
  189. package/dist/kempo/api/user/search/GET.js +1 -1
  190. package/dist/kempo/components/AdminNavFooter.js +1 -1
  191. package/dist/kempo/components/DashboardWidget.js +1 -1
  192. package/dist/kempo/components/Login.js +1 -1
  193. package/dist/kempo/components/LogoutBtn.js +1 -1
  194. package/dist/kempo/components/Menu.js +1 -1
  195. package/dist/kempo/components/NotificationBell.js +1 -1
  196. package/dist/kempo/components/NotificationList.js +1 -1
  197. package/dist/kempo/components/Permission.js +1 -1
  198. package/dist/kempo/components/PermissionTarget.js +1 -1
  199. package/dist/kempo/components/Register.js +1 -1
  200. package/dist/kempo/components/Role.js +1 -1
  201. package/dist/kempo/components/UserCreatedAt.js +1 -1
  202. package/dist/kempo/components/UserDisplayName.js +1 -1
  203. package/dist/kempo/components/UserEmail.js +1 -1
  204. package/dist/kempo/components/UserEmailVerified.js +1 -1
  205. package/dist/kempo/components/UserName.js +1 -1
  206. package/dist/kempo/fetch.js +2 -2
  207. package/dist/kempo/fetch.js.map +1 -1
  208. package/dist/kempo/realtime.js +1 -1
  209. package/dist/kempo/sdk.js +2 -2
  210. package/dist/kempo/sdk.js.map +1 -1
  211. package/dist/kempo/utils/safeNext.js +1 -1
  212. package/docker/.dockerignore +9 -0
  213. package/docker/Dockerfile +25 -0
  214. package/docker/docker-compose.prod.yml +39 -0
  215. package/docker/entrypoint.mjs +96 -0
  216. package/middleware/kempo.js +53 -3
  217. package/package.json +3 -1
  218. package/server/db/schema.js +12 -0
  219. package/server/db/upgrades/README.md +20 -0
  220. package/server/sdk.js +3 -0
  221. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  222. package/server/utils/extensions/reconcileExtensions.js +90 -0
  223. package/server/utils/extensions/updateExtension.js +14 -153
  224. package/server/utils/extensions/upgradeExtension.js +117 -0
  225. package/server/utils/extensions/writeSiteFile.js +66 -0
  226. package/server/utils/settings/systemSettings.js +1 -0
  227. package/server/utils/setup/runUpgrades.js +70 -0
  228. package/server/utils/setup/seedDatabase.js +1 -1
  229. package/server/utils/setup/seedIfNew.js +21 -0
  230. package/server/utils/setup/syncCoreSchema.js +130 -0
  231. package/server/utils/setup/systemPermissions.js +2 -0
  232. package/server/utils/updates/applyStagedUpdate.js +95 -0
  233. package/server/utils/updates/applyUpdate.js +76 -0
  234. package/server/utils/updates/backupDatabase.js +35 -0
  235. package/server/utils/updates/discardUpdate.js +11 -0
  236. package/server/utils/updates/extensionTargets.js +101 -0
  237. package/server/utils/updates/fetchManifest.js +26 -0
  238. package/server/utils/updates/getExtensionUpdates.js +70 -0
  239. package/server/utils/updates/getInstallInfo.js +25 -0
  240. package/server/utils/updates/getUpdateStatus.js +57 -0
  241. package/server/utils/updates/helpers.js +30 -0
  242. package/server/utils/updates/notifySchemaAttention.js +35 -0
  243. package/server/utils/updates/npm.js +41 -0
  244. package/server/utils/updates/prepareUpdate.js +109 -0
  245. package/server/utils/updates/releaseStatus.js +23 -0
  246. package/server/utils/updates/restart.js +64 -0
  247. package/server/utils/updates/rollbackUpdate.js +56 -0
  248. package/server/utils/updates/runUpdateCheck.js +52 -0
  249. package/server/utils/updates/runtime.js +15 -0
  250. package/server/utils/updates/siteLayout.js +200 -0
  251. package/server/utils/updates/starterFiles.js +118 -0
@@ -0,0 +1,200 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ /*
5
+ The folders of a site that runs from a Docker volume (KEMPO_SITE_ROOT, normally /site), and the steps that move between
6
+ versions of kempo. It uses nothing but Node's own modules, because the image's startup script runs it before any of the
7
+ site's code can be trusted, and so a copy of it that is older than the site's still understands the state it left.
8
+
9
+ app/ the site. Its files are the owner's (public, templates, uploads, .env) and are never moved. Its
10
+ node_modules and package.json are "the code": the only part a version change replaces
11
+ staging/ a new version being installed, with its own node_modules and copy of the manifests, while the site
12
+ keeps running from app/
13
+ releases/ the code the site had before the last switch, kept so it can go back
14
+ state/ what the startup script and the site tell each other (see below)
15
+
16
+ A switch is two renames inside one volume, done by the startup script while no site process is running, so the site never
17
+ loads files from two versions at once. Each step can be repeated, so a switch that was cut short finishes at the next start.
18
+
19
+ state/switch.json says where a switch is: "requested" (the site asked, nothing moved yet), "switching" (moving),
20
+ "switched" (the new code is in place and has not yet proved itself) and is removed once the site has stayed up for a
21
+ minute. A site that stops while it is "switched" more than MAX_ATTEMPTS times, or says it failed, is put back.
22
+ */
23
+ export const MAX_ATTEMPTS = 3;
24
+
25
+ const MANIFESTS = ['package.json', 'package-lock.json'];
26
+
27
+ export const paths = siteRoot => ({
28
+ siteRoot,
29
+ app: join(siteRoot, 'app'),
30
+ staging: join(siteRoot, 'staging'),
31
+ releases: join(siteRoot, 'releases'),
32
+ state: join(siteRoot, 'state'),
33
+ });
34
+
35
+ /*
36
+ State files
37
+ */
38
+ const readJson = file => {
39
+ try {
40
+ return JSON.parse(readFileSync(file, 'utf8'));
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const writeJson = (file, data) => {
47
+ mkdirSync(dirname(file), { recursive: true });
48
+ writeFileSync(`${file}.tmp`, JSON.stringify(data, null, 2));
49
+ renameSync(`${file}.tmp`, file);
50
+ };
51
+
52
+ export const readState = (siteRoot, name) => readJson(join(paths(siteRoot).state, `${name}.json`));
53
+
54
+ export const writeState = (siteRoot, name, data) => writeJson(join(paths(siteRoot).state, `${name}.json`), data);
55
+
56
+ export const clearState = (siteRoot, name) => rmSync(join(paths(siteRoot).state, `${name}.json`), { force: true });
57
+
58
+ const versionIn = dir => readJson(join(dir, 'node_modules', 'kempo', 'package.json'))?.version ?? null;
59
+
60
+ const releaseId = version => `${Date.now()}-${version}`;
61
+
62
+ const copyManifests = (from, to) => {
63
+ for(const name of MANIFESTS){
64
+ if(existsSync(join(from, name))) cpSync(join(from, name), join(to, name));
65
+ }
66
+ };
67
+
68
+ /*
69
+ The first start: the image holds the site as it was built (its files and its node_modules) and the volume is empty,
70
+ so the volume is filled from it. After that the volume is the site and the image is only the runtime.
71
+ */
72
+ export const seedSite = ({ siteRoot, seed }) => {
73
+ const { app } = paths(siteRoot);
74
+ if(existsSync(join(app, 'package.json'))) return false;
75
+ mkdirSync(app, { recursive: true });
76
+ cpSync(seed, app, { recursive: true, verbatimSymlinks: true });
77
+ const version = versionIn(app);
78
+ writeState(siteRoot, 'current', { id: releaseId(version ?? 'seed'), version, previous: null });
79
+ return true;
80
+ };
81
+
82
+ export const getCurrent = siteRoot => readState(siteRoot, 'current') ?? { id: null, version: versionIn(paths(siteRoot).app), previous: null };
83
+
84
+ /*
85
+ Staging a new version: a copy of the code the site has now, which the caller then installs into. The running site is
86
+ not touched. Only one is kept: staging again replaces the one before.
87
+ */
88
+ export const stage = ({ siteRoot, version }) => {
89
+ const { app, staging } = paths(siteRoot);
90
+ rmSync(staging, { recursive: true, force: true });
91
+ const id = releaseId(version);
92
+ const dir = join(staging, id);
93
+ mkdirSync(dir, { recursive: true });
94
+ copyManifests(app, dir);
95
+ cpSync(join(app, 'node_modules'), join(dir, 'node_modules'), { recursive: true, verbatimSymlinks: true });
96
+ return { id, dir };
97
+ };
98
+
99
+ /*
100
+ Asking for a switch. `source` is a folder holding node_modules and the manifests: a staged version, or an earlier
101
+ release to go back to. The startup script does it at the next start.
102
+ */
103
+ export const requestSwitch = ({ siteRoot, id, version, source, kind = 'update', ...details }) => {
104
+ const current = getCurrent(siteRoot);
105
+ writeState(siteRoot, 'switch', { status: 'requested', kind, id, version, source, from: { id: current.id, version: current.version }, attempts: 0, requestedAt: new Date().toISOString(), ...details });
106
+ };
107
+
108
+ const swapIn = ({ siteRoot, sw }) => {
109
+ const { app, releases } = paths(siteRoot);
110
+ const old = join(releases, sw.from.id ?? `${Date.now()}-unknown`);
111
+ const oldModules = join(app, 'node_modules');
112
+
113
+ if(existsSync(oldModules) && !existsSync(join(old, 'node_modules'))){
114
+ mkdirSync(old, { recursive: true });
115
+ renameSync(oldModules, join(old, 'node_modules'));
116
+ copyManifests(app, old);
117
+ }
118
+ if(existsSync(join(sw.source, 'node_modules'))){
119
+ renameSync(join(sw.source, 'node_modules'), oldModules);
120
+ copyManifests(sw.source, app);
121
+ }
122
+ rmSync(sw.source, { recursive: true, force: true });
123
+ rmSync(paths(siteRoot).staging, { recursive: true, force: true });
124
+ writeState(siteRoot, 'current', { id: sw.id, version: sw.version, previous: { id: sw.from.id, version: sw.from.version } });
125
+ };
126
+
127
+ /* Carries out a requested (or half-done) switch. Returns the switch, or null when there was nothing to do. */
128
+ export const performSwitch = siteRoot => {
129
+ const sw = readState(siteRoot, 'switch');
130
+ if(!sw || !['requested', 'switching'].includes(sw.status)) return null;
131
+ writeState(siteRoot, 'switch', { ...sw, status: 'switching' });
132
+ swapIn({ siteRoot, sw });
133
+ const done = { ...sw, status: 'switched', attempts: 0 };
134
+ writeState(siteRoot, 'switch', done);
135
+ return done;
136
+ };
137
+
138
+ /*
139
+ Files the update changed outside the code (starter files), saved before they were overwritten, put back: those that
140
+ existed are restored and those the update created are removed.
141
+ */
142
+ export const restoreFiles = ({ siteRoot, backup }) => {
143
+ if(!backup?.dir) return;
144
+ const { app } = paths(siteRoot);
145
+ for(const { path, existed } of backup.files ?? []){
146
+ if(existed) cpSync(join(backup.dir, path), join(app, path), { force: true });
147
+ else rmSync(join(app, path), { force: true });
148
+ }
149
+ };
150
+
151
+ /*
152
+ Back to the release before this one. Used by the startup script when a new version keeps failing, and for going back by
153
+ hand. Records why in state/last.json.
154
+ */
155
+ export const rollBack = ({ siteRoot, reason }) => {
156
+ const sw = readState(siteRoot, 'switch');
157
+ const current = getCurrent(siteRoot);
158
+ const previous = current.previous;
159
+ const source = previous?.id ? join(paths(siteRoot).releases, previous.id) : null;
160
+ if(!source || !existsSync(join(source, 'node_modules'))) return { ok: false, reason: 'There is no earlier version to go back to' };
161
+
162
+ swapIn({ siteRoot, sw: { id: previous.id, version: previous.version, source, from: { id: current.id, version: current.version } } });
163
+ restoreFiles({ siteRoot, backup: sw?.filesBackup });
164
+ writeState(siteRoot, 'last', { ok: false, from: current.version, to: previous.version, reason, at: new Date().toISOString(), dbBackup: sw?.dbBackup ?? null });
165
+ clearState(siteRoot, 'switch');
166
+ return { ok: true, from: current.version, to: previous.version, dbBackup: sw?.dbBackup ?? null };
167
+ };
168
+
169
+ /* True when a switch has not proved itself and the site has failed to stay up. Going back by hand is never undone by itself. */
170
+ export const shouldRollBack = sw => sw?.status === 'switched' && sw.kind !== 'rollback' && (Boolean(sw.failed) || sw.attempts > MAX_ATTEMPTS);
171
+
172
+ export const countAttempt = siteRoot => {
173
+ const sw = readState(siteRoot, 'switch');
174
+ if(sw?.status !== 'switched') return sw;
175
+ const next = { ...sw, attempts: sw.attempts + 1 };
176
+ writeState(siteRoot, 'switch', next);
177
+ return next;
178
+ };
179
+
180
+ /* The site says its new code cannot work (a data migration failed). It is put back at the next start. */
181
+ export const flagFailed = ({ siteRoot, reason }) => {
182
+ const sw = readState(siteRoot, 'switch');
183
+ if(sw?.status === 'switched') writeState(siteRoot, 'switch', { ...sw, failed: true, failure: reason });
184
+ };
185
+
186
+ /* The new code has stayed up: the switch is final, and every release but the one before this is deleted. */
187
+ export const markHealthy = siteRoot => {
188
+ const sw = readState(siteRoot, 'switch');
189
+ if(sw?.status !== 'switched') return false;
190
+ const { releases } = paths(siteRoot);
191
+ const current = getCurrent(siteRoot);
192
+ writeState(siteRoot, 'last', { ok: true, from: sw.from.version, to: sw.version, kind: sw.kind, breaking: Boolean(sw.breaking), packages: sw.packages ?? [], dbBackup: sw.dbBackup ?? null, filesBackup: sw.filesBackup ?? null, at: new Date().toISOString() });
193
+ clearState(siteRoot, 'switch');
194
+ if(existsSync(releases)){
195
+ for(const name of readdirSync(releases)){
196
+ if(name !== current.previous?.id) rmSync(join(releases, name), { recursive: true, force: true });
197
+ }
198
+ }
199
+ return true;
200
+ };
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'crypto';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
+ import { dirname, join, relative, sep } from 'path';
4
+
5
+ /*
6
+ The starter files: what `npx kempo init` copies from the kempo package into a site (app-public/ into public/, and
7
+ app-templates/ into templates/). They start as kempo's and become the owner's the moment anyone edits one.
8
+
9
+ Kempo remembers the fingerprint (SHA-256) of each starter file it put in the site, in .kempo/shipped.json. An update then
10
+ treats a file in the site like this:
11
+
12
+ identical to the new version's nothing to do
13
+ still what kempo put there never edited: replaced by the new version
14
+ anything else edited, by anyone (the admin, FTP, a shell): kept, and offered as "newer" only
15
+ missing, but kempo shipped it before the owner deleted it: it stays deleted
16
+ missing and new in this version added
17
+
18
+ A site made before this record existed has none, so a file is judged against what the version that is running ships.
19
+ A version can name files it must replace even when they were edited (a security fix) in its package.json:
20
+ { "kempo": { "forceUpdate": ["public/login/", "public/init.js"] } }. Those wait for the owner's confirmation, and the
21
+ file as it was is saved first.
22
+ */
23
+ export const SOURCES = [['app-public', 'public'], ['app-templates', 'templates']];
24
+
25
+ const RECORD = ['.kempo', 'shipped.json'];
26
+
27
+ const hashOf = file => createHash('sha256').update(readFileSync(file)).digest('hex');
28
+
29
+ const walk = dir => readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]));
30
+
31
+ /* [{ path, source, hash }] for every starter file a kempo package ships, `path` being where it goes in the site. */
32
+ export const listShipped = packageRoot => SOURCES.flatMap(([from, to]) => {
33
+ const base = join(packageRoot, from);
34
+ if(!existsSync(base)) return [];
35
+ return walk(base).map(file => ({ path: `${to}/${relative(base, file).split(sep).join('/')}`, source: file, hash: hashOf(file) }));
36
+ });
37
+
38
+ export const readRecord = cwd => {
39
+ try {
40
+ return JSON.parse(readFileSync(join(cwd, ...RECORD), 'utf8'));
41
+ } catch {
42
+ return { version: null, files: {} };
43
+ }
44
+ };
45
+
46
+ const writeRecord = (cwd, record) => {
47
+ mkdirSync(join(cwd, '.kempo'), { recursive: true });
48
+ writeFileSync(join(cwd, ...RECORD), JSON.stringify(record, null, 2));
49
+ };
50
+
51
+ /* The files a version must replace, from its package.json. */
52
+ export const forcedFiles = packageRoot => {
53
+ try {
54
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).kempo?.forceUpdate ?? [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ };
59
+
60
+ /* For `npx kempo init`: remembers what was copied. */
61
+ export const recordShipped = ({ cwd, packageRoot, version }) => {
62
+ const files = {};
63
+ for(const file of listShipped(packageRoot)){
64
+ if(existsSync(join(cwd, file.path)) && hashOf(join(cwd, file.path)) === file.hash) files[file.path] = file.hash;
65
+ }
66
+ writeRecord(cwd, { version, files });
67
+ };
68
+
69
+ /* What an update would do to the site's starter files. Changes nothing. */
70
+ export const planStarterFiles = ({ cwd, oldRoot, newRoot, forced = forcedFiles(newRoot) }) => {
71
+ const recorded = readRecord(cwd).files;
72
+ const before = new Map(listShipped(oldRoot).map(file => [file.path, file.hash]));
73
+ const plan = { add: [], update: [], current: [], edited: [], deleted: [], forced: [] };
74
+
75
+ for(const file of listShipped(newRoot)){
76
+ const target = join(cwd, file.path);
77
+ if(!existsSync(target)){
78
+ (recorded[file.path] || before.has(file.path) ? plan.deleted : plan.add).push(file.path);
79
+ } else if(before.get(file.path) === file.hash || hashOf(target) === file.hash){
80
+ plan.current.push(file.path);
81
+ } else {
82
+ const shipped = recorded[file.path] ?? before.get(file.path);
83
+ if(shipped && hashOf(target) === shipped) plan.update.push(file.path);
84
+ else if(forced.some(entry => entry === file.path || (entry.endsWith('/') && file.path.startsWith(entry)))) plan.forced.push(file.path);
85
+ else plan.edited.push(file.path);
86
+ }
87
+ }
88
+ return plan;
89
+ };
90
+
91
+ /*
92
+ Does it. Every file it replaces is copied to `backupDir` first. Resolves to { dir, files: [{ path, existed }] }, which is
93
+ what putting them back needs (restoreFiles in siteLayout.js).
94
+ */
95
+ export const applyStarterFiles = ({ cwd, newRoot, version, plan, includeForced = false, backupDir }) => {
96
+ const shipped = new Map(listShipped(newRoot).map(file => [file.path, file]));
97
+ const changed = [...plan.add, ...plan.update, ...(includeForced ? plan.forced : [])];
98
+ const files = [];
99
+
100
+ for(const path of changed){
101
+ const target = join(cwd, path);
102
+ const existed = existsSync(target);
103
+ if(existed){
104
+ mkdirSync(dirname(join(backupDir, path)), { recursive: true });
105
+ cpSync(target, join(backupDir, path));
106
+ }
107
+ mkdirSync(dirname(target), { recursive: true });
108
+ cpSync(shipped.get(path).source, target);
109
+ files.push({ path, existed });
110
+ }
111
+
112
+ const record = readRecord(cwd);
113
+ for(const path of [...changed, ...plan.current]){
114
+ if(existsSync(join(cwd, path)) && hashOf(join(cwd, path)) === shipped.get(path).hash) record.files[path] = shipped.get(path).hash;
115
+ }
116
+ writeRecord(cwd, { version, files: record.files });
117
+ return { dir: backupDir, files };
118
+ };