kempo 4.7.0 → 4.9.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 (132) hide show
  1. package/README.md +10 -4
  2. package/bin/cli.js +38 -34
  3. package/dist/admin/components/EmailTemplates.js +25 -0
  4. package/dist/admin/components/EmailTemplates.js.map +1 -0
  5. package/dist/admin/components/Emails.js +49 -0
  6. package/dist/admin/components/Emails.js.map +1 -0
  7. package/dist/admin/components/Updates.js +117 -0
  8. package/dist/admin/components/Updates.js.map +1 -0
  9. package/dist/admin/components/settings/AdminSettings.js +7 -0
  10. package/dist/admin/components/settings/AdminSettings.js.map +1 -0
  11. package/dist/admin/components/settings/BooleanSetting.js +2 -0
  12. package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
  13. package/dist/admin/components/settings/JsonSetting.js +2 -0
  14. package/dist/admin/components/settings/JsonSetting.js.map +1 -0
  15. package/dist/admin/components/settings/NumberSetting.js +2 -0
  16. package/dist/admin/components/settings/NumberSetting.js.map +1 -0
  17. package/dist/admin/components/settings/SecretSetting.js +2 -0
  18. package/dist/admin/components/settings/SecretSetting.js.map +1 -0
  19. package/dist/admin/components/settings/Setting.js +2 -0
  20. package/dist/admin/components/settings/Setting.js.map +1 -0
  21. package/dist/admin/components/settings/SettingEditor.js +2 -0
  22. package/dist/admin/components/settings/SettingEditor.js.map +1 -0
  23. package/dist/admin/components/settings/SettingsPanel.js +24 -0
  24. package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
  25. package/dist/admin/components/settings/StringSetting.js +2 -0
  26. package/dist/admin/components/settings/StringSetting.js.map +1 -0
  27. package/dist/admin/components/settings/humanize.js +2 -0
  28. package/dist/admin/components/settings/humanize.js.map +1 -0
  29. package/dist/admin/emails/index.page.html +14 -0
  30. package/dist/admin/icons/mail.svg +1 -0
  31. package/dist/admin/nav.fragment.html +12 -0
  32. package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
  33. package/dist/admin/settings/index.page.html +6 -289
  34. package/dist/admin/updates/index.page.html +14 -0
  35. package/dist/kempo/api/email-templates/GET.js +2 -0
  36. package/dist/kempo/api/email-templates/GET.js.map +1 -0
  37. package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
  38. package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
  39. package/dist/kempo/api/email-templates/file/GET.js +2 -0
  40. package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
  41. package/dist/kempo/api/email-templates/file/PUT.js +2 -0
  42. package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
  43. package/dist/kempo/api/email-templates/preview/GET.js +2 -0
  44. package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
  45. package/dist/kempo/api/emails/GET.js +2 -0
  46. package/dist/kempo/api/emails/GET.js.map +1 -0
  47. package/dist/kempo/api/emails/[id]/GET.js +2 -0
  48. package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
  49. package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
  50. package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
  51. package/dist/kempo/api/emails/test/POST.js +2 -0
  52. package/dist/kempo/api/emails/test/POST.js.map +1 -0
  53. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  54. package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
  55. package/dist/kempo/api/updates/GET.js +2 -0
  56. package/dist/kempo/api/updates/GET.js.map +1 -0
  57. package/dist/kempo/api/updates/apply/POST.js +2 -0
  58. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  59. package/dist/kempo/api/updates/check/POST.js +2 -0
  60. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  61. package/dist/kempo/api/updates/discard/POST.js +2 -0
  62. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  63. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  64. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  65. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  66. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  67. package/dist/kempo/fetch.js +1 -1
  68. package/dist/kempo/fetch.js.map +1 -1
  69. package/dist/kempo/sdk.js +1 -1
  70. package/dist/kempo/sdk.js.map +1 -1
  71. package/docker/.dockerignore +9 -0
  72. package/docker/Dockerfile +25 -0
  73. package/docker/docker-compose.prod.yml +39 -0
  74. package/docker/entrypoint.mjs +96 -0
  75. package/middleware/kempo.js +71 -0
  76. package/package.json +4 -3
  77. package/server/db/schema.js +44 -0
  78. package/server/db/upgrades/README.md +20 -0
  79. package/server/sdk.js +8 -0
  80. package/server/utils/email/defaultSender.js +2 -0
  81. package/server/utils/email/dispatchEmail.js +56 -0
  82. package/server/utils/email/emailSettings.js +21 -0
  83. package/server/utils/email/emailTemplatePaths.js +36 -0
  84. package/server/utils/email/getEmail.js +14 -0
  85. package/server/utils/email/getSenderEmail.js +11 -0
  86. package/server/utils/email/getSenderFrom.js +18 -0
  87. package/server/utils/email/isValidSender.js +4 -0
  88. package/server/utils/email/listEmailTemplates.js +23 -0
  89. package/server/utils/email/listEmails.js +35 -0
  90. package/server/utils/email/maintainEmails.js +46 -0
  91. package/server/utils/email/pruneEmails.js +29 -0
  92. package/server/utils/email/readEmailTemplate.js +28 -0
  93. package/server/utils/email/resetEmailTemplate.js +15 -0
  94. package/server/utils/email/retryEmail.js +21 -0
  95. package/server/utils/email/saveEmailTemplate.js +38 -0
  96. package/server/utils/email/sendEmail.js +22 -32
  97. package/server/utils/email/sendEmailFromTemplate.js +2 -2
  98. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  99. package/server/utils/extensions/reconcileExtensions.js +90 -0
  100. package/server/utils/extensions/updateExtension.js +14 -153
  101. package/server/utils/extensions/upgradeExtension.js +117 -0
  102. package/server/utils/extensions/writeSiteFile.js +66 -0
  103. package/server/utils/hooks/triggerHook.js +17 -4
  104. package/server/utils/settings/listSettings.js +10 -5
  105. package/server/utils/settings/setSetting.js +4 -0
  106. package/server/utils/settings/settingRules.js +21 -0
  107. package/server/utils/settings/systemSettings.js +41 -0
  108. package/server/utils/setup/runUpgrades.js +70 -0
  109. package/server/utils/setup/seedDatabase.js +5 -60
  110. package/server/utils/setup/seedIfNew.js +21 -0
  111. package/server/utils/setup/syncCoreSchema.js +130 -0
  112. package/server/utils/setup/systemPermissions.js +77 -0
  113. package/server/utils/updates/applyStagedUpdate.js +95 -0
  114. package/server/utils/updates/applyUpdate.js +76 -0
  115. package/server/utils/updates/backupDatabase.js +35 -0
  116. package/server/utils/updates/discardUpdate.js +11 -0
  117. package/server/utils/updates/extensionTargets.js +101 -0
  118. package/server/utils/updates/fetchManifest.js +26 -0
  119. package/server/utils/updates/getExtensionUpdates.js +70 -0
  120. package/server/utils/updates/getInstallInfo.js +25 -0
  121. package/server/utils/updates/getUpdateStatus.js +57 -0
  122. package/server/utils/updates/helpers.js +30 -0
  123. package/server/utils/updates/notifySchemaAttention.js +35 -0
  124. package/server/utils/updates/npm.js +41 -0
  125. package/server/utils/updates/prepareUpdate.js +109 -0
  126. package/server/utils/updates/releaseStatus.js +23 -0
  127. package/server/utils/updates/restart.js +64 -0
  128. package/server/utils/updates/rollbackUpdate.js +56 -0
  129. package/server/utils/updates/runUpdateCheck.js +52 -0
  130. package/server/utils/updates/runtime.js +15 -0
  131. package/server/utils/updates/siteLayout.js +200 -0
  132. package/server/utils/updates/starterFiles.js +118 -0
@@ -0,0 +1,28 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { locateTemplate, sameSource, titleOf } from './emailTemplatePaths.js';
3
+
4
+ const read = path => readFile(path, 'utf8').catch(() => null);
5
+
6
+ /*
7
+ One email template as the site would send it: `source` is the site's own version when it has one and the packaged one
8
+ otherwise, `defaultSource` is the packaged one (null when there is none), and `overridden` says the site's differs from it.
9
+ Resolves to [error, { template, owner, name, title, source, defaultSource, overridden }].
10
+ */
11
+ export default async ({ template } = {}) => {
12
+ const located = locateTemplate(template);
13
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
14
+
15
+ const [site, packaged] = await Promise.all([read(located.sitePath), read(located.defaultPath)]);
16
+ if(site === null && packaged === null) return [{ code: 404, msg: `Email template not found: ${template}` }, null];
17
+
18
+ const source = site ?? packaged;
19
+ return [null, {
20
+ template,
21
+ owner: located.owner,
22
+ name: located.name,
23
+ title: titleOf(source),
24
+ source,
25
+ defaultSource: packaged,
26
+ overridden: site !== null && packaged !== null && !sameSource(site, packaged),
27
+ }];
28
+ };
@@ -0,0 +1,15 @@
1
+ import { readFile, rm } from 'fs/promises';
2
+ import { locateTemplate } from './emailTemplatePaths.js';
3
+
4
+ /* Removes the site's version of an email template so the packaged one is used again. Resolves to [error, { template }]. */
5
+ export default async ({ template } = {}) => {
6
+ const located = locateTemplate(template);
7
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
8
+ if(await readFile(located.defaultPath, 'utf8').catch(() => null) === null) return [{ code: 409, msg: 'There is no packaged version to go back to' }, null];
9
+ try {
10
+ await rm(located.sitePath, { force: true });
11
+ return [null, { template }];
12
+ } catch {
13
+ return [{ code: 500, msg: 'The template could not be reset' }, null];
14
+ }
15
+ };
@@ -0,0 +1,21 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, eq, inArray } from 'drizzle-orm';
4
+ import dispatchEmail from './dispatchEmail.js';
5
+
6
+ /*
7
+ Tries an email that did not go again, as if it were new: it gets the full number of tries once more. Only a `queued` or `failed`
8
+ email can be tried again (one that is `sent` or being sent is left alone). Resolves like sendEmail.
9
+ */
10
+ export default async id => {
11
+ try {
12
+ const [row] = await db.update(email)
13
+ .set({ status: 'queued', attempts: 0, lastError: null, updatedAt: new Date() })
14
+ .where(and(eq(email.id, String(id ?? '')), inArray(email.status, ['queued', 'failed'])))
15
+ .returning({ id: email.id });
16
+ if(!row) return [{ code: 409, msg: 'Only an email that is waiting or failed can be tried again' }, null];
17
+ } catch {
18
+ return [{ code: 500, msg: 'Failed to retry the email' }, null];
19
+ }
20
+ return dispatchEmail(String(id));
21
+ };
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+ import { locateTemplate } from './emailTemplatePaths.js';
4
+ import renderEmailTemplate from './renderEmailTemplate.js';
5
+
6
+ const MAX_SOURCE = 100000;
7
+
8
+ /*
9
+ The site's own version of an email template, written to `templates/emails/` so it is used from the next email on (the packaged
10
+ one is never touched). Only a template that exists can be saved, so this cannot make up new names. The new version is rendered
11
+ once before it is kept, and the old one is put back when it does not render, so a typo cannot stop the site sending that email.
12
+ Resolves to [error, { template }].
13
+ */
14
+ export default async ({ template, source } = {}) => {
15
+ const located = locateTemplate(template);
16
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
17
+ if(typeof source !== 'string' || !source.trim()) return [{ code: 400, msg: 'The template cannot be empty' }, null];
18
+ if(source.length > MAX_SOURCE) return [{ code: 400, msg: `The template can be at most ${MAX_SOURCE} characters` }, null];
19
+ if(source.includes('\u0000')) return [{ code: 400, msg: 'The template cannot contain null characters' }, null];
20
+
21
+ const [site, packaged] = await Promise.all([readFile(located.sitePath, 'utf8').catch(() => null), readFile(located.defaultPath, 'utf8').catch(() => null)]);
22
+ if(site === null && packaged === null) return [{ code: 404, msg: `Email template not found: ${template}` }, null];
23
+
24
+ try {
25
+ await mkdir(dirname(located.sitePath), { recursive: true });
26
+ await writeFile(located.sitePath, source);
27
+ } catch {
28
+ return [{ code: 500, msg: 'The template could not be saved' }, null];
29
+ }
30
+
31
+ const [renderError] = await renderEmailTemplate({ template, data: {} });
32
+ if(renderError){
33
+ if(site === null) await rm(located.sitePath, { force: true });
34
+ else await writeFile(located.sitePath, site);
35
+ return [{ code: 400, msg: `The template does not render, so it was not saved: ${renderError.msg}` }, null];
36
+ }
37
+ return [null, { template }];
38
+ };
@@ -1,48 +1,38 @@
1
- import { Resend } from 'resend';
1
+ import crypto from 'crypto';
2
+ import db from '../../db/index.js';
3
+ import { email } from '../../db/schema.js';
4
+ import getSenderFrom from './getSenderFrom.js';
5
+ import dispatchEmail from './dispatchEmail.js';
2
6
 
3
7
  /*
4
- Constructed on first send, not at import. The Resend constructor throws when the key is missing,
5
- and this module is reachable from the server SDK, so building it at module scope made an unset
6
- RESEND_API_KEY fail every route that transitively imports it rather than just disabling email.
7
- */
8
- let resend = null;
9
- const client = () => (resend ??= new Resend(process.env.RESEND_API_KEY));
10
-
11
- export default async ({ to, subject, html }) => {
12
- if(!process.env.RESEND_API_KEY){
13
- return [{ code: 500, msg: 'RESEND_API_KEY not configured' }, null];
14
- }
8
+ Asks for an email to be sent. Kempo does not send email itself: this saves the email in the log (the `email` table) and hands it to
9
+ whichever email extension is installed (kempo-resend, kempo-smtp...) through the `email:send` hook; see dispatchEmail.js.
15
10
 
11
+ `template` is the name of the template the email was made from, if any, and is its type in the log. It resolves to
12
+ `[null, { id, status: 'sent' }]` when a sender sent it, or `[{ code, msg, emailId }, null]` when none did: 503 when there is no email
13
+ extension installed, 502 when one tried and failed. Either way the email is in the log, and a failed one is tried again (see the
14
+ `email_max_retries` setting).
15
+ */
16
+ export default async ({ to, subject, html, template = null } = {}) => {
16
17
  if(!to){
17
18
  return [{ code: 400, msg: 'Recipient email is required' }, null];
18
19
  }
19
-
20
+
20
21
  if(!subject){
21
22
  return [{ code: 400, msg: 'Email subject is required' }, null];
22
23
  }
23
-
24
+
24
25
  if(!html){
25
26
  return [{ code: 400, msg: 'Email content is required' }, null];
26
27
  }
27
28
 
28
- const from = process.env.SMTP_FROM || 'onboarding@resend.dev';
29
-
29
+ const id = crypto.randomUUID();
30
+ const now = new Date();
30
31
  try {
31
- const { data, error } = await client().emails.send({
32
- from,
33
- to,
34
- subject,
35
- html,
36
- });
37
-
38
- if(error){
39
- console.error('Email send error:', error);
40
- return [{ code: 500, msg: error.message || 'Failed to send email' }, null];
41
- }
42
-
43
- return [null, { success: true, messageId: data.id }];
44
- } catch(error){
45
- console.error('Email send error:', error);
46
- return [{ code: 500, msg: 'Failed to send email' }, null];
32
+ await db.insert(email).values({ id, type: template || 'other', recipient: String(to), sender: await getSenderFrom(), subject: String(subject), html: String(html), status: 'queued', attempts: 0, createdAt: now, updatedAt: now });
33
+ } catch(error) {
34
+ return [{ code: 500, msg: 'The email could not be saved to send' }, null];
47
35
  }
36
+
37
+ return dispatchEmail(id);
48
38
  };
@@ -3,7 +3,7 @@ import sendEmail from './sendEmail.js';
3
3
 
4
4
  /*
5
5
  `template` is a plain name for kempo's and the site's templates, or "owner/name" for an extension's;
6
- see renderEmailTemplate.js.
6
+ see renderEmailTemplate.js. It is also the email's type in the log, which the retention settings go by.
7
7
  */
8
8
  export default async ({ to, subject, template, data = {} }) => {
9
9
  if(!to){
@@ -23,5 +23,5 @@ export default async ({ to, subject, template, data = {} }) => {
23
23
  return [error, null];
24
24
  }
25
25
 
26
- return await sendEmail({ to, subject, html });
26
+ return await sendEmail({ to, subject, html, template });
27
27
  };
@@ -1,23 +1,11 @@
1
1
  import db from '../../db/index.js';
2
2
  import { extension } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
+ import { compareVersions, isVersion } from '../updates/helpers.js';
5
+ import fetchManifest from '../updates/fetchManifest.js';
4
6
 
5
- const toRawUrl = (gitUrl, file = 'package.json') => {
6
- const match = gitUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
7
- if(!match) return null;
8
- return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/${file}`;
9
- };
10
-
11
- const semverGt = (a, b) => {
12
- const parse = v => (v || '0.0.0').replace(/^v/, '').split('.').map(Number);
13
- const [aMaj, aMin, aPatch] = parse(a);
14
- const [bMaj, bMin, bPatch] = parse(b);
15
- if(aMaj !== bMaj) return aMaj > bMaj;
16
- if(aMin !== bMin) return aMin > bMin;
17
- return aPatch > bPatch;
18
- };
19
-
20
- export default async ({ name }) => {
7
+ /* Whether npm has a newer version of one installed extension than the one recorded for it. */
8
+ export default async ({ name, fetcher }) => {
21
9
  if(!name){
22
10
  return [{ code: 400, msg: 'Extension name is required' }, null];
23
11
  }
@@ -32,25 +20,10 @@ export default async ({ name }) => {
32
20
  return [{ code: 500, msg: 'Failed to find extension' }, null];
33
21
  }
34
22
 
35
- const gitUrl = record.kempo?.git;
36
- if(!gitUrl){
37
- return [null, { hasUpdate: false, reason: 'No git URL configured' }];
38
- }
39
-
40
- const rawUrl = toRawUrl(gitUrl);
41
- if(!rawUrl){
42
- return [null, { hasUpdate: false, reason: 'Unsupported git URL format' }];
43
- }
44
-
45
- let remotePkg;
46
- try {
47
- const res = await fetch(rawUrl);
48
- if(!res.ok) return [null, { hasUpdate: false, reason: 'Failed to fetch remote package.json' }];
49
- remotePkg = await res.json();
50
- } catch(error){
51
- return [{ code: 502, msg: 'Failed to reach remote repository' }, null];
52
- }
23
+ const [error, manifest] = await fetchManifest({ name, fetcher });
24
+ if(error?.code === 404) return [null, { hasUpdate: false, reason: 'This extension is not on npm' }];
25
+ if(error) return [error, null];
53
26
 
54
- const hasUpdate = semverGt(remotePkg.version, record.version);
55
- return [null, { hasUpdate, currentVersion: record.version, latestVersion: remotePkg.version }];
27
+ const hasUpdate = isVersion(record.version) && compareVersions(manifest.version, record.version) > 0;
28
+ return [null, { hasUpdate, currentVersion: record.version, latestVersion: manifest.version }];
56
29
  };
@@ -0,0 +1,90 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import { join } from 'path';
3
+ import db from '../../db/index.js';
4
+ import { extension } from '../../db/schema.js';
5
+ import readKempoConfig from './readKempoConfig.js';
6
+ import upgradeExtension from './upgradeExtension.js';
7
+ import { compareVersions, isBreakingBump, isVersion } from '../updates/helpers.js';
8
+
9
+ /*
10
+ Brings every installed extension's database in step with the version on disk. Run when the server starts, so it is the one
11
+ place an update of an extension takes effect, whichever way the new version got there (the Updates screen, a switch of the
12
+ site's code, npm run by hand, an image that was rebuilt). It is what a deploy script would otherwise have to do.
13
+
14
+ on disk is newer than the row upgradeExtension: its tables, update.js and whatever it newly declares
15
+ on disk is older than the row the site went back: the row says so, and no script runs, so going back is safe
16
+ the same, or not installed nothing
17
+
18
+ The row is claimed first (its version moved to the new one only if it still holds the old), so of several servers starting
19
+ together exactly one upgrades an extension. A failed upgrade gives the row its old version back, so it is tried again at
20
+ the next start, and it is reported rather than stopping the server.
21
+
22
+ Extensions are taken in dependency order (what each declares in `dependencies`, in the new version's kempo-config.json), so
23
+ an extension's update script finds the ones it builds on already up to date: kempo-media after kempo-files and kempo-thumbs,
24
+ kempo-commerce after kempo-products and kempo-payments.
25
+ */
26
+ let lastRun = { ran: false, upgraded: [], reverted: [], failed: [] };
27
+
28
+ export const getReconcileResult = () => lastRun;
29
+
30
+ const isBreaking = (from, to) => isVersion(from) && isVersion(to) && isBreakingBump(from, to);
31
+
32
+ const newer = (installed, recorded) => (isVersion(installed) && isVersion(recorded) ? compareVersions(installed, recorded) : (installed === recorded ? 0 : 1));
33
+
34
+ export default async ({ executor = db, root = process.cwd(), upgrade = upgradeExtension } = {}) => {
35
+ const result = { upgraded: [], reverted: [], failed: [] };
36
+
37
+ try {
38
+ const found = [];
39
+ for(const row of await executor.select().from(extension)){
40
+ const extRoot = join(root, 'node_modules', row.name);
41
+ try {
42
+ const { pkg, kempo } = await readKempoConfig(extRoot);
43
+ found.push({ row, extRoot, installed: pkg.version, requires: kempo.dependencies ?? row.kempo?.dependencies ?? [] });
44
+ } catch {
45
+ continue;
46
+ }
47
+ }
48
+
49
+ const ordered = [];
50
+ const placed = new Set();
51
+ const place = item => {
52
+ if(placed.has(item.row.name)) return;
53
+ placed.add(item.row.name);
54
+ for(const name of item.requires){
55
+ const dependency = found.find(other => other.row.name === name);
56
+ if(dependency) place(dependency);
57
+ }
58
+ ordered.push(item);
59
+ };
60
+ found.forEach(place);
61
+
62
+ for(const { row, extRoot, installed } of ordered){
63
+ if(!installed || installed === row.version) continue;
64
+
65
+ const direction = row.version ? newer(installed, row.version) : 1;
66
+ if(direction < 0){
67
+ await executor.update(extension).set({ version: installed, updatedAt: new Date() }).where(eq(extension.name, row.name));
68
+ result.reverted.push({ name: row.name, from: row.version, to: installed });
69
+ continue;
70
+ }
71
+
72
+ const claim = and(eq(extension.name, row.name), row.version ? eq(extension.version, row.version) : undefined);
73
+ const [claimed] = await executor.update(extension).set({ version: installed }).where(claim).returning({ name: extension.name });
74
+ if(!claimed) continue;
75
+
76
+ const [error] = await upgrade({ name: row.name, record: row, extRoot });
77
+ if(error){
78
+ await executor.update(extension).set({ version: row.version }).where(eq(extension.name, row.name));
79
+ result.failed.push({ name: row.name, from: row.version, to: installed, major: isBreaking(row.version, installed), reason: error.msg });
80
+ } else {
81
+ result.upgraded.push({ name: row.name, from: row.version, to: installed });
82
+ }
83
+ }
84
+ } catch(error) {
85
+ return [{ code: 500, msg: `The extensions could not be brought up to date: ${String(error.cause?.message || error.message).slice(0, 300)}` }, result];
86
+ }
87
+
88
+ lastRun = { ran: true, ...result };
89
+ return [null, result];
90
+ };
@@ -1,29 +1,29 @@
1
1
  import db from '../../db/index.js';
2
- import { extension, hook, permission, group, groupPermission } from '../../db/schema.js';
3
- import { eq, and } from 'drizzle-orm';
2
+ import { extension } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
4
  import { createRequire } from 'module';
5
5
  import { dirname, join } from 'path';
6
- import { randomUUID } from 'crypto';
7
6
  import { execSync } from 'child_process';
8
- import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
9
- import createHook from '../hooks/createHook.js';
10
- import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
11
- import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
12
- import registerJobs from '../scheduler/registerJobs.js';
13
- import setSetting from '../settings/setSetting.js';
14
- import declaredValue from '../settings/declaredValue.js';
15
- import createTablesFromSchema from './createTablesFromSchema.js';
16
7
  import readKempoConfig from './readKempoConfig.js';
8
+ import upgradeExtension from './upgradeExtension.js';
17
9
  import { normalizeAliases } from './aliases.js';
18
10
  import { normalizeScopes, scopeNames } from './scopes.js';
19
11
  import checkClaims from './checkClaims.js';
20
12
 
21
13
  const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
22
14
 
15
+ /*
16
+ Installs a newer version of one extension in place, and brings the database in step with it straight away, without a
17
+ restart (see upgradeExtension). For a site that runs from a Docker volume updates go through the Updates screen instead,
18
+ which stages them, shows what they change and can go back; this refuses there so it cannot bypass that.
19
+ */
23
20
  export default async ({ name, version = 'latest' }) => {
24
21
  if(!name){
25
22
  return [{ code: 400, msg: 'Extension name is required' }, null];
26
23
  }
24
+ if(process.env.KEMPO_SITE_ROOT){
25
+ return [{ code: 409, msg: 'This site updates extensions from the Updates screen' }, null];
26
+ }
27
27
 
28
28
  let oldRecord;
29
29
  try {
@@ -35,27 +35,18 @@ export default async ({ name, version = 'latest' }) => {
35
35
  return [{ code: 500, msg: 'Failed to find extension' }, null];
36
36
  }
37
37
 
38
- const oldKempo = oldRecord.kempo || {};
39
38
  const oldVersion = oldRecord.version;
40
39
 
41
- /*
42
- Install updated package
43
- */
44
40
  try {
45
41
  execSync(`npm install ${name}@${version}`, { cwd: process.cwd(), stdio: 'pipe' });
46
42
  } catch(error){
47
43
  return [{ code: 500, msg: `Failed to update package: ${error.message}` }, null];
48
44
  }
49
45
 
50
- /*
51
- What the new version declares comes from its kempo-config.json (package.json's own `kempo` field is
52
- only the fallback for an older extension). Reading package.json alone meant that no extension's new
53
- permissions, settings, groups, hooks or widgets ever reached the database through this update.
54
- */
55
- let extRoot, pkg, newKempo;
46
+ let extRoot, newKempo;
56
47
  try {
57
48
  extRoot = dirname(requireFromProject.resolve(`${name}/package.json`));
58
- ({ pkg, kempo: newKempo } = await readKempoConfig(extRoot));
49
+ ({ kempo: newKempo } = await readKempoConfig(extRoot));
59
50
  } catch {
60
51
  return [{ code: 404, msg: `Updated package "${name}" not found` }, null];
61
52
  }
@@ -81,135 +72,5 @@ export default async ({ name, version = 'latest' }) => {
81
72
  return [{ ...refusal, msg }, null];
82
73
  }
83
74
 
84
- /*
85
- Create any new tables from schema
86
- */
87
- if(newKempo.schema){
88
- try {
89
- const schemaModule = await import(`${join(extRoot, newKempo.schema)}?t=${Date.now()}`);
90
- const [err] = await createTablesFromSchema(schemaModule);
91
- if(err) return [err, null];
92
- } catch(error){
93
- return [{ code: 500, msg: `Failed to load extension schema: ${error.message}` }, null];
94
- }
95
- }
96
-
97
- /*
98
- Run update script with diff context
99
- */
100
- try {
101
- const updateModule = await import(`${join(extRoot, 'update.js')}?t=${Date.now()}`).catch(() => null);
102
- if(updateModule?.default){
103
- await updateModule.default({ oldVersion, newVersion: pkg.version, oldKempo, newKempo });
104
- }
105
- } catch(error){
106
- return [{ code: 500, msg: `Extension update script failed: ${error.message}` }, null];
107
- }
108
-
109
- /*
110
- Add new permissions (never delete — user data may depend on them)
111
- */
112
- const oldPermNames = new Set((oldKempo.permissions || []).map(p => p.name));
113
- for(const { name: permName, description } of newKempo.permissions || []){
114
- if(!oldPermNames.has(permName)){
115
- await db.insert(permission).values({
116
- name: permName,
117
- description: description || null,
118
- owner: name,
119
- createdAt: new Date(),
120
- }).onConflictDoNothing();
121
- }
122
- }
123
-
124
- /*
125
- Add new settings only (never overwrite — user may have changed the value)
126
- */
127
- const oldSettingNames = new Set((oldKempo.settings || []).map(s => s.name));
128
- for(const { name: settingName, value, type, description } of newKempo.settings || []){
129
- if(!oldSettingNames.has(settingName)){
130
- await setSetting(name, settingName, declaredValue(value, type), type || null, false, description || null);
131
- }
132
- }
133
-
134
- /*
135
- Add new groups
136
- */
137
- const oldGroupNames = new Set((oldKempo.groups || []).map(g => g.name));
138
- for(const { name: groupName, description, permissions: groupPerms = [] } of newKempo.groups || []){
139
- if(!oldGroupNames.has(groupName)){
140
- await db.insert(group).values({
141
- name: groupName,
142
- description: description || null,
143
- owner: name,
144
- createdAt: new Date(),
145
- }).onConflictDoNothing();
146
- for(const permName of groupPerms){
147
- await db.insert(groupPermission).values({
148
- id: randomUUID(),
149
- groupName,
150
- permissionName: permName,
151
- createdAt: new Date(),
152
- }).onConflictDoNothing();
153
- }
154
- }
155
- }
156
-
157
- /*
158
- Sync hooks: add new, remove deleted
159
- */
160
- const oldHooks = oldKempo.hooks || {};
161
- const newHooks = newKempo.hooks || {};
162
- for(const [event, callback] of Object.entries(newHooks)){
163
- if(!oldHooks[event]){
164
- await createHook({ owner: name, event, callback });
165
- }
166
- }
167
- for(const event of Object.keys(oldHooks)){
168
- if(!newHooks[event]){
169
- await db.delete(hook).where(and(eq(hook.owner, name), eq(hook.event, event))).catch(() => {});
170
- }
171
- }
172
-
173
- /*
174
- Sync dashboard widgets: register or refresh every declared one, remove those no longer declared
175
- */
176
- const newWidgetKeys = new Set((newKempo.widgets || []).map(w => w.key));
177
- for(const widget of newKempo.widgets || []){
178
- const [widgetError] = await registerDashboardWidget({ ...widget, owner: name });
179
- if(widgetError){
180
- console.warn(`[${name}] dashboard widget "${widget.key}" was not registered: ${widgetError.msg}`);
181
- }
182
- }
183
- for(const { key } of oldKempo.widgets || []){
184
- if(!newWidgetKeys.has(key)){
185
- await unregisterDashboardWidget({ owner: name, key });
186
- }
187
- }
188
-
189
- /*
190
- Sync scheduled jobs: the schedule becomes exactly what is declared now
191
- */
192
- if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
193
- const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
194
- if(jobsError){
195
- console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
196
- }
197
- }
198
-
199
- const now = new Date();
200
- try {
201
- await db.update(extension).set({
202
- version: pkg.version || null,
203
- kempo: newKempo,
204
- updatedAt: now,
205
- }).where(eq(extension.name, name));
206
- } catch(error){
207
- return [{ code: 500, msg: 'Failed to update extension record' }, null];
208
- }
209
-
210
- clearHandlerCache();
211
- await triggerHook('extension:updated', { name, oldVersion, newVersion: pkg.version });
212
-
213
- return [null, { name, oldVersion, newVersion: pkg.version }];
75
+ return upgradeExtension({ name, record: oldRecord, extRoot });
214
76
  };
215
-
@@ -0,0 +1,117 @@
1
+ import db from '../../db/index.js';
2
+ import { extension, hook, permission, group, groupPermission } from '../../db/schema.js';
3
+ import { eq, and } from 'drizzle-orm';
4
+ import { join } from 'path';
5
+ import { pathToFileURL } from 'url';
6
+ import { randomUUID } from 'crypto';
7
+ import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
8
+ import createHook from '../hooks/createHook.js';
9
+ import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
10
+ import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
11
+ import registerJobs from '../scheduler/registerJobs.js';
12
+ import setSetting from '../settings/setSetting.js';
13
+ import declaredValue from '../settings/declaredValue.js';
14
+ import syncCoreSchema from '../setup/syncCoreSchema.js';
15
+ import readKempoConfig from './readKempoConfig.js';
16
+ import { invalidateScopeCache } from './scopeCache.js';
17
+
18
+ /*
19
+ Brings the database in step with a newer version of an extension that is already on disk: its tables (additions only: new
20
+ tables, columns and indexes), its own `update.js` (given the old and new version and what each declared), and whatever the
21
+ new version declares that the old one did not (permissions, settings, groups, hooks, dashboard widgets and scheduled jobs).
22
+ It only adds: a permission, setting or group an extension no longer declares stays, because data may depend on it, and a
23
+ setting the owner changed keeps their value. Hooks, widgets and jobs follow what is declared now.
24
+
25
+ `record` is the extension's row as it was before. Resolves to [error, { name, oldVersion, newVersion }]. Run by the startup
26
+ step (reconcileExtensions) and by updateExtension, never on a version that is older than the row's.
27
+ */
28
+ export default async ({ name, record, extRoot = join(process.cwd(), 'node_modules', name) }) => {
29
+ const oldKempo = record.kempo || {};
30
+ const oldVersion = record.version;
31
+
32
+ let pkg, newKempo;
33
+ try {
34
+ ({ pkg, kempo: newKempo } = await readKempoConfig(extRoot));
35
+ } catch {
36
+ return [{ code: 404, msg: `Package "${name}" is not installed` }, null];
37
+ }
38
+
39
+ if(newKempo.schema){
40
+ try {
41
+ const schemaModule = await import(`${pathToFileURL(join(extRoot, newKempo.schema)).href}?t=${Date.now()}`);
42
+ const [schemaError, synced] = await syncCoreSchema({ schema: schemaModule });
43
+ if(schemaError) return [schemaError, null];
44
+ for(const { change, reason } of synced.failed) console.warn(`[${name}] database change not applied: ${change}: ${reason}`);
45
+ } catch(error) {
46
+ return [{ code: 500, msg: `Failed to load extension schema: ${error.message}` }, null];
47
+ }
48
+ }
49
+
50
+ try {
51
+ const updateModule = await import(`${pathToFileURL(join(extRoot, 'update.js')).href}?t=${Date.now()}`).catch(() => null);
52
+ if(updateModule?.default){
53
+ await updateModule.default({ oldVersion, newVersion: pkg.version, oldKempo, newKempo });
54
+ }
55
+ } catch(error) {
56
+ return [{ code: 500, msg: `Extension update script failed: ${error.message}` }, null];
57
+ }
58
+
59
+ const oldPermNames = new Set((oldKempo.permissions || []).map(p => p.name));
60
+ for(const { name: permName, description } of newKempo.permissions || []){
61
+ if(!oldPermNames.has(permName)){
62
+ await db.insert(permission).values({ name: permName, description: description || null, owner: name, createdAt: new Date() }).onConflictDoNothing();
63
+ }
64
+ }
65
+
66
+ const oldSettingNames = new Set((oldKempo.settings || []).map(s => s.name));
67
+ for(const { name: settingName, value, type, description } of newKempo.settings || []){
68
+ if(!oldSettingNames.has(settingName)){
69
+ await setSetting(name, settingName, declaredValue(value, type), type || null, false, description || null);
70
+ }
71
+ }
72
+
73
+ const oldGroupNames = new Set((oldKempo.groups || []).map(g => g.name));
74
+ for(const { name: groupName, description, permissions: groupPerms = [] } of newKempo.groups || []){
75
+ if(!oldGroupNames.has(groupName)){
76
+ await db.insert(group).values({ name: groupName, description: description || null, owner: name, createdAt: new Date() }).onConflictDoNothing();
77
+ for(const permName of groupPerms){
78
+ await db.insert(groupPermission).values({ id: randomUUID(), groupName, permissionName: permName, createdAt: new Date() }).onConflictDoNothing();
79
+ }
80
+ }
81
+ }
82
+
83
+ const oldHooks = oldKempo.hooks || {};
84
+ const newHooks = newKempo.hooks || {};
85
+ for(const [event, callback] of Object.entries(newHooks)){
86
+ if(!oldHooks[event]) await createHook({ owner: name, event, callback });
87
+ }
88
+ for(const event of Object.keys(oldHooks)){
89
+ if(!newHooks[event]) await db.delete(hook).where(and(eq(hook.owner, name), eq(hook.event, event))).catch(() => {});
90
+ }
91
+
92
+ const newWidgetKeys = new Set((newKempo.widgets || []).map(w => w.key));
93
+ for(const widget of newKempo.widgets || []){
94
+ const [widgetError] = await registerDashboardWidget({ ...widget, owner: name });
95
+ if(widgetError) console.warn(`[${name}] dashboard widget "${widget.key}" was not registered: ${widgetError.msg}`);
96
+ }
97
+ for(const { key } of oldKempo.widgets || []){
98
+ if(!newWidgetKeys.has(key)) await unregisterDashboardWidget({ owner: name, key });
99
+ }
100
+
101
+ if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
102
+ const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
103
+ if(jobsError) console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
104
+ }
105
+
106
+ try {
107
+ await db.update(extension).set({ version: pkg.version || null, kempo: newKempo, updatedAt: new Date() }).where(eq(extension.name, name));
108
+ } catch {
109
+ return [{ code: 500, msg: 'Failed to update extension record' }, null];
110
+ }
111
+
112
+ clearHandlerCache();
113
+ invalidateScopeCache();
114
+ await triggerHook('extension:updated', { name, oldVersion, newVersion: pkg.version });
115
+
116
+ return [null, { name, oldVersion, newVersion: pkg.version }];
117
+ };