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,26 @@
|
|
|
1
|
+
import { DEFAULT_REGISTRY, PACKAGE, isVersion } from './helpers.js';
|
|
2
|
+
import { NAME_PATTERN } from '../extensions/marketplaceClient.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Reads one version's manifest of a package (kempo by default, or an extension) from the npm registry, "latest" by default.
|
|
6
|
+
Resolves to [error, { name, version, peerDependencies }].
|
|
7
|
+
*/
|
|
8
|
+
export default async ({ name = PACKAGE, version = 'latest', registry = process.env.KEMPO_NPM_REGISTRY || DEFAULT_REGISTRY, fetcher = fetch } = {}) => {
|
|
9
|
+
if(typeof name !== 'string' || !NAME_PATTERN.test(name)){
|
|
10
|
+
return [{ code: 400, msg: 'Package name is not valid' }, null];
|
|
11
|
+
}
|
|
12
|
+
if(version !== 'latest' && !isVersion(version)){
|
|
13
|
+
return [{ code: 400, msg: 'Version is not valid' }, null];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetcher(`${registry.replace(/\/+$/, '')}/${name.replace('/', '%2F')}/${version}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
|
|
18
|
+
if(res.status === 404) return [{ code: 404, msg: `${name}${version === 'latest' ? '' : ` ${version}`} was not found on npm` }, null];
|
|
19
|
+
if(!res.ok) return [{ code: 502, msg: `npm answered ${res.status}` }, null];
|
|
20
|
+
const manifest = await res.json();
|
|
21
|
+
if(!isVersion(manifest?.version)) return [{ code: 502, msg: 'npm did not return a usable version' }, null];
|
|
22
|
+
return [null, { name, version: manifest.version, peerDependencies: manifest.peerDependencies ?? {} }];
|
|
23
|
+
} catch {
|
|
24
|
+
return [{ code: 502, msg: 'npm could not be reached' }, null];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import db from '../../db/index.js';
|
|
3
|
+
import { extension } from '../../db/schema.js';
|
|
4
|
+
import { compareVersions, isBreakingBump, isVersion, lowerBound } from './helpers.js';
|
|
5
|
+
import fetchManifest from './fetchManifest.js';
|
|
6
|
+
import readKempoConfig from '../extensions/readKempoConfig.js';
|
|
7
|
+
|
|
8
|
+
/* The last answer npm gave for the installed extensions, kept for the process like the one for kempo itself. */
|
|
9
|
+
let known = null;
|
|
10
|
+
let checkError = null;
|
|
11
|
+
|
|
12
|
+
export const resetExtensionUpdates = () => {
|
|
13
|
+
known = null;
|
|
14
|
+
checkError = null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CONCURRENCY = 4;
|
|
18
|
+
|
|
19
|
+
const installedVersionOf = async (root, row) => {
|
|
20
|
+
try {
|
|
21
|
+
return (await readKempoConfig(join(root, 'node_modules', row.name))).pkg.version;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
Asks npm for the newest version of every installed extension, four at a time, and lists those that are newer than what is
|
|
29
|
+
on disk: [{ name, installedVersion, latestVersion, major, kempoRequired }]. `kempoRequired` is the lowest kempo the new
|
|
30
|
+
version asks for, which can be newer than the one running (then it can only be updated together with kempo).
|
|
31
|
+
An extension that is not on npm (a git-only one) or whose answer was an error is left out; if every answer was an
|
|
32
|
+
error, `checkError` says npm could not be reached. Asks when `force` is set or the last answer is older than `maxAgeMs`;
|
|
33
|
+
`offline` never asks.
|
|
34
|
+
*/
|
|
35
|
+
export default async ({ force = false, offline = false, maxAgeMs = 3600000, fetcher, executor = db, root = process.cwd() } = {}) => {
|
|
36
|
+
if(!offline && (force || !known || Date.now() - known.checkedAt > maxAgeMs)){
|
|
37
|
+
const rows = await executor.select().from(extension);
|
|
38
|
+
const updates = [];
|
|
39
|
+
let failures = 0;
|
|
40
|
+
let next = 0;
|
|
41
|
+
|
|
42
|
+
const work = async () => {
|
|
43
|
+
while(next < rows.length){
|
|
44
|
+
const row = rows[next++];
|
|
45
|
+
const installedVersion = await installedVersionOf(root, row);
|
|
46
|
+
if(!isVersion(installedVersion)) continue;
|
|
47
|
+
const [error, manifest] = await fetchManifest({ name: row.name, fetcher });
|
|
48
|
+
if(error){
|
|
49
|
+
if(error.code !== 404) failures++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if(compareVersions(manifest.version, installedVersion) > 0){
|
|
53
|
+
updates.push({
|
|
54
|
+
name: row.name,
|
|
55
|
+
installedVersion,
|
|
56
|
+
latestVersion: manifest.version,
|
|
57
|
+
major: isBreakingBump(installedVersion, manifest.version),
|
|
58
|
+
kempoRequired: manifest.peerDependencies.kempo ? lowerBound(manifest.peerDependencies.kempo) : null
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, work));
|
|
64
|
+
|
|
65
|
+
checkError = rows.length && failures === rows.length ? 'npm could not be reached' : null;
|
|
66
|
+
if(!checkError) known = { checkedAt: Date.now(), updates: updates.sort((a, b) => a.name.localeCompare(b.name)) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [null, { updates: known?.updates ?? [], checkedAt: known ? new Date(known.checkedAt).toISOString() : null, checkError }];
|
|
70
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, sep } from 'path';
|
|
3
|
+
import { PACKAGE } from './helpers.js';
|
|
4
|
+
import { PACKAGE_ROOT } from './runtime.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Whether this site can update kempo by installing a new version of it. A checkout that is linked or run
|
|
8
|
+
from source (this repository, or a site using `npm run link:local`) is refused: `npm install` would
|
|
9
|
+
replace the link with the registry copy and throw the local work away.
|
|
10
|
+
*/
|
|
11
|
+
export default ({ cwd = process.cwd(), packageRoot = PACKAGE_ROOT } = {}) => {
|
|
12
|
+
if(!packageRoot.startsWith(join(cwd, 'node_modules') + sep)){
|
|
13
|
+
return { canUpdate: false, reason: 'Kempo is linked from a local checkout, not installed from npm.' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const spec = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).dependencies?.[PACKAGE];
|
|
18
|
+
if(!spec) return { canUpdate: false, reason: "Kempo is not a dependency in this site's package.json." };
|
|
19
|
+
if(/^(file|link|git|github|https?):/.test(spec)) return { canUpdate: false, reason: `This site's package.json points kempo at "${spec}", not at an npm version.` };
|
|
20
|
+
} catch {
|
|
21
|
+
return { canUpdate: false, reason: "This site's package.json could not be read." };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { canUpdate: true, reason: null };
|
|
25
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PACKAGE, compareVersions, isBreakingBump } from './helpers.js';
|
|
2
|
+
import { RUNNING_VERSION, BOOT_ID } from './runtime.js';
|
|
3
|
+
import fetchManifest from './fetchManifest.js';
|
|
4
|
+
import getInstallInfo from './getInstallInfo.js';
|
|
5
|
+
import { restartMode } from './restart.js';
|
|
6
|
+
import releaseStatus from './releaseStatus.js';
|
|
7
|
+
import getExtensionUpdates, { resetExtensionUpdates } from './getExtensionUpdates.js';
|
|
8
|
+
import { getReconcileResult } from '../extensions/reconcileExtensions.js';
|
|
9
|
+
import { getSchemaSyncResult } from '../setup/syncCoreSchema.js';
|
|
10
|
+
|
|
11
|
+
/* The last answer npm gave, kept for the process so that opening the screen does not ask npm every time. */
|
|
12
|
+
let known = null;
|
|
13
|
+
let checkError = null;
|
|
14
|
+
|
|
15
|
+
export const resetUpdateStatus = () => {
|
|
16
|
+
resetExtensionUpdates();
|
|
17
|
+
known = null;
|
|
18
|
+
checkError = null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
Where this site stands: what is running, the newest version on npm and whether it can be installed here.
|
|
23
|
+
Asks npm when `force` is set or the last answer is older than `maxAgeMs`; a failed ask keeps the last
|
|
24
|
+
good answer and reports why in `checkError`. `offline` never asks, for a caller that must answer at once.
|
|
25
|
+
*/
|
|
26
|
+
export default async ({ force = false, offline = false, maxAgeMs = 3600000, fetcher, install = getInstallInfo(), executor } = {}) => {
|
|
27
|
+
if(!offline && (force || !known || Date.now() - known.checkedAt > maxAgeMs)){
|
|
28
|
+
const [error, manifest] = await fetchManifest({ fetcher });
|
|
29
|
+
if(error){
|
|
30
|
+
checkError = error.msg;
|
|
31
|
+
} else {
|
|
32
|
+
checkError = null;
|
|
33
|
+
known = { latestVersion: manifest.version, checkedAt: Date.now() };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [, extensionUpdates] = await getExtensionUpdates({ force, offline, maxAgeMs, fetcher, ...(executor ? { executor } : {}) });
|
|
38
|
+
const latestVersion = known?.latestVersion ?? null;
|
|
39
|
+
const updateAvailable = latestVersion !== null && compareVersions(latestVersion, RUNNING_VERSION) > 0;
|
|
40
|
+
|
|
41
|
+
return [null, {
|
|
42
|
+
runningVersion: RUNNING_VERSION,
|
|
43
|
+
bootId: BOOT_ID,
|
|
44
|
+
latestVersion,
|
|
45
|
+
updateAvailable,
|
|
46
|
+
major: updateAvailable && isBreakingBump(RUNNING_VERSION, latestVersion),
|
|
47
|
+
checkedAt: known ? new Date(known.checkedAt).toISOString() : null,
|
|
48
|
+
checkError,
|
|
49
|
+
restart: restartMode(),
|
|
50
|
+
database: getSchemaSyncResult(),
|
|
51
|
+
extensionUpdates,
|
|
52
|
+
extensionsRun: getReconcileResult(),
|
|
53
|
+
...releaseStatus(),
|
|
54
|
+
npmUrl: `https://www.npmjs.com/package/${PACKAGE}`,
|
|
55
|
+
...install
|
|
56
|
+
}];
|
|
57
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const PACKAGE = 'kempo';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
4
|
+
|
|
5
|
+
/* Only plain releases are ever installed by an update: no tags, ranges or prereleases, so nothing here can reach a shell as anything else. */
|
|
6
|
+
export const isVersion = value => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
|
|
7
|
+
|
|
8
|
+
/* -1, 0 or 1 for a compared with b, both plain versions. */
|
|
9
|
+
export const compareVersions = (a, b) => {
|
|
10
|
+
const left = a.split('.').map(Number);
|
|
11
|
+
const right = b.split('.').map(Number);
|
|
12
|
+
for(let i = 0; i < 3; i++){
|
|
13
|
+
if(left[i] !== right[i]) return left[i] > right[i] ? 1 : -1;
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
Whether going from one plain version to a higher one may break what depended on it, by semver: a new major version, and
|
|
20
|
+
while the major is still 0 a new minor version too (0.1.x to 0.2.0 is the breaking step there, which is what npm's own ^0.1.1
|
|
21
|
+
assumes). Every extension kempo has is still 0.x, so this is what makes its updates warn.
|
|
22
|
+
*/
|
|
23
|
+
export const isBreakingBump = (from, to) => {
|
|
24
|
+
const [fromMajor, fromMinor] = from.split('.').map(Number);
|
|
25
|
+
const [toMajor, toMinor] = to.split('.').map(Number);
|
|
26
|
+
return fromMajor === 0 ? (toMajor > 0 || toMinor > fromMinor) : toMajor > fromMajor;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* The version a peer range such as ">=2.2.5" or "^3.4.0" starts at, or null for one that names none ("*"). */
|
|
30
|
+
export const lowerBound = range => String(range).match(/\d+\.\d+\.\d+/)?.[0] ?? null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { and, eq } from 'drizzle-orm';
|
|
3
|
+
import db from '../../db/index.js';
|
|
4
|
+
import { notification } from '../../db/schema.js';
|
|
5
|
+
import createNotification from '../notifications/createNotification.js';
|
|
6
|
+
import { getSchemaSyncResult } from '../setup/syncCoreSchema.js';
|
|
7
|
+
import { getReconcileResult } from '../extensions/reconcileExtensions.js';
|
|
8
|
+
import { PACKAGE } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
After the server starts: when syncing kempo's tables left a change that the database refused, or an extension could not be
|
|
12
|
+
brought up to date, tell everyone who can update kempo, once for each distinct set of problems.
|
|
13
|
+
Raising the same notification at every start would mark it unread for everyone each time.
|
|
14
|
+
*/
|
|
15
|
+
export default async ({ result = getSchemaSyncResult(), extensions = getReconcileResult() } = {}) => {
|
|
16
|
+
const problems = [
|
|
17
|
+
...result.failed.map(({ change, reason }) => `${change} (${reason})`),
|
|
18
|
+
...extensions.failed.map(({ name, to, reason }) => `${name} ${to} could not be brought up to date (${reason})`)
|
|
19
|
+
];
|
|
20
|
+
if(!problems.length) return [null];
|
|
21
|
+
|
|
22
|
+
const dedupeKey = `schema:${createHash('sha256').update(problems.join('\n')).digest('hex').slice(0, 16)}`;
|
|
23
|
+
const [existing] = await db.select({ id: notification.id }).from(notification).where(and(eq(notification.owner, PACKAGE), eq(notification.dedupeKey, dedupeKey))).limit(1);
|
|
24
|
+
if(existing) return [null];
|
|
25
|
+
|
|
26
|
+
return createNotification({
|
|
27
|
+
owner: PACKAGE,
|
|
28
|
+
title: 'An update needs attention',
|
|
29
|
+
message: `Kempo started, but ${problems.length === 1 ? 'one thing was' : `${problems.length} things were`} not applied: ${problems.slice(0, 3).join('; ')}${problems.length > 3 ? '; and more' : ''}.`,
|
|
30
|
+
level: 'error',
|
|
31
|
+
permission: 'system:updates:manage',
|
|
32
|
+
dedupeKey,
|
|
33
|
+
actions: [{ label: 'View details', href: '/admin/updates' }]
|
|
34
|
+
});
|
|
35
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { PACKAGE, compareVersions, lowerBound } from './helpers.js';
|
|
6
|
+
import { NAME_PATTERN } from '../extensions/marketplaceClient.js';
|
|
7
|
+
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/* `npm install` of already validated targets (plain names and x.y.z or "latest") in `cwd`. */
|
|
11
|
+
export const runNpm = (args, cwd) => execute('npm', ['install', ...args, '--no-audit', '--no-fund'], {
|
|
12
|
+
cwd,
|
|
13
|
+
shell: process.platform === 'win32',
|
|
14
|
+
timeout: 10 * 60 * 1000,
|
|
15
|
+
maxBuffer: 10 * 1024 * 1024
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const installedVersion = (dir, name) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(join(dir, 'node_modules', name, 'package.json'), 'utf8')).version;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
What to install for a manifest of kempo: kempo itself, and any peer (kempo-server, kempo-ui, kempo-css) that is installed
|
|
28
|
+
in `dir` at a version below what the new kempo asks for at least. A peer that already meets it is left alone.
|
|
29
|
+
*/
|
|
30
|
+
export const targetsFor = ({ manifest, dir }) => {
|
|
31
|
+
const targets = [`${PACKAGE}@${manifest.version}`];
|
|
32
|
+
for(const [name, range] of Object.entries(manifest.peerDependencies)){
|
|
33
|
+
const bound = lowerBound(range);
|
|
34
|
+
const installed = installedVersion(dir, name);
|
|
35
|
+
if(NAME_PATTERN.test(name) && bound && installed && compareVersions(installed, bound) < 0) targets.push(`${name}@latest`);
|
|
36
|
+
}
|
|
37
|
+
return targets;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/* The last lines of what npm said when it failed. */
|
|
41
|
+
export const npmFailure = error => String(error.stderr || error.message || '').trim().split('\n').filter(Boolean).slice(-3).join(' ');
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { rmSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { compareVersions } from './helpers.js';
|
|
4
|
+
import { PACKAGE_ROOT, RUNNING_VERSION } from './runtime.js';
|
|
5
|
+
import extensionTargets, { extensionPlan } from './extensionTargets.js';
|
|
6
|
+
import fetchManifest from './fetchManifest.js';
|
|
7
|
+
import getInstallInfo from './getInstallInfo.js';
|
|
8
|
+
import { installedVersion, npmFailure, runNpm, targetsFor } from './npm.js';
|
|
9
|
+
import { paths, stage, writeState } from './siteLayout.js';
|
|
10
|
+
import { forcedFiles, planStarterFiles } from './starterFiles.js';
|
|
11
|
+
import { pendingUpgrades } from '../setup/runUpgrades.js';
|
|
12
|
+
|
|
13
|
+
let preparing = false;
|
|
14
|
+
|
|
15
|
+
const NO_FILES = { add: [], update: [], current: [], edited: [], deleted: [], forced: [] };
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
What switching to a staged version would change besides the code, worked out from the staged copy: for kempo, the starter
|
|
19
|
+
files it would replace, add, keep (edited) or need confirmation for, and the data migrations it carries; for each updated
|
|
20
|
+
extension, what it adds and whether it is breaking. `breaking` is true when anything in it can only be undone from a backup.
|
|
21
|
+
*/
|
|
22
|
+
export const buildPlan = async ({ cwd, oldRoot, newRoot, extensions = [], executor }) => {
|
|
23
|
+
const files = newRoot ? planStarterFiles({ cwd, oldRoot, newRoot, forced: forcedFiles(newRoot) }) : NO_FILES;
|
|
24
|
+
const upgrades = newRoot ? await pendingUpgrades({ dir: join(newRoot, 'server', 'db', 'upgrades'), ...(executor ? { executor } : {}) }) : [];
|
|
25
|
+
return { files, upgrades, extensions, breaking: upgrades.some(upgrade => upgrade.breaking) || extensions.some(extension => extension.breaking) };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Installs newer versions of kempo and/or of installed extensions into a staging folder next to the running site, which keeps
|
|
30
|
+
running from its own files, and works out what switching to them would change. Nothing the site uses is touched, so it
|
|
31
|
+
can fail, or be abandoned, without a trace. Applying it is applyStagedUpdate. Only for a site that runs from a Docker
|
|
32
|
+
volume (KEMPO_SITE_ROOT); any other site is updated in place, by applyUpdate.
|
|
33
|
+
|
|
34
|
+
`version` is the kempo version, "latest" by default, or null to leave kempo as it is; `extensions` are names. All are
|
|
35
|
+
installed in one npm run so they are consistent with each other. Resolves to [error, { version, packages, plan }]. `run`,
|
|
36
|
+
`install`, `oldRoot`, `fetcher` and `executor` are for tests.
|
|
37
|
+
*/
|
|
38
|
+
export default async ({ version = 'latest', extensions = [], siteRoot = process.env.KEMPO_SITE_ROOT, cwd = process.cwd(), oldRoot = PACKAGE_ROOT, install = getInstallInfo({ cwd }), run = runNpm, fetcher, executor } = {}) => {
|
|
39
|
+
if(!siteRoot){
|
|
40
|
+
return [{ code: 409, msg: 'This site is not set up to prepare an update ahead of switching to it' }, null];
|
|
41
|
+
}
|
|
42
|
+
if(!install.canUpdate){
|
|
43
|
+
return [{ code: 409, msg: install.reason }, null];
|
|
44
|
+
}
|
|
45
|
+
if(version === null && !extensions.length){
|
|
46
|
+
return [{ code: 400, msg: 'Choose something to update' }, null];
|
|
47
|
+
}
|
|
48
|
+
if(preparing){
|
|
49
|
+
return [{ code: 409, msg: 'An update is already being prepared' }, null];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
preparing = true;
|
|
53
|
+
try {
|
|
54
|
+
let manifest = null;
|
|
55
|
+
if(version !== null){
|
|
56
|
+
const [manifestError, found] = await fetchManifest({ version, fetcher });
|
|
57
|
+
if(manifestError) return [manifestError, null];
|
|
58
|
+
if(compareVersions(found.version, RUNNING_VERSION) <= 0){
|
|
59
|
+
return [{ code: 409, msg: `Version ${RUNNING_VERSION} is already running` }, null];
|
|
60
|
+
}
|
|
61
|
+
manifest = found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const kempoVersion = manifest?.version ?? RUNNING_VERSION;
|
|
65
|
+
const [targetsError, wanted] = await extensionTargets({ names: extensions, kempoVersion, root: cwd, fetcher, ...(executor ? { executor } : {}) });
|
|
66
|
+
if(targetsError) return [targetsError, null];
|
|
67
|
+
|
|
68
|
+
let staged;
|
|
69
|
+
try {
|
|
70
|
+
staged = stage({ siteRoot, version: kempoVersion });
|
|
71
|
+
} catch(error) {
|
|
72
|
+
return [{ code: 500, msg: `Space for the new version could not be made: ${error.message}` }, null];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const discard = () => rmSync(paths(siteRoot).staging, { recursive: true, force: true });
|
|
76
|
+
const targets = [...(manifest ? targetsFor({ manifest, dir: staged.dir }) : []), ...wanted.targets];
|
|
77
|
+
try {
|
|
78
|
+
await run(targets, staged.dir);
|
|
79
|
+
} catch(error) {
|
|
80
|
+
discard();
|
|
81
|
+
return [{ code: 500, msg: `The update could not be downloaded: ${npmFailure(error)}` }, null];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const missing = [...(manifest ? [{ name: 'kempo', to: manifest.version }] : []), ...wanted.items].find(item => installedVersion(staged.dir, item.name) !== item.to);
|
|
85
|
+
if(missing){
|
|
86
|
+
discard();
|
|
87
|
+
return [{ code: 500, msg: `npm finished but ${missing.name} ${missing.to} is not in the staged copy` }, null];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const [planError, extensionEntries] = await extensionPlan({ dir: staged.dir, items: wanted.items, executor });
|
|
91
|
+
if(planError){
|
|
92
|
+
discard();
|
|
93
|
+
return [planError, null];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let plan;
|
|
97
|
+
try {
|
|
98
|
+
plan = await buildPlan({ cwd, oldRoot, newRoot: manifest ? join(staged.dir, 'node_modules', 'kempo') : null, extensions: extensionEntries, executor });
|
|
99
|
+
} catch(error) {
|
|
100
|
+
discard();
|
|
101
|
+
return [{ code: 500, msg: `What this update would change could not be worked out: ${error.message}` }, null];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
writeState(siteRoot, 'staged', { id: staged.id, dir: staged.dir, version: kempoVersion, kempoUpdate: Boolean(manifest), from: RUNNING_VERSION, packages: targets, plan, preparedAt: new Date().toISOString() });
|
|
105
|
+
return [null, { version: kempoVersion, kempoUpdate: Boolean(manifest), packages: targets, plan }];
|
|
106
|
+
} finally {
|
|
107
|
+
preparing = false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { getCurrent, paths, readState } from './siteLayout.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The part of the update status that only a site running from a Docker volume has: whether a version is staged and what
|
|
7
|
+
switching to it would change, whether there is an earlier version to go back to, and how the last switch ended.
|
|
8
|
+
*/
|
|
9
|
+
export default (siteRoot = process.env.KEMPO_SITE_ROOT) => {
|
|
10
|
+
if(!siteRoot) return { mode: 'in-place' };
|
|
11
|
+
|
|
12
|
+
const staged = readState(siteRoot, 'staged');
|
|
13
|
+
const { previous } = getCurrent(siteRoot);
|
|
14
|
+
const last = readState(siteRoot, 'last');
|
|
15
|
+
return {
|
|
16
|
+
mode: 'release',
|
|
17
|
+
staged: staged && existsSync(join(staged.dir, 'node_modules')) ? { version: staged.version, kempoUpdate: Boolean(staged.kempoUpdate), packages: staged.packages ?? [], plan: staged.plan, preparedAt: staged.preparedAt } : null,
|
|
18
|
+
canGoBack: Boolean(previous?.id && existsSync(join(paths(siteRoot).releases, previous.id, 'node_modules'))),
|
|
19
|
+
previousVersion: previous?.version ?? null,
|
|
20
|
+
switching: readState(siteRoot, 'switch')?.status ?? null,
|
|
21
|
+
lastUpdate: last ? { ok: last.ok, from: last.from, to: last.to, packages: last.packages ?? [], reason: last.reason ?? null, breaking: Boolean(last.breaking), at: last.at } : null
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
How the server comes back after an update.
|
|
6
|
+
|
|
7
|
+
respawn this process starts its own replacement and exits. For `npm start` or `npm run dev` run by hand.
|
|
8
|
+
exit this process only exits and whatever started it starts it again (pm2, systemd, a container's
|
|
9
|
+
restart policy). Respawning there would fight the supervisor for the port, and a container
|
|
10
|
+
stops when its main process does.
|
|
11
|
+
|
|
12
|
+
Chosen by the KEMPO_RESTART environment variable ("respawn" or "exit"), otherwise "exit" when pm2, systemd,
|
|
13
|
+
Kubernetes or Docker is detected and "respawn" when none is.
|
|
14
|
+
*/
|
|
15
|
+
export const restartMode = (env = process.env, inContainer = () => existsSync('/.dockerenv')) => {
|
|
16
|
+
if(env.KEMPO_RESTART === 'exit' || env.KEMPO_RESTART === 'respawn') return env.KEMPO_RESTART;
|
|
17
|
+
return env.pm_id !== undefined || env.INVOCATION_ID || env.KUBERNETES_SERVICE_HOST || inContainer() ? 'exit' : 'respawn';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
The replacement cannot be started by the process it replaces: the port is still taken. A small detached
|
|
22
|
+
watcher waits for that process to be gone and then starts the same command in the same directory with
|
|
23
|
+
the same environment. It gives up waiting after a minute and starts anyway.
|
|
24
|
+
*/
|
|
25
|
+
const WATCHER = `
|
|
26
|
+
const { spawn } = require('child_process');
|
|
27
|
+
const pid = Number(process.argv[1]);
|
|
28
|
+
const command = JSON.parse(process.argv[2]);
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const alive = () => {
|
|
31
|
+
try { process.kill(pid, 0); return true; } catch(error) { return error.code === 'EPERM'; }
|
|
32
|
+
};
|
|
33
|
+
const wait = () => {
|
|
34
|
+
if(alive() && Date.now() - started < 60000) return setTimeout(wait, 200);
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
spawn(command[0], command.slice(1), { detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env, windowsHide: true }).unref();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}, 500);
|
|
39
|
+
};
|
|
40
|
+
wait();
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
Under a supervisor the exit code is nonzero on purpose (75, "try again later"): a Docker `restart: on-failure` or a
|
|
45
|
+
systemd `Restart=on-failure` policy restarts only after a failure, while `always` and `unless-stopped` restart after any exit.
|
|
46
|
+
*/
|
|
47
|
+
export const RESTART_EXIT_CODE = 75;
|
|
48
|
+
|
|
49
|
+
export const restartServer = ({ mode = restartMode(), exit = code => process.exit(code), spawner = spawn } = {}) => {
|
|
50
|
+
if(mode === 'respawn'){
|
|
51
|
+
const command = [process.execPath, ...process.execArgv, ...process.argv.slice(1)];
|
|
52
|
+
spawner(process.execPath, ['-e', WATCHER, String(process.pid), JSON.stringify(command)], {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: 'ignore',
|
|
55
|
+
cwd: process.cwd(),
|
|
56
|
+
env: process.env,
|
|
57
|
+
windowsHide: true
|
|
58
|
+
}).unref();
|
|
59
|
+
}
|
|
60
|
+
exit(mode === 'respawn' ? 0 : RESTART_EXIT_CODE);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* Restarts after `delayMs`, so the response that announced it has been sent first. */
|
|
64
|
+
export const scheduleRestart = ({ delayMs = 1000, ...options } = {}) => setTimeout(() => restartServer(options), delayMs);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { RUNNING_VERSION } from './runtime.js';
|
|
4
|
+
import { restartMode, scheduleRestart } from './restart.js';
|
|
5
|
+
import { getCurrent, paths, readState, requestSwitch } from './siteLayout.js';
|
|
6
|
+
|
|
7
|
+
let going = false;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Puts a site that runs from a Docker volume back on the version it had before the last update: the code, and the
|
|
11
|
+
starter files the update replaced. If that update changed data, going back also restores the database from the backup
|
|
12
|
+
taken before it, which loses whatever was entered since, so that needs `confirm.data`. Otherwise (the usual case: an
|
|
13
|
+
update only added to the database) the earlier version runs on the database as it is and nothing is lost.
|
|
14
|
+
|
|
15
|
+
Resolves to [error, { from, to, restart, restoresData }].
|
|
16
|
+
*/
|
|
17
|
+
export default async ({ confirm = {}, siteRoot = process.env.KEMPO_SITE_ROOT, restart = scheduleRestart } = {}) => {
|
|
18
|
+
if(!siteRoot){
|
|
19
|
+
return [{ code: 409, msg: 'This site is not set up to go back to an earlier version' }, null];
|
|
20
|
+
}
|
|
21
|
+
if(going){
|
|
22
|
+
return [{ code: 409, msg: 'A switch is already under way' }, null];
|
|
23
|
+
}
|
|
24
|
+
if(readState(siteRoot, 'switch')){
|
|
25
|
+
return [{ code: 409, msg: 'The last switch has not finished proving itself yet' }, null];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { previous, version } = getCurrent(siteRoot);
|
|
29
|
+
const source = previous?.id ? join(paths(siteRoot).releases, previous.id) : null;
|
|
30
|
+
if(!source || !existsSync(join(source, 'node_modules'))){
|
|
31
|
+
return [{ code: 409, msg: 'There is no earlier version to go back to' }, null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const last = readState(siteRoot, 'last');
|
|
35
|
+
const restoresData = Boolean(last?.ok && last.breaking && last.to === version && last.dbBackup && existsSync(last.dbBackup));
|
|
36
|
+
if(restoresData && !confirm.data){
|
|
37
|
+
return [{ code: 409, msg: 'Going back restores the database from before the update and loses what was entered since', details: { confirm: 'data' } }, null];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
going = true;
|
|
41
|
+
try {
|
|
42
|
+
requestSwitch({
|
|
43
|
+
siteRoot,
|
|
44
|
+
id: previous.id,
|
|
45
|
+
version: previous.version,
|
|
46
|
+
source,
|
|
47
|
+
kind: 'rollback',
|
|
48
|
+
filesBackup: last?.ok && last.to === version ? last.filesBackup : null,
|
|
49
|
+
dbBackup: restoresData ? last.dbBackup : null
|
|
50
|
+
});
|
|
51
|
+
restart();
|
|
52
|
+
return [null, { from: RUNNING_VERSION, to: previous.version, restart: restartMode(), restoresData }];
|
|
53
|
+
} finally {
|
|
54
|
+
going = false;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { and, eq } from 'drizzle-orm';
|
|
3
|
+
import db from '../../db/index.js';
|
|
4
|
+
import { notification } from '../../db/schema.js';
|
|
5
|
+
import getSetting from '../settings/getSetting.js';
|
|
6
|
+
import createNotification from '../notifications/createNotification.js';
|
|
7
|
+
import getUpdateStatus from './getUpdateStatus.js';
|
|
8
|
+
import { PACKAGE } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
Raising a notification again would mark it unread for everyone, so each one has a key that names what it is about, and
|
|
12
|
+
one that already exists is left alone: one for each new version of kempo, and one for each distinct set of extension
|
|
13
|
+
updates that are waiting.
|
|
14
|
+
*/
|
|
15
|
+
const raiseOnce = async ({ dedupeKey, ...notice }) => {
|
|
16
|
+
const [existing] = await db.select({ id: notification.id }).from(notification).where(and(eq(notification.owner, PACKAGE), eq(notification.dedupeKey, dedupeKey))).limit(1);
|
|
17
|
+
if(existing) return;
|
|
18
|
+
const [error] = await createNotification({ owner: PACKAGE, permission: 'system:updates:manage', dedupeKey, actions: [{ label: 'View updates', href: '/admin/updates' }], ...notice });
|
|
19
|
+
if(error) console.error(`[kempo] Update notification: ${error.msg}`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
The scheduled check (kempo's `update-check` job). Asks npm for the newest version of kempo and of every installed extension
|
|
24
|
+
and tells everyone who can install them what is waiting, once for each new version of kempo and once for each distinct set
|
|
25
|
+
of extension updates. Nothing is said about a site that cannot install updates (a linked checkout). The
|
|
26
|
+
`system:update_check_enabled` setting turns this off.
|
|
27
|
+
*/
|
|
28
|
+
export default async ({ fetcher, install } = {}) => {
|
|
29
|
+
const [, enabled] = await getSetting('system', 'update_check_enabled', true);
|
|
30
|
+
if(enabled === false) return;
|
|
31
|
+
|
|
32
|
+
const [, status] = await getUpdateStatus({ force: true, fetcher, install });
|
|
33
|
+
if(!status?.canUpdate) return;
|
|
34
|
+
|
|
35
|
+
if(status.updateAvailable){
|
|
36
|
+
await raiseOnce({
|
|
37
|
+
dedupeKey: `update:${status.latestVersion}`,
|
|
38
|
+
title: `Kempo ${status.latestVersion} is available`,
|
|
39
|
+
message: `This site runs ${status.runningVersion}.${status.major ? ' This is a major release and may include breaking changes.' : ''}`
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { updates } = status.extensionUpdates;
|
|
44
|
+
if(updates.length){
|
|
45
|
+
const list = updates.map(update => `${update.name}@${update.latestVersion}`);
|
|
46
|
+
await raiseOnce({
|
|
47
|
+
dedupeKey: `extension-updates:${createHash('sha256').update(list.join('\n')).digest('hex').slice(0, 16)}`,
|
|
48
|
+
title: updates.length === 1 ? `${updates[0].name} ${updates[0].latestVersion} is available` : `${updates.length} extension updates are available`,
|
|
49
|
+
message: `${updates.slice(0, 5).map(update => `${update.name} ${update.installedVersion} -> ${update.latestVersion}${update.major ? ' (major)' : ''}`).join(', ')}${updates.length > 5 ? ', and more' : ''}.`
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
What this process is running, read once when it starts. After an update the files on disk are the new
|
|
8
|
+
version while this process is still the old one, so these are what tell the two apart: the running
|
|
9
|
+
version, and a boot id that is different in every process.
|
|
10
|
+
*/
|
|
11
|
+
export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
12
|
+
|
|
13
|
+
export const RUNNING_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
14
|
+
|
|
15
|
+
export const BOOT_ID = randomUUID();
|