kempo 4.8.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/bin/cli.js +23 -1
- package/dist/admin/components/Updates.js +117 -0
- package/dist/admin/components/Updates.js.map +1 -0
- package/dist/admin/nav.fragment.html +6 -0
- package/dist/admin/updates/index.page.html +14 -0
- 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/fetch.js +1 -1
- package/dist/kempo/fetch.js.map +1 -1
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +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,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
|
+
};
|
|
@@ -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(' ');
|