kempo 4.5.7 → 4.6.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 +1 -1
- package/dist/admin/components/tableControls/ExtensionDetails.js +2 -0
- package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -0
- package/dist/admin/components/tableControls/InstallExtension.js.map +1 -1
- package/dist/admin/components/tableControls/ToggleExtension.js +2 -0
- package/dist/admin/components/tableControls/ToggleExtension.js.map +1 -0
- package/dist/admin/components/tableControls/UninstallExtension.js +1 -1
- package/dist/admin/components/tableControls/UninstallExtension.js.map +1 -1
- package/dist/admin/extensions/browse/index.page.html +118 -0
- package/dist/admin/extensions/details/index.page.html +254 -0
- package/dist/admin/extensions/index.page.html +8 -2
- package/dist/kempo/api/extensions/DELETE.js +1 -1
- package/dist/kempo/api/extensions/DELETE.js.map +1 -1
- package/dist/kempo/api/extensions/POST.js +1 -1
- package/dist/kempo/api/extensions/POST.js.map +1 -1
- package/dist/kempo/api/extensions/[name]/dependents/GET.js +2 -0
- package/dist/kempo/api/extensions/[name]/dependents/GET.js.map +1 -0
- package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/disable/POST.js.map +1 -1
- package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
- package/dist/kempo/api/extensions/[name]/enable/POST.js.map +1 -1
- package/dist/kempo/api/extensions/[name]/requirements/GET.js +2 -0
- package/dist/kempo/api/extensions/[name]/requirements/GET.js.map +1 -0
- package/dist/kempo/api/extensions/available/GET.js +1 -1
- package/dist/kempo/api/extensions/available/GET.js.map +1 -1
- package/dist/kempo/api/extensions/known/GET.js +1 -1
- package/dist/kempo/api/extensions/known/GET.js.map +1 -1
- package/dist/kempo/api/extensions/rating/PUT.js +2 -0
- package/dist/kempo/api/extensions/rating/PUT.js.map +1 -0
- package/dist/kempo/icons/payments.svg +1 -0
- package/dist/kempo/icons/toll.svg +1 -0
- package/dist/kempo/icons/wallet.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +9 -6
- package/package.json +1 -1
- package/server/utils/extensions/catalogClient.js +203 -0
- package/server/utils/extensions/disableExtension.js +2 -32
- package/server/utils/extensions/disableExtensionTree.js +46 -0
- package/server/utils/extensions/enableExtension.js +2 -33
- package/server/utils/extensions/enableExtensionTree.js +54 -0
- package/server/utils/extensions/getDependentTree.js +47 -0
- package/server/utils/extensions/getExtension.js +4 -3
- package/server/utils/extensions/getMarketplaceClient.js +20 -0
- package/server/utils/extensions/getRequirementTree.js +43 -0
- package/server/utils/extensions/getSiteInstallId.js +21 -0
- package/server/utils/extensions/installExtension.js +2 -6
- package/server/utils/extensions/installFromCatalog.js +127 -0
- package/server/utils/extensions/installPackage.js +36 -0
- package/server/utils/extensions/listExtensions.js +8 -7
- package/server/utils/extensions/readKempoConfig.js +20 -0
- package/server/utils/extensions/scopeCache.js +14 -5
- package/server/utils/extensions/uninstallExtension.js +2 -124
- package/server/utils/extensions/uninstallExtensionTree.js +165 -0
- package/server/utils/extensions/updateExtension.js +9 -7
- package/server/utils/hooks/triggerHook.js +8 -0
- package/dist/admin/known-extensions.json +0 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { extension } from '../../db/schema.js';
|
|
3
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
4
|
+
import { invalidateScopeCache } from './scopeCache.js';
|
|
5
|
+
import getEnabledDependentTree from './getDependentTree.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Switches an extension off. Anything enabled that requires it (directly or through another) cannot
|
|
9
|
+
keep running without it, so without `cascade` this refuses and returns that whole tree as
|
|
10
|
+
`dependents` for the caller to show. With `cascade` the extension and everything above it are
|
|
11
|
+
switched off together, in one statement, so there is no half-disabled state to recover from.
|
|
12
|
+
Enabling is never cascaded: each of them is turned back on deliberately.
|
|
13
|
+
*/
|
|
14
|
+
export default async ({ name, cascade = false }) => {
|
|
15
|
+
if(!name){
|
|
16
|
+
return [{ code: 400, msg: 'Extension name is required' }, null];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const [existing] = await db.select({ name: extension.name }).from(extension).where(eq(extension.name, name)).limit(1);
|
|
21
|
+
if(!existing){
|
|
22
|
+
return [{ code: 404, msg: 'Extension not found' }, null];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dependents = await getEnabledDependentTree(name);
|
|
26
|
+
if(dependents.length && !cascade){
|
|
27
|
+
return [{
|
|
28
|
+
code: 409,
|
|
29
|
+
msg: `${dependents.map(dependent => dependent.name).join(', ')} depends on this extension and is still enabled`,
|
|
30
|
+
dependents,
|
|
31
|
+
}, null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const names = [name, ...dependents.map(dependent => dependent.name)];
|
|
35
|
+
await db.update(extension)
|
|
36
|
+
.set({ enabled: false, updatedAt: new Date() })
|
|
37
|
+
.where(inArray(extension.name, names));
|
|
38
|
+
|
|
39
|
+
const [updated] = await db.select().from(extension).where(eq(extension.name, name)).limit(1);
|
|
40
|
+
|
|
41
|
+
invalidateScopeCache();
|
|
42
|
+
return [null, { ...updated, disabled: names }];
|
|
43
|
+
} catch(error) {
|
|
44
|
+
return [{ code: 500, msg: 'Failed to disable extension' }, null];
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -1,33 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import { eq } from 'drizzle-orm';
|
|
4
|
-
import { invalidateScopeCache } from './scopeCache.js';
|
|
5
|
-
import { getUnmetDependencies } from './dependencies.js';
|
|
6
|
-
|
|
7
|
-
export default async ({ name }) => {
|
|
8
|
-
if(!name){
|
|
9
|
-
return [{ code: 400, msg: 'Extension name is required' }, null];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
const [existing] = await db.select().from(extension).where(eq(extension.name, name)).limit(1);
|
|
14
|
-
if(!existing){
|
|
15
|
-
return [{ code: 404, msg: 'Extension not found' }, null];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const unmet = await getUnmetDependencies(existing.kempo?.dependencies || []);
|
|
19
|
-
if(unmet.length){
|
|
20
|
-
return [{ code: 409, msg: `Requires ${unmet.join(', ')} to be enabled first` }, null];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const [updated] = await db.update(extension)
|
|
24
|
-
.set({ enabled: true, updatedAt: new Date() })
|
|
25
|
-
.where(eq(extension.name, name))
|
|
26
|
-
.returning();
|
|
27
|
-
|
|
28
|
-
invalidateScopeCache();
|
|
29
|
-
return [null, updated];
|
|
30
|
-
} catch(error) {
|
|
31
|
-
return [{ code: 500, msg: 'Failed to enable extension' }, null];
|
|
32
|
-
}
|
|
33
|
-
};
|
|
1
|
+
/* The name callers import; the implementation, with its cascade over requirements, is in enableExtensionTree.js. */
|
|
2
|
+
export { default } from './enableExtensionTree.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { extension } from '../../db/schema.js';
|
|
3
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
4
|
+
import { invalidateScopeCache } from './scopeCache.js';
|
|
5
|
+
import getRequirementTree from './getRequirementTree.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Switches an extension on. It cannot run without what it requires, so any of that which is installed
|
|
9
|
+
but off has to be enabled too: without `cascade` this refuses and returns that whole chain as
|
|
10
|
+
`requirements` for the caller to show, and with `cascade` they are enabled together with it, in one
|
|
11
|
+
statement. A requirement that is not installed at all cannot be fixed by enabling, and is refused
|
|
12
|
+
by name either way.
|
|
13
|
+
|
|
14
|
+
It only ever turns on what the extension needs. Whatever else was switched off along with it, for
|
|
15
|
+
its own reasons, stays off.
|
|
16
|
+
*/
|
|
17
|
+
export default async ({ name, cascade = false }) => {
|
|
18
|
+
if(!name){
|
|
19
|
+
return [{ code: 400, msg: 'Extension name is required' }, null];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const [existing] = await db.select({ name: extension.name }).from(extension).where(eq(extension.name, name)).limit(1);
|
|
24
|
+
if(!existing){
|
|
25
|
+
return [{ code: 404, msg: 'Extension not found' }, null];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { disabled, missing } = await getRequirementTree(name);
|
|
29
|
+
|
|
30
|
+
if(missing.length){
|
|
31
|
+
return [{ code: 409, msg: `Requires ${missing.join(', ')}, which ${missing.length === 1 ? 'is' : 'are'} not installed`, missing }, null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if(disabled.length && !cascade){
|
|
35
|
+
return [{
|
|
36
|
+
code: 409,
|
|
37
|
+
msg: `Requires ${disabled.map(requirement => requirement.name).join(', ')} to be enabled first`,
|
|
38
|
+
requirements: disabled,
|
|
39
|
+
}, null];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const names = [name, ...disabled.map(requirement => requirement.name)];
|
|
43
|
+
await db.update(extension)
|
|
44
|
+
.set({ enabled: true, updatedAt: new Date() })
|
|
45
|
+
.where(inArray(extension.name, names));
|
|
46
|
+
|
|
47
|
+
const [updated] = await db.select().from(extension).where(eq(extension.name, name)).limit(1);
|
|
48
|
+
|
|
49
|
+
invalidateScopeCache();
|
|
50
|
+
return [null, { ...updated, enabled: true, enabledNames: names }];
|
|
51
|
+
} catch(error) {
|
|
52
|
+
return [{ code: 500, msg: 'Failed to enable extension' }, null];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { extension } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Everything that has to go along with `name`: the extensions that require it, the ones that require
|
|
7
|
+
those, and so on. Each entry says which members of the affected set (the extension itself included)
|
|
8
|
+
it requires directly, so the chain can be shown, and whether it is currently enabled. Deepest
|
|
9
|
+
dependents first, which is the order they would have to be dealt with one at a time.
|
|
10
|
+
|
|
11
|
+
By default only enabled extensions count, which is what disabling needs: one that is already off
|
|
12
|
+
does not have to be switched off again. Uninstalling needs `includeDisabled`, because an extension
|
|
13
|
+
that is installed but switched off is still installed, and must not be left behind without what it
|
|
14
|
+
requires.
|
|
15
|
+
*/
|
|
16
|
+
export default async (name, { includeDisabled = false } = {}) => {
|
|
17
|
+
const rows = await (includeDisabled
|
|
18
|
+
? db.select({ name: extension.name, kempo: extension.kempo, enabled: extension.enabled }).from(extension)
|
|
19
|
+
: db.select({ name: extension.name, kempo: extension.kempo, enabled: extension.enabled }).from(extension).where(eq(extension.enabled, true)));
|
|
20
|
+
|
|
21
|
+
const requires = new Map(rows.map(row => [row.name, row.kempo?.dependencies || []]));
|
|
22
|
+
const enabled = new Map(rows.map(row => [row.name, row.enabled]));
|
|
23
|
+
const depth = new Map([[name, 0]]);
|
|
24
|
+
let frontier = [name];
|
|
25
|
+
|
|
26
|
+
while(frontier.length){
|
|
27
|
+
const next = [];
|
|
28
|
+
for(const [candidate, needs] of requires){
|
|
29
|
+
if(depth.has(candidate)) continue;
|
|
30
|
+
const via = frontier.find(need => needs.includes(need));
|
|
31
|
+
if(via !== undefined){
|
|
32
|
+
depth.set(candidate, depth.get(via) + 1);
|
|
33
|
+
next.push(candidate);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
frontier = next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [...depth.entries()]
|
|
40
|
+
.filter(([candidate]) => candidate !== name)
|
|
41
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
42
|
+
.map(([candidate]) => ({
|
|
43
|
+
name: candidate,
|
|
44
|
+
requires: requires.get(candidate).filter(need => depth.has(need)),
|
|
45
|
+
enabled: enabled.get(candidate) === true,
|
|
46
|
+
}));
|
|
47
|
+
};
|
|
@@ -2,7 +2,7 @@ import db from '../../db/index.js';
|
|
|
2
2
|
import { extension } from '../../db/schema.js';
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
4
|
import { join } from 'path';
|
|
5
|
-
import
|
|
5
|
+
import readKempoConfig from './readKempoConfig.js';
|
|
6
6
|
|
|
7
7
|
const NODE_MODULES = join(process.cwd(), 'node_modules');
|
|
8
8
|
|
|
@@ -18,15 +18,16 @@ export default async ({ name }) => {
|
|
|
18
18
|
}
|
|
19
19
|
const ext = results[0];
|
|
20
20
|
let pkg = {};
|
|
21
|
+
let kempo = {};
|
|
21
22
|
try {
|
|
22
|
-
pkg =
|
|
23
|
+
({ pkg, kempo } = await readKempoConfig(join(NODE_MODULES, name)));
|
|
23
24
|
} catch { /* package may not be installed */ }
|
|
24
25
|
return [null, {
|
|
25
26
|
...ext,
|
|
26
27
|
description: pkg.description || null,
|
|
27
28
|
author: pkg.author || null,
|
|
28
29
|
license: pkg.license || null,
|
|
29
|
-
docs:
|
|
30
|
+
docs: kempo.docs || null,
|
|
30
31
|
}];
|
|
31
32
|
} catch(error) {
|
|
32
33
|
return [{ code: 500, msg: 'Failed to get extension' }, null];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import getSetting from '../settings/getSetting.js';
|
|
2
|
+
import { createCatalogClient, DEFAULT_CATALOG_URL } from './catalogClient.js';
|
|
3
|
+
|
|
4
|
+
const clients = new Map();
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
This site's marketplace client. The address is the `system:extensions_catalog_url` setting: it
|
|
8
|
+
defaults to https://extensions.kempo.dev/api/v1, and an empty value turns the marketplace off. It is
|
|
9
|
+
deliberately not one of the seeded default settings, because seeding rewrites those on every run and
|
|
10
|
+
would put a site's chosen address back.
|
|
11
|
+
|
|
12
|
+
One client per address, so its in-memory cache outlives a request.
|
|
13
|
+
*/
|
|
14
|
+
export default async () => {
|
|
15
|
+
const [, configured] = await getSetting('system', 'extensions_catalog_url', DEFAULT_CATALOG_URL);
|
|
16
|
+
const baseUrl = typeof configured === 'string' ? configured.trim() : DEFAULT_CATALOG_URL;
|
|
17
|
+
|
|
18
|
+
if(!clients.has(baseUrl)) clients.set(baseUrl, createCatalogClient({ baseUrl }));
|
|
19
|
+
return clients.get(baseUrl);
|
|
20
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { extension } from '../../db/schema.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
What an extension needs in order to run, followed all the way down: the extensions it requires, the
|
|
6
|
+
ones those require, and so on.
|
|
7
|
+
|
|
8
|
+
disabled installed but switched off, so they have to be enabled first. Each says which of its
|
|
9
|
+
own requirements it needs, and they come dependencies-first, the order they could be
|
|
10
|
+
enabled one at a time.
|
|
11
|
+
missing not installed at all. Enabling cannot fix that.
|
|
12
|
+
|
|
13
|
+
The mirror of getDependentTree.js, which walks the other way (what needs this one).
|
|
14
|
+
*/
|
|
15
|
+
export default async name => {
|
|
16
|
+
const rows = await db.select({ name: extension.name, kempo: extension.kempo, enabled: extension.enabled }).from(extension);
|
|
17
|
+
const byName = new Map(rows.map(row => [row.name, row]));
|
|
18
|
+
|
|
19
|
+
const disabled = [];
|
|
20
|
+
const missing = [];
|
|
21
|
+
const seen = new Set([name]);
|
|
22
|
+
|
|
23
|
+
const visit = current => {
|
|
24
|
+
for(const required of byName.get(current)?.kempo?.dependencies || []){
|
|
25
|
+
if(seen.has(required)) continue;
|
|
26
|
+
seen.add(required);
|
|
27
|
+
|
|
28
|
+
const row = byName.get(required);
|
|
29
|
+
if(!row){
|
|
30
|
+
missing.push(required);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
visit(required);
|
|
35
|
+
if(!row.enabled){
|
|
36
|
+
disabled.push({ name: required, requires: (row.kempo?.dependencies || []).filter(need => byName.has(need)) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
visit(name);
|
|
41
|
+
|
|
42
|
+
return { disabled, missing };
|
|
43
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import getSetting from '../settings/getSetting.js';
|
|
3
|
+
import setSetting from '../settings/setSetting.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The random id this site uses to identify itself to the extension marketplace, so an install is
|
|
7
|
+
counted once however many times an extension is uninstalled and reinstalled, and a site can have one
|
|
8
|
+
rating per extension. Generated the first time it is needed and kept in `system:install_id`.
|
|
9
|
+
|
|
10
|
+
It identifies nothing but this install: no name, address or account is attached to it.
|
|
11
|
+
*/
|
|
12
|
+
export default async () => {
|
|
13
|
+
const [error, existing] = await getSetting('system', 'install_id', null);
|
|
14
|
+
if(error) return [error, null];
|
|
15
|
+
if(typeof existing === 'string' && existing) return [null, existing];
|
|
16
|
+
|
|
17
|
+
const installId = randomUUID();
|
|
18
|
+
const [setError] = await setSetting('system', 'install_id', installId, 'string', false, 'Random id this site uses to identify itself to the extension marketplace');
|
|
19
|
+
if(setError) return [setError, null];
|
|
20
|
+
return [null, installId];
|
|
21
|
+
};
|
|
@@ -4,7 +4,6 @@ import { eq } from 'drizzle-orm';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { pathToFileURL } from 'url';
|
|
6
6
|
import { existsSync } from 'fs';
|
|
7
|
-
import { readFile } from 'fs/promises';
|
|
8
7
|
import { randomUUID } from 'crypto';
|
|
9
8
|
import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
|
|
10
9
|
import createHook from '../hooks/createHook.js';
|
|
@@ -12,6 +11,7 @@ import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
|
|
|
12
11
|
import setSetting from '../settings/setSetting.js';
|
|
13
12
|
import declaredValue from '../settings/declaredValue.js';
|
|
14
13
|
import createTablesFromSchema from './createTablesFromSchema.js';
|
|
14
|
+
import readKempoConfig from './readKempoConfig.js';
|
|
15
15
|
import { invalidateScopeCache } from './scopeCache.js';
|
|
16
16
|
import { getUnmetDependencies } from './dependencies.js';
|
|
17
17
|
|
|
@@ -31,7 +31,6 @@ export default async ({ name }) => {
|
|
|
31
31
|
|
|
32
32
|
const extRoot = join(process.cwd(), 'node_modules', name);
|
|
33
33
|
const extPkgPath = join(extRoot, 'package.json');
|
|
34
|
-
const kempoConfigPath = join(extRoot, 'kempo-config.json');
|
|
35
34
|
|
|
36
35
|
if(!existsSync(extPkgPath)){
|
|
37
36
|
return [{ code: 404, msg: `Extension package "${name}" not found` }, null];
|
|
@@ -39,10 +38,7 @@ export default async ({ name }) => {
|
|
|
39
38
|
|
|
40
39
|
let pkg, kempoConfig;
|
|
41
40
|
try {
|
|
42
|
-
pkg =
|
|
43
|
-
kempoConfig = existsSync(kempoConfigPath)
|
|
44
|
-
? JSON.parse(await readFile(kempoConfigPath, 'utf-8'))
|
|
45
|
-
: {};
|
|
41
|
+
({ pkg, kempo: kempoConfig } = await readKempoConfig(extRoot));
|
|
46
42
|
} catch {
|
|
47
43
|
return [{ code: 500, msg: `Failed to read extension package files` }, null];
|
|
48
44
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import db from '../../db/index.js';
|
|
4
|
+
import { extension } from '../../db/schema.js';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import installExtension from './installExtension.js';
|
|
7
|
+
import installPackage from './installPackage.js';
|
|
8
|
+
import enableExtension from './enableExtension.js';
|
|
9
|
+
import { NAME_PATTERN } from './catalogClient.js';
|
|
10
|
+
|
|
11
|
+
const isInstalled = async name => {
|
|
12
|
+
const [row] = await db.select({ name: extension.name }).from(extension).where(eq(extension.name, name)).limit(1);
|
|
13
|
+
return !!row;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isEnabled = async name => {
|
|
17
|
+
const [row] = await db.select({ enabled: extension.enabled }).from(extension).where(eq(extension.name, name)).limit(1);
|
|
18
|
+
return row?.enabled === true;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const hasPackage = name => existsSync(join(process.cwd(), 'node_modules', name, 'package.json'));
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
Installs an extension, and with `withDependencies` every extension it needs first, in dependency
|
|
25
|
+
order, downloading what is not in node_modules. What is installed is exactly the marketplace's tree
|
|
26
|
+
for that extension: the caller is expected to have shown it to the administrator for approval.
|
|
27
|
+
|
|
28
|
+
Without `withDependencies` only `name` is installed, and a dependency that is not installed yet is
|
|
29
|
+
an error that names them (`needs`), so the caller can ask.
|
|
30
|
+
|
|
31
|
+
A package that is already in node_modules is not downloaded again. A failure part way leaves what
|
|
32
|
+
was installed before it installed (each one is a normal, complete install) and says which.
|
|
33
|
+
|
|
34
|
+
`deps` exists so the order and the refusals can be tested without npm or a database.
|
|
35
|
+
*/
|
|
36
|
+
export default async ({ name, withDependencies = false, client, installId, report = true, deps = {} }) => {
|
|
37
|
+
const download = deps.download || installPackage;
|
|
38
|
+
const install = deps.install || installExtension;
|
|
39
|
+
const installed = deps.isInstalled || isInstalled;
|
|
40
|
+
const enabled = deps.isEnabled || isEnabled;
|
|
41
|
+
const enable = deps.enable || enableExtension;
|
|
42
|
+
const present = deps.hasPackage || hasPackage;
|
|
43
|
+
|
|
44
|
+
if(typeof name !== 'string' || !NAME_PATTERN.test(name)){
|
|
45
|
+
return [{ code: 400, msg: 'Extension name is not a valid package name' }, null];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if(await installed(name)){
|
|
49
|
+
return [{ code: 409, msg: 'Extension is already installed' }, null];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [detailsError, details] = await client.details(name);
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
Without the marketplace an extension that is already in node_modules can still be installed the
|
|
56
|
+
way it always could be; one that has to be downloaded cannot.
|
|
57
|
+
*/
|
|
58
|
+
if(detailsError){
|
|
59
|
+
if(!present(name)) return [detailsError, null];
|
|
60
|
+
const [error, data] = await install({ name });
|
|
61
|
+
if(error) return [error, null];
|
|
62
|
+
return [null, { name, installed: [name], ...data }];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const plan = [...details.dependencies.extensions, { name, version: details.version, installable: details.installable }];
|
|
66
|
+
|
|
67
|
+
for(const missing of details.dependencies.unresolved){
|
|
68
|
+
if(!(await installed(missing)) && !present(missing)){
|
|
69
|
+
return [{ code: 409, msg: `Requires ${missing}, which is not in the extension catalogue` }, null];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if(!withDependencies){
|
|
74
|
+
const needs = [];
|
|
75
|
+
for(const step of plan.slice(0, -1)){
|
|
76
|
+
if(!(await installed(step.name)) || !(await enabled(step.name))) needs.push(step.name);
|
|
77
|
+
}
|
|
78
|
+
if(needs.length){
|
|
79
|
+
return [{ code: 409, msg: `Requires ${needs.join(', ')} to be installed and enabled first`, needs }, null];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const done = [];
|
|
84
|
+
const switchedOn = [];
|
|
85
|
+
let result = {};
|
|
86
|
+
|
|
87
|
+
for(const step of withDependencies ? plan : plan.slice(-1)){
|
|
88
|
+
/*
|
|
89
|
+
Installed but switched off is not usable as a requirement. The administrator approved this chain,
|
|
90
|
+
so it is switched on rather than refused.
|
|
91
|
+
*/
|
|
92
|
+
if(await installed(step.name)){
|
|
93
|
+
if(!(await enabled(step.name))){
|
|
94
|
+
const [enableError] = await enable({ name: step.name, cascade: true });
|
|
95
|
+
if(enableError) return [{ ...enableError, installed: done, enabled: switchedOn }, null];
|
|
96
|
+
switchedOn.push(step.name);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if(!present(step.name)){
|
|
102
|
+
if(!step.installable){
|
|
103
|
+
return [{ code: 409, msg: `"${step.name}" is listed but not published yet, so it cannot be installed`, installed: done }, null];
|
|
104
|
+
}
|
|
105
|
+
const [downloadError] = await download({ name: step.name, version: step.version || 'latest' });
|
|
106
|
+
if(downloadError) return [{ ...downloadError, installed: done }, null];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [installError, data] = await install({ name: step.name });
|
|
110
|
+
if(installError) return [{ ...installError, installed: done }, null];
|
|
111
|
+
|
|
112
|
+
done.push(step.name);
|
|
113
|
+
if(step.name === name) result = data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
Tell the marketplace, once per extension, after the fact and without making the install wait for
|
|
118
|
+
it: a marketplace that is down must not make an install fail.
|
|
119
|
+
*/
|
|
120
|
+
if(report && installId){
|
|
121
|
+
for(const installedName of done){
|
|
122
|
+
client.report({ installId, extension: installedName }).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return [null, { name, installed: done, enabled: switchedOn, ...result }];
|
|
127
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { NAME_PATTERN } from './catalogClient.js';
|
|
4
|
+
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Downloads an extension's package into this site's node_modules with `npm install`, which also adds it
|
|
9
|
+
to the site's package.json. Only an exact package name and a plain version or tag are accepted, which
|
|
10
|
+
is what makes passing them to a shell on Windows (where npm is a .cmd) safe.
|
|
11
|
+
|
|
12
|
+
Note for container deployments: this changes the running container's files. A redeploy that rebuilds
|
|
13
|
+
the image from the repository's package.json does not include it, so add the package there too.
|
|
14
|
+
*/
|
|
15
|
+
export default async ({ name, version = 'latest' }) => {
|
|
16
|
+
if(typeof name !== 'string' || !NAME_PATTERN.test(name)){
|
|
17
|
+
return [{ code: 400, msg: 'Extension name is not a valid package name' }, null];
|
|
18
|
+
}
|
|
19
|
+
if(typeof version !== 'string' || !/^[A-Za-z0-9._-]+$/.test(version)){
|
|
20
|
+
return [{ code: 400, msg: 'Extension version is not valid' }, null];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await run('npm', ['install', `${name}@${version}`, '--no-audit', '--no-fund'], {
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
shell: process.platform === 'win32',
|
|
27
|
+
timeout: 5 * 60 * 1000,
|
|
28
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
29
|
+
});
|
|
30
|
+
} catch(error) {
|
|
31
|
+
const detail = String(error.stderr || error.message || '').trim().split('\n').filter(Boolean).slice(-3).join(' ');
|
|
32
|
+
return [{ code: 500, msg: `Failed to download "${name}": ${detail}` }, null];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [null, { name, version }];
|
|
36
|
+
};
|
|
@@ -2,15 +2,16 @@ import db from '../../db/index.js';
|
|
|
2
2
|
import { extension } from '../../db/schema.js';
|
|
3
3
|
import { sql } from 'drizzle-orm';
|
|
4
4
|
import { join } from 'path';
|
|
5
|
-
import
|
|
5
|
+
import readKempoConfig from './readKempoConfig.js';
|
|
6
6
|
|
|
7
7
|
const NODE_MODULES = join(process.cwd(), 'node_modules');
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const readConfig = async name => {
|
|
10
10
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
return await readKempoConfig(join(NODE_MODULES, name));
|
|
12
|
+
} catch {
|
|
13
|
+
return { pkg: {}, kempo: {} };
|
|
14
|
+
}
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export default async ({ limit = 50, offset = 0 } = {}) => {
|
|
@@ -20,13 +21,13 @@ export default async ({ limit = 50, offset = 0 } = {}) => {
|
|
|
20
21
|
db.select({ count: sql`count(*)` }).from(extension),
|
|
21
22
|
]);
|
|
22
23
|
const enriched = await Promise.all(items.map(async (ext) => {
|
|
23
|
-
const pkg = await
|
|
24
|
+
const { pkg, kempo } = await readConfig(ext.name);
|
|
24
25
|
return {
|
|
25
26
|
...ext,
|
|
26
27
|
description: pkg.description || null,
|
|
27
28
|
author: pkg.author || null,
|
|
28
29
|
license: pkg.license || null,
|
|
29
|
-
docs:
|
|
30
|
+
docs: kempo.docs || null,
|
|
30
31
|
};
|
|
31
32
|
}));
|
|
32
33
|
return [null, { items: enriched, total: Number(count), limit, offset }];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
What an installed extension declares to kempo: its `public-scope`, `docs`, `git`, `schema`, `hooks`,
|
|
6
|
+
`permissions`, `settings`, `groups`, `dependencies` and `widgets`.
|
|
7
|
+
|
|
8
|
+
It lives in the extension's kempo-config.json, which is the one place it is written. package.json's own
|
|
9
|
+
`kempo` field is still read, as the fallback for an older extension that put everything there, and
|
|
10
|
+
kempo-config.json wins field by field. Nothing should add new keys to package.json.
|
|
11
|
+
|
|
12
|
+
Reads both files fresh each time, so an extension replaced on disk is seen without a restart.
|
|
13
|
+
Throws when package.json cannot be read, which is how a caller knows the package is not installed.
|
|
14
|
+
*/
|
|
15
|
+
export default async extRoot => {
|
|
16
|
+
const pkg = JSON.parse(await readFile(join(extRoot, 'package.json'), 'utf-8'));
|
|
17
|
+
const config = await readFile(join(extRoot, 'kempo-config.json'), 'utf-8').then(JSON.parse, () => ({}));
|
|
18
|
+
|
|
19
|
+
return { pkg, kempo: { ...(pkg.kempo || {}), ...config } };
|
|
20
|
+
};
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
2
|
import { extension } from '../../db/schema.js';
|
|
3
|
-
import { eq } from 'drizzle-orm';
|
|
4
3
|
|
|
5
4
|
let cache = null;
|
|
6
5
|
|
|
7
6
|
export const invalidateScopeCache = () => { cache = null; };
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
/* One read of the extension table serves both questions, until an install/enable/disable/uninstall. */
|
|
9
|
+
const load = async () => {
|
|
10
10
|
if(cache) return cache;
|
|
11
11
|
try {
|
|
12
|
-
|
|
12
|
+
const rows = await db.select().from(extension);
|
|
13
|
+
cache = {
|
|
14
|
+
enabled: rows.filter(row => row.enabled),
|
|
15
|
+
disabled: new Set(rows.filter(row => !row.enabled).map(row => row.name)),
|
|
16
|
+
};
|
|
13
17
|
} catch(e) {
|
|
14
|
-
console.error('[scopeCache] DB error fetching
|
|
15
|
-
return [];
|
|
18
|
+
console.error('[scopeCache] DB error fetching extensions:', e.message);
|
|
19
|
+
return { enabled: [], disabled: new Set() };
|
|
16
20
|
}
|
|
17
21
|
return cache;
|
|
18
22
|
};
|
|
23
|
+
|
|
24
|
+
export const getEnabledExtensions = async () => (await load()).enabled;
|
|
25
|
+
|
|
26
|
+
/* Names of extensions that are installed but switched off. Not-installed owners (core, a site) are never in it. */
|
|
27
|
+
export const getDisabledExtensionNames = async () => (await load()).disabled;
|