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.
- package/README.md +7 -3
- package/bin/cli.js +23 -1
- package/dist/admin/component-overrides.js +1 -1
- package/dist/admin/components/AddGroupControl.js +1 -1
- package/dist/admin/components/AdminTableControl.js +1 -1
- package/dist/admin/components/ClearExpiredSessionsControl.js +1 -1
- package/dist/admin/components/Dashboard.js +1 -1
- package/dist/admin/components/EmailTemplates.js +1 -1
- package/dist/admin/components/Emails.js +1 -1
- package/dist/admin/components/FragmentEditor.js +1 -1
- package/dist/admin/components/GlobalContentEditor.js +1 -1
- package/dist/admin/components/GroupDeleteSelected.js +1 -1
- package/dist/admin/components/GroupRemoveRecord.js +1 -1
- package/dist/admin/components/Menus.js +1 -1
- package/dist/admin/components/Monaco.js +1 -1
- package/dist/admin/components/PageEditor.js +1 -1
- package/dist/admin/components/TableRowDelete.js +1 -1
- package/dist/admin/components/TableRowLink.js +1 -1
- package/dist/admin/components/TemplateEditor.js +1 -1
- package/dist/admin/components/Updates.js +117 -0
- package/dist/admin/components/Updates.js.map +1 -0
- package/dist/admin/components/ValueEditor.js +1 -1
- package/dist/admin/components/dashboardLayout.js +1 -1
- package/dist/admin/components/settings/AdminSettings.js +1 -1
- package/dist/admin/components/settings/BooleanSetting.js +1 -1
- package/dist/admin/components/settings/JsonSetting.js +1 -1
- package/dist/admin/components/settings/NumberSetting.js +1 -1
- package/dist/admin/components/settings/SecretSetting.js +1 -1
- package/dist/admin/components/settings/Setting.js +1 -1
- package/dist/admin/components/settings/SettingEditor.js +1 -1
- package/dist/admin/components/settings/SettingsPanel.js +1 -1
- package/dist/admin/components/settings/StringSetting.js +1 -1
- package/dist/admin/components/settings/humanize.js +1 -1
- package/dist/admin/components/tableControls/AddMember.js +1 -1
- package/dist/admin/components/tableControls/AddPermission.js +1 -1
- package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
- package/dist/admin/components/tableControls/InstallExtension.js +1 -1
- package/dist/admin/components/tableControls/ToggleExtension.js +1 -1
- package/dist/admin/components/tableControls/UninstallExtension.js +1 -1
- package/dist/admin/components/widgets/Content.js +1 -1
- package/dist/admin/components/widgets/Extensions.js +1 -1
- package/dist/admin/components/widgets/Notifications.js +1 -1
- package/dist/admin/components/widgets/Pages.js +1 -1
- package/dist/admin/components/widgets/Users.js +1 -1
- package/dist/admin/init.js +1 -1
- package/dist/admin/nav.fragment.html +6 -0
- package/dist/admin/updates/index.page.html +14 -0
- package/dist/kempo/api/admin-globals/DELETE.js +1 -1
- package/dist/kempo/api/admin-globals/GET.js +1 -1
- package/dist/kempo/api/admin-globals/POST.js +1 -1
- package/dist/kempo/api/admin-globals/[id]/GET.js +1 -1
- package/dist/kempo/api/admin-globals/[id]/PUT.js +1 -1
- package/dist/kempo/api/admin-globals/[id]/disable/PUT.js +1 -1
- package/dist/kempo/api/admin-globals/[id]/enable/PUT.js +1 -1
- package/dist/kempo/api/auth/change-password/POST.js +1 -1
- package/dist/kempo/api/auth/login/email/POST.js +1 -1
- package/dist/kempo/api/auth/logout/POST.js +1 -1
- package/dist/kempo/api/auth/register/email/POST.js +1 -1
- package/dist/kempo/api/auth/reset-password/POST.js +1 -1
- package/dist/kempo/api/auth/session/GET.js +1 -1
- package/dist/kempo/api/auth/verify-email/POST.js +1 -1
- package/dist/kempo/api/dashboard/widgets/GET.js +1 -1
- package/dist/kempo/api/email-templates/GET.js +1 -1
- package/dist/kempo/api/email-templates/file/DELETE.js +1 -1
- package/dist/kempo/api/email-templates/file/GET.js +1 -1
- package/dist/kempo/api/email-templates/file/PUT.js +1 -1
- package/dist/kempo/api/email-templates/preview/GET.js +1 -1
- package/dist/kempo/api/emails/GET.js +1 -1
- package/dist/kempo/api/emails/[id]/GET.js +1 -1
- package/dist/kempo/api/emails/[id]/retry/POST.js +1 -1
- package/dist/kempo/api/emails/test/POST.js +1 -1
- package/dist/kempo/api/extensions/DELETE.js +1 -1
- package/dist/kempo/api/extensions/GET.js +1 -1
- package/dist/kempo/api/extensions/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/dependents/GET.js +1 -1
- package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/requirements/GET.js +1 -1
- package/dist/kempo/api/extensions/[name]/update/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/update-check/GET.js +1 -1
- package/dist/kempo/api/extensions/available/GET.js +1 -1
- package/dist/kempo/api/extensions/known/GET.js +1 -1
- package/dist/kempo/api/extensions/rating/PUT.js +1 -1
- package/dist/kempo/api/forgot-password/POST.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/groups/DELETE.js +1 -1
- package/dist/kempo/api/groups/GET.js +1 -1
- package/dist/kempo/api/groups/PATCH.js +1 -1
- package/dist/kempo/api/groups/POST.js +1 -1
- package/dist/kempo/api/groups/[name]/GET.js +1 -1
- package/dist/kempo/api/groups/[name]/members/DELETE.js +1 -1
- package/dist/kempo/api/groups/[name]/members/GET.js +1 -1
- package/dist/kempo/api/groups/[name]/members/POST.js +1 -1
- package/dist/kempo/api/groups/[name]/permissions/DELETE.js +1 -1
- package/dist/kempo/api/groups/[name]/permissions/GET.js +1 -1
- package/dist/kempo/api/groups/[name]/permissions/POST.js +1 -1
- package/dist/kempo/api/menus/DELETE.js +1 -1
- package/dist/kempo/api/menus/GET.js +1 -1
- package/dist/kempo/api/menus/POST.js +1 -1
- package/dist/kempo/api/menus/adopt/PUT.js +1 -1
- package/dist/kempo/api/menus/items/DELETE.js +1 -1
- package/dist/kempo/api/menus/items/POST.js +1 -1
- package/dist/kempo/api/menus/items/enabled/PUT.js +1 -1
- package/dist/kempo/api/menus/items/order/PUT.js +1 -1
- package/dist/kempo/api/menus/location/PUT.js +1 -1
- package/dist/kempo/api/notifications/GET.js +1 -1
- package/dist/kempo/api/notifications/[id]/DELETE.js +1 -1
- package/dist/kempo/api/notifications/[id]/handled/POST.js +1 -1
- package/dist/kempo/api/notifications/[id]/read/POST.js +1 -1
- package/dist/kempo/api/notifications/count/GET.js +1 -1
- package/dist/kempo/api/notifications/read-all/POST.js +1 -1
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/permissions/DELETE.js +1 -1
- package/dist/kempo/api/permissions/GET.js +1 -1
- package/dist/kempo/api/permissions/PATCH.js +1 -1
- package/dist/kempo/api/permissions/POST.js +1 -1
- package/dist/kempo/api/realtime/WS.js +1 -1
- package/dist/kempo/api/realtime/connections/GET.js +1 -1
- package/dist/kempo/api/settings/[owner]/GET.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/DELETE.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/GET.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
- package/dist/kempo/api/settings/public/GET.js +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/updates/GET.js +2 -0
- package/dist/kempo/api/updates/GET.js.map +1 -0
- package/dist/kempo/api/updates/apply/POST.js +2 -0
- package/dist/kempo/api/updates/apply/POST.js.map +1 -0
- package/dist/kempo/api/updates/check/POST.js +2 -0
- package/dist/kempo/api/updates/check/POST.js.map +1 -0
- package/dist/kempo/api/updates/discard/POST.js +2 -0
- package/dist/kempo/api/updates/discard/POST.js.map +1 -0
- package/dist/kempo/api/updates/prepare/POST.js +2 -0
- package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
- package/dist/kempo/api/updates/rollback/POST.js +2 -0
- package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
- package/dist/kempo/api/user/DELETE.js +1 -1
- package/dist/kempo/api/user/GET.js +1 -1
- package/dist/kempo/api/user/PATCH.js +1 -1
- package/dist/kempo/api/user/[userid]/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/groups/DELETE.js +1 -1
- package/dist/kempo/api/user/[userid]/groups/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/groups/POST.js +1 -1
- package/dist/kempo/api/user/[userid]/name/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/permissions/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/permissions/all/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/permissions/list/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/permissions/some/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/sessions/DELETE.js +1 -1
- package/dist/kempo/api/user/[userid]/sessions/GET.js +1 -1
- package/dist/kempo/api/user/[userid]/sessions/expired/DELETE.js +1 -1
- package/dist/kempo/api/user/create/POST.js +1 -1
- package/dist/kempo/api/user/search/GET.js +1 -1
- package/dist/kempo/components/AdminNavFooter.js +1 -1
- package/dist/kempo/components/DashboardWidget.js +1 -1
- package/dist/kempo/components/Login.js +1 -1
- package/dist/kempo/components/LogoutBtn.js +1 -1
- package/dist/kempo/components/Menu.js +1 -1
- package/dist/kempo/components/NotificationBell.js +1 -1
- package/dist/kempo/components/NotificationList.js +1 -1
- package/dist/kempo/components/Permission.js +1 -1
- package/dist/kempo/components/PermissionTarget.js +1 -1
- package/dist/kempo/components/Register.js +1 -1
- package/dist/kempo/components/Role.js +1 -1
- package/dist/kempo/components/UserCreatedAt.js +1 -1
- package/dist/kempo/components/UserDisplayName.js +1 -1
- package/dist/kempo/components/UserEmail.js +1 -1
- package/dist/kempo/components/UserEmailVerified.js +1 -1
- package/dist/kempo/components/UserName.js +1 -1
- package/dist/kempo/fetch.js +2 -2
- package/dist/kempo/fetch.js.map +1 -1
- package/dist/kempo/realtime.js +1 -1
- package/dist/kempo/sdk.js +2 -2
- package/dist/kempo/sdk.js.map +1 -1
- package/dist/kempo/utils/safeNext.js +1 -1
- package/docker/.dockerignore +9 -0
- package/docker/Dockerfile +25 -0
- package/docker/docker-compose.prod.yml +39 -0
- package/docker/entrypoint.mjs +96 -0
- package/middleware/kempo.js +53 -3
- package/package.json +3 -1
- package/server/db/schema.js +12 -0
- package/server/db/upgrades/README.md +20 -0
- package/server/sdk.js +3 -0
- package/server/utils/extensions/checkExtensionUpdate.js +9 -36
- package/server/utils/extensions/reconcileExtensions.js +90 -0
- package/server/utils/extensions/updateExtension.js +14 -153
- package/server/utils/extensions/upgradeExtension.js +117 -0
- package/server/utils/extensions/writeSiteFile.js +66 -0
- package/server/utils/settings/systemSettings.js +1 -0
- package/server/utils/setup/runUpgrades.js +70 -0
- package/server/utils/setup/seedDatabase.js +1 -1
- package/server/utils/setup/seedIfNew.js +21 -0
- package/server/utils/setup/syncCoreSchema.js +130 -0
- package/server/utils/setup/systemPermissions.js +2 -0
- package/server/utils/updates/applyStagedUpdate.js +95 -0
- package/server/utils/updates/applyUpdate.js +76 -0
- package/server/utils/updates/backupDatabase.js +35 -0
- package/server/utils/updates/discardUpdate.js +11 -0
- package/server/utils/updates/extensionTargets.js +101 -0
- package/server/utils/updates/fetchManifest.js +26 -0
- package/server/utils/updates/getExtensionUpdates.js +70 -0
- package/server/utils/updates/getInstallInfo.js +25 -0
- package/server/utils/updates/getUpdateStatus.js +57 -0
- package/server/utils/updates/helpers.js +30 -0
- package/server/utils/updates/notifySchemaAttention.js +35 -0
- package/server/utils/updates/npm.js +41 -0
- package/server/utils/updates/prepareUpdate.js +109 -0
- package/server/utils/updates/releaseStatus.js +23 -0
- package/server/utils/updates/restart.js +64 -0
- package/server/utils/updates/rollbackUpdate.js +56 -0
- package/server/utils/updates/runUpdateCheck.js +52 -0
- package/server/utils/updates/runtime.js +15 -0
- package/server/utils/updates/siteLayout.js +200 -0
- package/server/utils/updates/starterFiles.js +118 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join, normalize, sep } from 'path';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
For an extension that has to put a file into the site (a generated template, a starter page): writes it the way kempo's own
|
|
7
|
+
starter files are handled, so an update of the extension never overwrites what the owner changed.
|
|
8
|
+
|
|
9
|
+
not there, never written by this extension written
|
|
10
|
+
not there, but this extension wrote it before the owner deleted it, so it stays deleted
|
|
11
|
+
identical to what is being written nothing to do
|
|
12
|
+
still exactly what this extension last wrote never edited, so replaced
|
|
13
|
+
anything else edited by the owner (or anyone), so kept
|
|
14
|
+
|
|
15
|
+
The extension's fingerprints are in .kempo/shipped/<owner>.json. `path` is relative to the site and must be inside public/
|
|
16
|
+
or templates/. Most extensions need none of this, because their own public/ is served from the package; this is for the
|
|
17
|
+
few that generate something into the site. Resolves to [error, { status }] where status is "written", "current", "kept"
|
|
18
|
+
or "deleted".
|
|
19
|
+
*/
|
|
20
|
+
const hashOf = content => createHash('sha256').update(content).digest('hex');
|
|
21
|
+
|
|
22
|
+
export default ({ owner, path, content, cwd = process.cwd() } = {}) => {
|
|
23
|
+
if(typeof owner !== 'string' || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(owner)){
|
|
24
|
+
return [{ code: 400, msg: 'owner must be the extension name' }, null];
|
|
25
|
+
}
|
|
26
|
+
const relative = typeof path === 'string' ? normalize(path).split(sep).join('/') : '';
|
|
27
|
+
if(!relative || relative.startsWith('..') || relative.startsWith('/') || !/^(public|templates)\//.test(relative)){
|
|
28
|
+
return [{ code: 400, msg: 'path must be a file inside public/ or templates/' }, null];
|
|
29
|
+
}
|
|
30
|
+
if(typeof content !== 'string' && !Buffer.isBuffer(content)){
|
|
31
|
+
return [{ code: 400, msg: 'content must be text or a buffer' }, null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const recordFile = join(cwd, '.kempo', 'shipped', `${owner.replace('/', '__')}.json`);
|
|
35
|
+
let files = {};
|
|
36
|
+
try {
|
|
37
|
+
files = JSON.parse(readFileSync(recordFile, 'utf8')).files ?? {};
|
|
38
|
+
} catch {
|
|
39
|
+
files = {};
|
|
40
|
+
}
|
|
41
|
+
const save = () => {
|
|
42
|
+
mkdirSync(dirname(recordFile), { recursive: true });
|
|
43
|
+
writeFileSync(recordFile, JSON.stringify({ files }, null, 2));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const target = join(cwd, relative);
|
|
47
|
+
const wanted = hashOf(content);
|
|
48
|
+
|
|
49
|
+
if(!existsSync(target)){
|
|
50
|
+
if(files[relative]) return [null, { status: 'deleted' }];
|
|
51
|
+
} else {
|
|
52
|
+
const here = hashOf(readFileSync(target));
|
|
53
|
+
if(here === wanted){
|
|
54
|
+
files[relative] = wanted;
|
|
55
|
+
save();
|
|
56
|
+
return [null, { status: 'current' }];
|
|
57
|
+
}
|
|
58
|
+
if(files[relative] !== here) return [null, { status: 'kept' }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
62
|
+
writeFileSync(target, content);
|
|
63
|
+
files[relative] = wanted;
|
|
64
|
+
save();
|
|
65
|
+
return [null, { status: 'written' }];
|
|
66
|
+
};
|
|
@@ -20,6 +20,7 @@ export const SYSTEM_SETTINGS = [
|
|
|
20
20
|
{ owner: 'system', name: 'email_retention_days', value: 30, type: 'number', isPublic: false, description: 'Days to keep the log of sent emails before it is deleted (0 keeps it forever)' },
|
|
21
21
|
{ owner: 'system', name: 'email_retention_by_type', value: {}, type: 'json', isPublic: false, description: 'Days to keep a particular kind of email, instead of the number above, as { "verify-email": 7, "kempo-commerce/order-processing": 365 } (0 keeps that kind forever)' },
|
|
22
22
|
{ owner: 'system', name: 'email_from', value: '', type: 'string', isPublic: false, description: 'The address emails are sent from, as name@example.com or Shop Name <name@example.com>. It must be on a domain verified with the email provider. Empty uses EMAIL_FROM from the server environment' },
|
|
23
|
+
{ owner: 'system', name: 'update_check_enabled', value: true, type: 'boolean', isPublic: false, description: 'Check npm every 12 hours for a newer kempo and tell the people who can install it' },
|
|
23
24
|
];
|
|
24
25
|
|
|
25
26
|
/* Resolves to [error, { added: [names] }]. */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
4
|
+
import { sql } from 'drizzle-orm';
|
|
5
|
+
import db from '../../db/index.js';
|
|
6
|
+
import { upgrade } from '../../db/schema.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Kempo's own data migrations: the files in server/db/upgrades, which restructure or move data that the additive sync of
|
|
10
|
+
the tables (syncCoreSchema) cannot. See that folder's README for how to write one.
|
|
11
|
+
|
|
12
|
+
They run in file-name order, each once, in a transaction, when the server starts and after the tables are in step. A
|
|
13
|
+
database that has never had the `upgrade` table is taken to already hold what the shipped migrations make (a new site,
|
|
14
|
+
or a site on its first version with this): they are recorded as run without being run, under a row called 'baseline'.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'db', 'upgrades');
|
|
17
|
+
|
|
18
|
+
const filesIn = dir => (existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith('.js')).sort() : []);
|
|
19
|
+
|
|
20
|
+
const load = (dir, file) => import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`);
|
|
21
|
+
|
|
22
|
+
const appliedIds = async executor => new Set((await executor.select({ id: upgrade.id }).from(upgrade)).map(row => row.id));
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
What would run, in order, as [{ id, description, breaking }]. `dir` is another version's folder, to look ahead at an
|
|
26
|
+
update before it is switched to. Nothing is run or recorded.
|
|
27
|
+
*/
|
|
28
|
+
export const pendingUpgrades = async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
|
|
29
|
+
const applied = await appliedIds(executor);
|
|
30
|
+
if(!applied.has('baseline')) return [];
|
|
31
|
+
const pending = [];
|
|
32
|
+
for(const file of filesIn(dir).filter(name => !applied.has(name))){
|
|
33
|
+
const { meta = {} } = await load(dir, file);
|
|
34
|
+
pending.push({ id: file, description: meta.description ?? '', breaking: Boolean(meta.breaking) });
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
Resolves to [error, { ran }]. An error is the first migration that failed, with `breaking` saying whether it was one the
|
|
41
|
+
previous version cannot run without; nothing after it runs, and what it did is rolled back.
|
|
42
|
+
*/
|
|
43
|
+
export default async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
|
|
44
|
+
try {
|
|
45
|
+
const applied = await appliedIds(executor);
|
|
46
|
+
if(!applied.has('baseline')){
|
|
47
|
+
await executor.transaction(async tx => {
|
|
48
|
+
for(const id of [...filesIn(dir), 'baseline']) await tx.insert(upgrade).values({ id, description: id === 'baseline' ? 'Existing migrations were already part of this database' : 'Part of the baseline' }).onConflictDoNothing();
|
|
49
|
+
});
|
|
50
|
+
return [null, { ran: [] }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ran = [];
|
|
54
|
+
for(const file of filesIn(dir).filter(name => !applied.has(name))){
|
|
55
|
+
const { meta = {}, up } = await load(dir, file);
|
|
56
|
+
try {
|
|
57
|
+
await executor.transaction(async tx => {
|
|
58
|
+
await up({ db: tx, sql });
|
|
59
|
+
await tx.insert(upgrade).values({ id: file, description: meta.description ?? null, breaking: Boolean(meta.breaking) });
|
|
60
|
+
});
|
|
61
|
+
ran.push(file);
|
|
62
|
+
} catch(error) {
|
|
63
|
+
return [{ code: 500, msg: `The data migration ${file} failed: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: Boolean(meta.breaking), id: file }, { ran }];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [null, { ran }];
|
|
67
|
+
} catch(error) {
|
|
68
|
+
return [{ code: 500, msg: `The data migrations could not be checked: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: false }, null];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -22,7 +22,7 @@ const DEFAULT_GROUPS = {
|
|
|
22
22
|
'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
|
|
23
23
|
'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
|
|
24
24
|
'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
|
|
25
|
-
'system:realtime:read', 'system:emails:read', 'system:emails:manage'
|
|
25
|
+
'system:realtime:read', 'system:emails:read', 'system:emails:manage', 'system:updates:manage'
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import db from '../../db/index.js';
|
|
3
|
+
import { group } from '../../db/schema.js';
|
|
4
|
+
import seedDatabase from './seedDatabase.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
A database with no Administrators group is a new one (the group cannot be deleted), so kempo's groups, permissions and
|
|
8
|
+
settings are put in it, which is what `scripts/init-db.js` does by hand. It is what lets a new site start from an empty
|
|
9
|
+
database with no command typed: the tables come from syncCoreSchema, this fills them, and the /setup page makes the
|
|
10
|
+
first administrator. A database that has the group is never touched.
|
|
11
|
+
*/
|
|
12
|
+
export default async () => {
|
|
13
|
+
try {
|
|
14
|
+
const [existing] = await db.select({ name: group.name }).from(group).where(eq(group.name, 'system:Administrators')).limit(1);
|
|
15
|
+
if(existing) return [null, { seeded: false }];
|
|
16
|
+
await seedDatabase({ log: () => {} });
|
|
17
|
+
return [null, { seeded: true }];
|
|
18
|
+
} catch(error) {
|
|
19
|
+
return [{ code: 500, msg: `A new database could not be set up: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import db from '../../db/index.js';
|
|
3
|
+
import * as coreSchema from '../../db/schema.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Brings kempo's own tables in step with the installed version of kempo, so an update never needs a command typed.
|
|
7
|
+
It runs when the server starts, before the site serves anything.
|
|
8
|
+
|
|
9
|
+
It only ever adds. It looks at which of the schema's tables, columns, indexes and constraints the database already
|
|
10
|
+
has, and asks drizzle-kit's migration generator (the one `drizzle-kit push` is built on) for the difference between
|
|
11
|
+
"what the database has" and "what the schema says", so the new indexes and foreign keys come out exactly as a push
|
|
12
|
+
would make them. What the database has is the schema with every missing piece cut out, so the difference can only
|
|
13
|
+
be additions: it can never contain a DROP or a TRUNCATE, and a table that an extension or the site made is never
|
|
14
|
+
looked at. A column that kempo no longer has stays where it is. A change to something that already exists (a new
|
|
15
|
+
type, a NOT NULL) is not detected here: that is what a release's own migration step is for.
|
|
16
|
+
|
|
17
|
+
Each statement is applied on its own, so one the database refuses (a new unique index over rows that already break
|
|
18
|
+
it, a NOT NULL column with no default on a table that has rows) is reported without holding back the rest. Several
|
|
19
|
+
processes starting together queue on an advisory lock, and the one after the first finds nothing left to do.
|
|
20
|
+
|
|
21
|
+
(drizzle-kit's `pushSchema` is not used: it drops the parameters of its own introspection queries, which fails on any
|
|
22
|
+
table with a composite key and then ends the process, and for a type change it proposes TRUNCATE ... CASCADE.)
|
|
23
|
+
*/
|
|
24
|
+
const LOCK_KEY = 'kempo:sync-core-schema';
|
|
25
|
+
|
|
26
|
+
const ADDITIVE = [
|
|
27
|
+
/^CREATE TABLE /i,
|
|
28
|
+
/^CREATE (UNIQUE )?INDEX /i,
|
|
29
|
+
/^CREATE TYPE /i,
|
|
30
|
+
/^ALTER TABLE .+ ADD COLUMN /i,
|
|
31
|
+
/^ALTER TABLE .+ ADD CONSTRAINT /i,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/* A last check on what the generator produced: anything that is not an addition is never run. */
|
|
35
|
+
export const isAdditive = statement => ADDITIVE.some(pattern => pattern.test(statement.trim()));
|
|
36
|
+
|
|
37
|
+
/* One plain sentence for a statement, for the log and the Updates screen. */
|
|
38
|
+
export const describe = statement => {
|
|
39
|
+
const text = statement.trim();
|
|
40
|
+
const named = (pattern, make) => {
|
|
41
|
+
const match = text.match(pattern);
|
|
42
|
+
return match ? make(...match.slice(1)) : null;
|
|
43
|
+
};
|
|
44
|
+
return named(/^CREATE TABLE "([^"]+)"/i, table => `Created table ${table}`)
|
|
45
|
+
?? named(/^ALTER TABLE "([^"]+)" ADD COLUMN "([^"]+)"/i, (table, column) => `Added column ${table}.${column}`)
|
|
46
|
+
?? named(/^CREATE (?:UNIQUE )?INDEX "([^"]+)"/i, index => `Created index ${index}`)
|
|
47
|
+
?? named(/^ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)"/i, (table, constraint) => `Added ${constraint} to ${table}`)
|
|
48
|
+
?? text.replace(/\s+/g, ' ').slice(0, 120);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const rowsOf = result => (Array.isArray(result) ? result : result.rows);
|
|
52
|
+
|
|
53
|
+
/* The names of what the database already has in the public schema. */
|
|
54
|
+
const readDatabase = async executor => {
|
|
55
|
+
const names = async (query, field) => new Set(rowsOf(await executor.execute(query)).map(row => row[field]));
|
|
56
|
+
const columns = rowsOf(await executor.execute(sql`select table_name, column_name from information_schema.columns where table_schema = 'public'`));
|
|
57
|
+
return {
|
|
58
|
+
tables: await names(sql`select table_name from information_schema.tables where table_schema = 'public'`, 'table_name'),
|
|
59
|
+
columns: new Set(columns.map(row => `${row.table_name}.${row.column_name}`)),
|
|
60
|
+
indexes: await names(sql`select indexname from pg_indexes where schemaname = 'public'`, 'indexname'),
|
|
61
|
+
constraints: await names(sql`select conname from pg_constraint join pg_namespace on pg_namespace.oid = pg_constraint.connamespace where nspname = 'public'`, 'conname'),
|
|
62
|
+
enums: await names(sql`select typname from pg_type join pg_namespace on pg_namespace.oid = pg_type.typnamespace where typtype = 'e' and nspname = 'public'`, 'typname'),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/* The schema snapshot with everything the database lacks cut out: what drizzle-kit would see if it had introspected the database. */
|
|
67
|
+
const withoutMissing = (snapshot, existing) => {
|
|
68
|
+
const prev = structuredClone(snapshot);
|
|
69
|
+
for(const key of Object.keys(prev.enums)){
|
|
70
|
+
if(!existing.enums.has(prev.enums[key].name)) delete prev.enums[key];
|
|
71
|
+
}
|
|
72
|
+
for(const [key, table] of Object.entries(prev.tables)){
|
|
73
|
+
if(!existing.tables.has(table.name)){
|
|
74
|
+
delete prev.tables[key];
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for(const name of Object.keys(table.columns)){
|
|
78
|
+
if(!existing.columns.has(`${table.name}.${name}`)) delete table.columns[name];
|
|
79
|
+
}
|
|
80
|
+
for(const name of Object.keys(table.indexes)){
|
|
81
|
+
if(!existing.indexes.has(name)) delete table.indexes[name];
|
|
82
|
+
}
|
|
83
|
+
for(const group of [table.foreignKeys, table.uniqueConstraints, table.compositePrimaryKeys ?? {}]){
|
|
84
|
+
for(const id of Object.keys(group)){
|
|
85
|
+
if(!existing.constraints.has(group[id].name)) delete group[id];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return prev;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/* What the last run did, for the Updates screen. */
|
|
93
|
+
let lastRun = { ran: false, applied: [], failed: [] };
|
|
94
|
+
|
|
95
|
+
export const getSchemaSyncResult = () => lastRun;
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
Resolves to [error, { applied, failed }]: `applied` is a list of sentences, `failed` is { change, reason } for what
|
|
99
|
+
the database refused. `schema` and `executor` are for tests and extensions: other tables, in another database.
|
|
100
|
+
*/
|
|
101
|
+
export default async ({ schema = coreSchema, executor = db } = {}) => {
|
|
102
|
+
try {
|
|
103
|
+
const { generateDrizzleJson, generateMigration } = await import('drizzle-kit/api');
|
|
104
|
+
const wanted = generateDrizzleJson(schema);
|
|
105
|
+
const result = { applied: [], failed: [] };
|
|
106
|
+
|
|
107
|
+
await executor.transaction(async tx => {
|
|
108
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${LOCK_KEY}::text))`);
|
|
109
|
+
const statements = await generateMigration(withoutMissing(wanted, await readDatabase(tx)), wanted);
|
|
110
|
+
|
|
111
|
+
for(const statement of statements){
|
|
112
|
+
if(!isAdditive(statement)){
|
|
113
|
+
result.failed.push({ change: describe(statement), reason: 'Kempo only adds to its tables, so this was not run' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await tx.transaction(inner => inner.execute(sql.raw(statement)));
|
|
118
|
+
result.applied.push(describe(statement));
|
|
119
|
+
} catch(error) {
|
|
120
|
+
result.failed.push({ change: describe(statement), reason: String(error.cause?.message || error.message).slice(0, 300) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if(schema === coreSchema) lastRun = { ran: true, ...result };
|
|
126
|
+
return [null, result];
|
|
127
|
+
} catch(error) {
|
|
128
|
+
return [{ code: 500, msg: `The database could not be checked against this version of kempo: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
@@ -55,6 +55,8 @@ export const SYSTEM_PERMISSIONS = [
|
|
|
55
55
|
{ name: 'system:emails:read', description: 'View the log of emails, including what they said' },
|
|
56
56
|
{ name: 'system:emails:manage', description: 'Send a test email and try failed emails again' },
|
|
57
57
|
|
|
58
|
+
{ name: 'system:updates:manage', description: 'See, check for and install kempo updates, which restarts the server' },
|
|
59
|
+
|
|
58
60
|
{ name: 'system:admin:access', description: 'Access admin panel' },
|
|
59
61
|
];
|
|
60
62
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { and, eq, like } from 'drizzle-orm';
|
|
4
|
+
import db from '../../db/index.js';
|
|
5
|
+
import { notification } from '../../db/schema.js';
|
|
6
|
+
import { RUNNING_VERSION } from './runtime.js';
|
|
7
|
+
import backupDatabase from './backupDatabase.js';
|
|
8
|
+
import { restartMode, scheduleRestart } from './restart.js';
|
|
9
|
+
import { clearState, paths, readState, requestSwitch } from './siteLayout.js';
|
|
10
|
+
import { applyStarterFiles } from './starterFiles.js';
|
|
11
|
+
import { PACKAGE } from './helpers.js';
|
|
12
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
13
|
+
import markHandled from '../notifications/markHandled.js';
|
|
14
|
+
|
|
15
|
+
let switching = false;
|
|
16
|
+
|
|
17
|
+
/* The notifications about extension updates are done with once the owner has acted on them. */
|
|
18
|
+
const resolveExtensionNotices = async () => {
|
|
19
|
+
const rows = await db.select({ id: notification.id }).from(notification).where(and(eq(notification.owner, PACKAGE), like(notification.dedupeKey, 'extension-updates:%')));
|
|
20
|
+
for(const { id } of rows) await markHandled({ notificationId: id });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
Switches a site that runs from a Docker volume to what prepareUpdate staged (a new kempo, new extensions, or both): replaces
|
|
25
|
+
the starter files the owner never touched (and, when the owner confirms, the ones a security fix must replace, keeping a
|
|
26
|
+
copy of each), backs the database up first if anything in the update changes data, then asks for the switch and restarts.
|
|
27
|
+
The startup script moves the code into place while nothing is running, the site is only unavailable for the restart, and
|
|
28
|
+
at that start each extension is brought up to date (reconcileExtensions) before the site serves anything.
|
|
29
|
+
|
|
30
|
+
`confirm` is { forced: true, breaking: true }: the owner has accepted that edited files will be replaced, and that an
|
|
31
|
+
update which changes data (a data migration in kempo, or a new major version of an extension) can only be undone from a
|
|
32
|
+
backup. Without them, an update that needs them answers 409 with `details` saying which, and does nothing.
|
|
33
|
+
|
|
34
|
+
Resolves to [error, { from, to, restart, breaking }].
|
|
35
|
+
*/
|
|
36
|
+
export default async ({ confirm = {}, siteRoot = process.env.KEMPO_SITE_ROOT, cwd = process.cwd(), restart = scheduleRestart, backup = backupDatabase } = {}) => {
|
|
37
|
+
if(!siteRoot){
|
|
38
|
+
return [{ code: 409, msg: 'This site is not set up to switch to a prepared update' }, null];
|
|
39
|
+
}
|
|
40
|
+
if(switching){
|
|
41
|
+
return [{ code: 409, msg: 'A switch is already under way' }, null];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const staged = readState(siteRoot, 'staged');
|
|
45
|
+
if(!staged || !existsSync(join(staged.dir, 'node_modules'))){
|
|
46
|
+
return [{ code: 409, msg: 'No update is ready: prepare one first' }, null];
|
|
47
|
+
}
|
|
48
|
+
if(readState(siteRoot, 'switch')){
|
|
49
|
+
return [{ code: 409, msg: 'The last switch has not finished proving itself yet' }, null];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { plan } = staged;
|
|
53
|
+
if(plan.files.forced.length && !confirm.forced){
|
|
54
|
+
return [{ code: 409, msg: 'This update must replace files you have edited', details: { confirm: 'forced', files: plan.files.forced } }, null];
|
|
55
|
+
}
|
|
56
|
+
if(plan.breaking && !confirm.breaking){
|
|
57
|
+
return [{ code: 409, msg: 'This update changes your data and can only be undone from a backup', details: { confirm: 'breaking', upgrades: plan.upgrades.filter(upgrade => upgrade.breaking), extensions: plan.extensions.filter(extension => extension.breaking).map(extension => extension.name) } }, null];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
switching = true;
|
|
61
|
+
try {
|
|
62
|
+
let dbBackup = null;
|
|
63
|
+
if(plan.breaking){
|
|
64
|
+
dbBackup = join(paths(siteRoot).state, 'backups', `${staged.id}.sql.gz`);
|
|
65
|
+
const [backupError] = await backup({ file: dbBackup });
|
|
66
|
+
if(backupError) return [backupError, null];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const filesBackup = staged.kempoUpdate
|
|
70
|
+
? applyStarterFiles({
|
|
71
|
+
cwd,
|
|
72
|
+
newRoot: join(staged.dir, 'node_modules', 'kempo'),
|
|
73
|
+
version: staged.version,
|
|
74
|
+
plan: plan.files,
|
|
75
|
+
includeForced: Boolean(confirm.forced),
|
|
76
|
+
backupDir: join(paths(siteRoot).state, 'backups', `${staged.id}-files`)
|
|
77
|
+
})
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
requestSwitch({ siteRoot, id: staged.id, version: staged.version, source: staged.dir, kind: 'update', breaking: plan.breaking, packages: staged.packages, dbBackup, filesBackup });
|
|
81
|
+
clearState(siteRoot, 'staged');
|
|
82
|
+
|
|
83
|
+
if(staged.kempoUpdate){
|
|
84
|
+
await markHandled({ owner: PACKAGE, dedupeKey: `update:${staged.version}` });
|
|
85
|
+
await triggerHook('kempo:updated', { oldVersion: RUNNING_VERSION, newVersion: staged.version, packages: staged.packages });
|
|
86
|
+
}
|
|
87
|
+
if(plan.extensions.length) await resolveExtensionNotices();
|
|
88
|
+
restart();
|
|
89
|
+
return [null, { from: RUNNING_VERSION, to: staged.version, restart: restartMode(), breaking: plan.breaking }];
|
|
90
|
+
} catch(error) {
|
|
91
|
+
return [{ code: 500, msg: `The switch could not be requested: ${error.message}` }, null];
|
|
92
|
+
} finally {
|
|
93
|
+
switching = false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { PACKAGE, compareVersions } from './helpers.js';
|
|
2
|
+
import extensionTargets from './extensionTargets.js';
|
|
3
|
+
import { RUNNING_VERSION } from './runtime.js';
|
|
4
|
+
import fetchManifest from './fetchManifest.js';
|
|
5
|
+
import getInstallInfo from './getInstallInfo.js';
|
|
6
|
+
import { installedVersion, npmFailure, runNpm, targetsFor } from './npm.js';
|
|
7
|
+
import { restartMode, scheduleRestart } from './restart.js';
|
|
8
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
9
|
+
import markHandled from '../notifications/markHandled.js';
|
|
10
|
+
|
|
11
|
+
let updating = false;
|
|
12
|
+
let restarting = false;
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
For a site that does not run from a Docker volume (see prepareUpdate for one that does): installs in place.
|
|
16
|
+
|
|
17
|
+
Installs a newer version of kempo and/or of installed extensions (`version` null leaves kempo as it is, `extensions` are
|
|
18
|
+
names), then restarts the server so that it runs them, and at that start each extension is brought up to date
|
|
19
|
+
(reconcileExtensions). Without `version` it is the newest kempo. A newer kempo can need newer kempo-server, kempo-ui or kempo-css than the site has, so any peer
|
|
20
|
+
that no longer meets the new version's minimum is updated in the same install. Older versions are refused.
|
|
21
|
+
|
|
22
|
+
Returns once the install has finished and the restart is scheduled, a second later, so the caller can answer
|
|
23
|
+
the request first. The rest are for tests.
|
|
24
|
+
*/
|
|
25
|
+
export default async ({ version = 'latest', extensions = [], cwd = process.cwd(), install = getInstallInfo({ cwd }), run = runNpm, restart = scheduleRestart, fetcher, executor } = {}) => {
|
|
26
|
+
const { canUpdate, reason } = install;
|
|
27
|
+
if(!canUpdate){
|
|
28
|
+
return [{ code: 409, msg: reason }, null];
|
|
29
|
+
}
|
|
30
|
+
if(version === null && !extensions.length){
|
|
31
|
+
return [{ code: 400, msg: 'Choose something to update' }, null];
|
|
32
|
+
}
|
|
33
|
+
if(updating){
|
|
34
|
+
return [{ code: 409, msg: 'An update is already running' }, null];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
updating = true;
|
|
38
|
+
try {
|
|
39
|
+
let manifest = null;
|
|
40
|
+
if(version !== null){
|
|
41
|
+
const [manifestError, found] = await fetchManifest({ version, fetcher });
|
|
42
|
+
if(manifestError) return [manifestError, null];
|
|
43
|
+
if(compareVersions(found.version, RUNNING_VERSION) <= 0){
|
|
44
|
+
return [{ code: 409, msg: `Version ${RUNNING_VERSION} is already running` }, null];
|
|
45
|
+
}
|
|
46
|
+
manifest = found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [targetsError, wanted] = await extensionTargets({ names: extensions, kempoVersion: manifest?.version ?? RUNNING_VERSION, root: cwd, fetcher, ...(executor ? { executor } : {}) });
|
|
50
|
+
if(targetsError) return [targetsError, null];
|
|
51
|
+
|
|
52
|
+
const targets = [...(manifest ? targetsFor({ manifest, dir: cwd }) : []), ...wanted.targets];
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await run(targets, cwd);
|
|
56
|
+
} catch(error) {
|
|
57
|
+
return [{ code: 500, msg: `The update could not be installed: ${npmFailure(error)}` }, null];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missing = [...(manifest ? [{ name: PACKAGE, to: manifest.version }] : []), ...wanted.items].find(item => installedVersion(cwd, item.name) !== item.to);
|
|
61
|
+
if(missing){
|
|
62
|
+
return [{ code: 500, msg: `npm finished but ${missing.name} ${missing.to} is not installed` }, null];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if(manifest){
|
|
66
|
+
await markHandled({ owner: PACKAGE, dedupeKey: `update:${manifest.version}` });
|
|
67
|
+
await triggerHook('kempo:updated', { oldVersion: RUNNING_VERSION, newVersion: manifest.version, packages: targets });
|
|
68
|
+
}
|
|
69
|
+
restarting = true;
|
|
70
|
+
restart();
|
|
71
|
+
|
|
72
|
+
return [null, { from: RUNNING_VERSION, to: manifest?.version ?? RUNNING_VERSION, packages: targets, restart: restartMode() }];
|
|
73
|
+
} finally {
|
|
74
|
+
updating = restarting;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createWriteStream, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
import { createGzip } from 'zlib';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
A copy of the whole database in `file`, as gzipped plain SQL that restores itself (it drops what it replaces), taken with
|
|
9
|
+
pg_dump. It is what an update that changes data is put back from, so without pg_dump (the Docker image has it) such an
|
|
10
|
+
update is refused rather than made without a way back. `spawner` is for tests.
|
|
11
|
+
*/
|
|
12
|
+
export default async ({ file, url = process.env.DATABASE_URL, spawner = spawn } = {}) => {
|
|
13
|
+
if(!file) return [{ code: 400, msg: 'A file to write the backup to is required' }, null];
|
|
14
|
+
if(!url) return [{ code: 500, msg: 'DATABASE_URL is not set, so the database cannot be backed up' }, null];
|
|
15
|
+
|
|
16
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
17
|
+
const dump = spawner('pg_dump', ['--clean', '--if-exists', '--no-owner', '--no-privileges', url], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
18
|
+
let errors = '';
|
|
19
|
+
dump.stderr?.on('data', chunk => { errors += chunk; });
|
|
20
|
+
const ended = new Promise(resolve => {
|
|
21
|
+
dump.on('error', error => resolve(error.code === 'ENOENT' ? 'missing' : error.message));
|
|
22
|
+
dump.on('exit', code => resolve(code === 0 ? null : `pg_dump ended with ${code}: ${errors.trim().split('\n').slice(-2).join(' ')}`));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await pipeline(dump.stdout, createGzip(), createWriteStream(file));
|
|
27
|
+
} catch(error) {
|
|
28
|
+
return [{ code: 500, msg: `The database backup could not be written: ${error.message}` }, null];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const problem = await ended;
|
|
32
|
+
if(problem === 'missing') return [{ code: 501, msg: 'pg_dump is not installed here, so the data cannot be backed up and this update will not be made without one' }, null];
|
|
33
|
+
if(problem) return [{ code: 500, msg: `The database could not be backed up: ${problem}` }, null];
|
|
34
|
+
return [null, { file }];
|
|
35
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { rmSync } from 'fs';
|
|
2
|
+
import { clearState, paths, readState } from './siteLayout.js';
|
|
3
|
+
|
|
4
|
+
/* Throws away a staged update that was not switched to, which frees its disk space. */
|
|
5
|
+
export default ({ siteRoot = process.env.KEMPO_SITE_ROOT } = {}) => {
|
|
6
|
+
if(!siteRoot) return [{ code: 409, msg: 'This site has no staged updates' }, null];
|
|
7
|
+
if(readState(siteRoot, 'switch')) return [{ code: 409, msg: 'A switch is under way' }, null];
|
|
8
|
+
rmSync(paths(siteRoot).staging, { recursive: true, force: true });
|
|
9
|
+
clearState(siteRoot, 'staged');
|
|
10
|
+
return [null, { discarded: true }];
|
|
11
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import db from '../../db/index.js';
|
|
4
|
+
import { extension } from '../../db/schema.js';
|
|
5
|
+
import { compareVersions, isBreakingBump, isVersion, lowerBound } from './helpers.js';
|
|
6
|
+
import fetchManifest from './fetchManifest.js';
|
|
7
|
+
import { installedVersion } from './npm.js';
|
|
8
|
+
import readKempoConfig from '../extensions/readKempoConfig.js';
|
|
9
|
+
import checkClaims from '../extensions/checkClaims.js';
|
|
10
|
+
import { normalizeAliases } from '../extensions/aliases.js';
|
|
11
|
+
import { normalizeScopes, scopeNames } from '../extensions/scopes.js';
|
|
12
|
+
import { NAME_PATTERN } from '../extensions/marketplaceClient.js';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Checks a set of extension updates against npm and the site, and says what to install: for each name, that it is installed,
|
|
16
|
+
that npm has a newer version, and that the new version does not need a newer kempo than the one that will be running
|
|
17
|
+
(`kempoVersion`: the one being installed alongside, or the one running). Nothing is installed here.
|
|
18
|
+
|
|
19
|
+
Resolves to [error, { targets, items }] where `targets` are `name@version` for npm and each item is
|
|
20
|
+
{ name, row, from, to, major }. The first problem is the error, with the extension's name in it.
|
|
21
|
+
*/
|
|
22
|
+
export default async ({ names, kempoVersion, executor = db, root = process.cwd(), fetcher }) => {
|
|
23
|
+
const items = [];
|
|
24
|
+
const targets = [];
|
|
25
|
+
|
|
26
|
+
for(const name of [...new Set(names)]){
|
|
27
|
+
if(typeof name !== 'string' || !NAME_PATTERN.test(name)){
|
|
28
|
+
return [{ code: 400, msg: `"${name}" is not a valid extension name` }, null];
|
|
29
|
+
}
|
|
30
|
+
const [row] = await executor.select().from(extension).where(eq(extension.name, name)).limit(1);
|
|
31
|
+
if(!row){
|
|
32
|
+
return [{ code: 404, msg: `${name} is not installed` }, null];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [error, manifest] = await fetchManifest({ name, fetcher });
|
|
36
|
+
if(error) return [{ ...error, msg: `${name}: ${error.msg}` }, null];
|
|
37
|
+
|
|
38
|
+
const from = installedVersion(root, name) ?? row.version;
|
|
39
|
+
if(!isVersion(from) || compareVersions(manifest.version, from) <= 0){
|
|
40
|
+
return [{ code: 409, msg: `${name} is already up to date` }, null];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const needs = manifest.peerDependencies.kempo ? lowerBound(manifest.peerDependencies.kempo) : null;
|
|
44
|
+
if(needs && compareVersions(needs, kempoVersion) > 0){
|
|
45
|
+
return [{ code: 409, msg: `${name} ${manifest.version} needs Kempo ${needs} or newer: update Kempo first, or at the same time` }, null];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
items.push({ name, row, from, to: manifest.version, major: isBreakingBump(from, manifest.version) });
|
|
49
|
+
targets.push(`${name}@${manifest.version}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [null, { targets, items }];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const countNew = (next = [], old = [], key = item => item.name) => {
|
|
56
|
+
const had = new Set(old.map(key));
|
|
57
|
+
return next.filter(item => !had.has(key(item))).length;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
What each updated extension would change, read from the staged copy of its package against what was recorded when it was
|
|
62
|
+
installed: how many permissions, settings, groups, dashboard widgets and scheduled jobs it adds, whether it is breaking (a
|
|
63
|
+
new major version, a new minor one while the major is 0, or the new version says `"breaking": true` in its kempo-config.json), and whether the addresses it
|
|
64
|
+
claims collide with another extension's, which stops the update. Resolves to [error, [entry]].
|
|
65
|
+
*/
|
|
66
|
+
export const extensionPlan = async ({ dir, items, executor }) => {
|
|
67
|
+
const plan = [];
|
|
68
|
+
for(const item of items){
|
|
69
|
+
let next;
|
|
70
|
+
try {
|
|
71
|
+
({ kempo: next } = await readKempoConfig(join(dir, 'node_modules', item.name)));
|
|
72
|
+
} catch {
|
|
73
|
+
return [{ code: 500, msg: `${item.name} ${item.to} was not found in the staged copy` }, null];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [scopesError] = normalizeScopes(next);
|
|
77
|
+
const [aliasesError] = scopesError ? [null] : normalizeAliases(next.aliases, scopeNames(next));
|
|
78
|
+
const [claimsError] = scopesError || aliasesError || !item.row.enabled
|
|
79
|
+
? [null]
|
|
80
|
+
: await checkClaims({ names: [item.name], verb: 'update', kempo: { [item.name]: next }, ...(executor ? { executor } : {}) });
|
|
81
|
+
const refusal = scopesError || aliasesError || claimsError;
|
|
82
|
+
if(refusal) return [{ code: 409, msg: `${item.name} ${item.to} cannot be installed: ${refusal.msg}` }, null];
|
|
83
|
+
|
|
84
|
+
const old = item.row.kempo || {};
|
|
85
|
+
plan.push({
|
|
86
|
+
name: item.name,
|
|
87
|
+
from: item.from,
|
|
88
|
+
to: item.to,
|
|
89
|
+
major: item.major,
|
|
90
|
+
breaking: item.major || next.breaking === true,
|
|
91
|
+
adds: {
|
|
92
|
+
permissions: countNew(next.permissions, old.permissions),
|
|
93
|
+
settings: countNew(next.settings, old.settings),
|
|
94
|
+
groups: countNew(next.groups, old.groups),
|
|
95
|
+
widgets: countNew(next.widgets, old.widgets, widget => widget.key),
|
|
96
|
+
jobs: countNew(next.jobs, old.jobs)
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return [null, plan];
|
|
101
|
+
};
|