kempo 4.5.8 → 4.6.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.
Files changed (50) hide show
  1. package/dist/admin/components/tableControls/ExtensionDetails.js +2 -0
  2. package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -0
  3. package/dist/admin/components/tableControls/InstallExtension.js.map +1 -1
  4. package/dist/admin/components/tableControls/ToggleExtension.js +2 -0
  5. package/dist/admin/components/tableControls/ToggleExtension.js.map +1 -0
  6. package/dist/admin/components/tableControls/UninstallExtension.js +1 -1
  7. package/dist/admin/components/tableControls/UninstallExtension.js.map +1 -1
  8. package/dist/admin/extensions/browse/index.page.html +118 -0
  9. package/dist/admin/extensions/details/index.page.html +254 -0
  10. package/dist/admin/extensions/index.page.html +8 -2
  11. package/dist/kempo/api/extensions/DELETE.js +1 -1
  12. package/dist/kempo/api/extensions/DELETE.js.map +1 -1
  13. package/dist/kempo/api/extensions/POST.js +1 -1
  14. package/dist/kempo/api/extensions/POST.js.map +1 -1
  15. package/dist/kempo/api/extensions/[name]/dependents/GET.js +2 -0
  16. package/dist/kempo/api/extensions/[name]/dependents/GET.js.map +1 -0
  17. package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
  18. package/dist/kempo/api/extensions/[name]/disable/POST.js.map +1 -1
  19. package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
  20. package/dist/kempo/api/extensions/[name]/enable/POST.js.map +1 -1
  21. package/dist/kempo/api/extensions/[name]/requirements/GET.js +2 -0
  22. package/dist/kempo/api/extensions/[name]/requirements/GET.js.map +1 -0
  23. package/dist/kempo/api/extensions/available/GET.js +1 -1
  24. package/dist/kempo/api/extensions/available/GET.js.map +1 -1
  25. package/dist/kempo/api/extensions/known/GET.js +1 -1
  26. package/dist/kempo/api/extensions/known/GET.js.map +1 -1
  27. package/dist/kempo/api/extensions/rating/PUT.js +2 -0
  28. package/dist/kempo/api/extensions/rating/PUT.js.map +1 -0
  29. package/dist/kempo/icons/payments.svg +1 -0
  30. package/dist/kempo/icons/toll.svg +1 -0
  31. package/dist/kempo/icons/wallet.svg +1 -0
  32. package/dist/kempo/sdk.js +1 -1
  33. package/dist/kempo/sdk.js.map +1 -1
  34. package/package.json +1 -1
  35. package/server/utils/extensions/catalogClient.js +203 -0
  36. package/server/utils/extensions/disableExtension.js +2 -32
  37. package/server/utils/extensions/disableExtensionTree.js +46 -0
  38. package/server/utils/extensions/enableExtension.js +2 -33
  39. package/server/utils/extensions/enableExtensionTree.js +54 -0
  40. package/server/utils/extensions/getDependentTree.js +47 -0
  41. package/server/utils/extensions/getMarketplaceClient.js +20 -0
  42. package/server/utils/extensions/getRequirementTree.js +43 -0
  43. package/server/utils/extensions/getSiteInstallId.js +21 -0
  44. package/server/utils/extensions/installFromCatalog.js +127 -0
  45. package/server/utils/extensions/installPackage.js +36 -0
  46. package/server/utils/extensions/scopeCache.js +14 -5
  47. package/server/utils/extensions/uninstallExtension.js +2 -124
  48. package/server/utils/extensions/uninstallExtensionTree.js +165 -0
  49. package/server/utils/hooks/triggerHook.js +8 -0
  50. package/dist/admin/known-extensions.json +0 -1
@@ -1,33 +1,2 @@
1
- import db from '../../db/index.js';
2
- import { extension } from '../../db/schema.js';
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
+ };
@@ -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://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
+ };
@@ -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
+ };
@@ -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
- export const getEnabledExtensions = async () => {
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
- cache = await db.select().from(extension).where(eq(extension.enabled, true));
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 enabled extensions:', e.message);
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;
@@ -1,124 +1,2 @@
1
- import db from '../../db/index.js';
2
- import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget } from '../../db/schema.js';
3
- import { eq, inArray, like } from 'drizzle-orm';
4
- import { join } from 'path';
5
- import { pathToFileURL } from 'url';
6
- import { sql } from 'drizzle-orm';
7
- import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
8
- import { invalidateScopeCache } from './scopeCache.js';
9
- import { getEnabledDependents } from './dependencies.js';
10
-
11
- /*
12
- Uninstall an extension.
13
-
14
- `purgeData` false (the default) deregisters the extension but leaves everything it created in
15
- place — its tables and their rows, group memberships, permissions, groups and settings — so
16
- reinstalling restores the site exactly as it was. This is the right mode for reinstalling to pick
17
- up a fix.
18
-
19
- `purgeData` true additionally destroys all of that. For an extension like kempo-blog that means
20
- every post and comment, so it must always be a deliberate choice rather than a default.
21
-
22
- Hooks and the extension row are removed either way. A registered hook whose module is no longer
23
- installed cannot be tolerated: `middleware:before_page` is triggered with `bail: true`, so a
24
- handler that fails to import now aborts the render and every page 404s.
25
- */
26
- export default async ({ name, purgeData = false }) => {
27
- if(!name){
28
- return [{ code: 400, msg: 'Extension name is required' }, null];
29
- }
30
-
31
- let kempoConfig;
32
- try {
33
- const [existing] = await db.select().from(extension).where(eq(extension.name, name)).limit(1);
34
- if(!existing){
35
- return [{ code: 404, msg: 'Extension not found' }, null];
36
- }
37
- kempoConfig = existing.kempo || {};
38
- } catch(error) {
39
- return [{ code: 500, msg: 'Failed to find extension' }, null];
40
- }
41
-
42
- const dependents = await getEnabledDependents(name);
43
- if(dependents.length){
44
- return [{ code: 409, msg: `${dependents.join(', ')} depends on this extension and is still enabled` }, null];
45
- }
46
-
47
- try {
48
- const extRoot = join(process.cwd(), 'node_modules', name);
49
- const uninstallModule = await import(pathToFileURL(join(extRoot, 'uninstall.js')).href + `?t=${Date.now()}`).catch(() => null);
50
- if(uninstallModule?.default){
51
- await uninstallModule.default();
52
- }
53
- } catch {}
54
-
55
- /*
56
- Drop declarative schema tables. Only when purging — these hold the extension's actual content.
57
- */
58
- if(purgeData && kempoConfig.schema){
59
- try {
60
- const extRoot = join(process.cwd(), 'node_modules', name);
61
- const schemaModule = await import(pathToFileURL(join(extRoot, kempoConfig.schema)).href);
62
- const { getTableConfig } = await import('drizzle-orm/pg-core');
63
- for(const exported of Object.values(schemaModule)){
64
- if(typeof exported !== 'object' || !exported?.[Symbol.for('drizzle:Name')]) continue;
65
- const { name: tableName } = getTableConfig(exported);
66
- await db.execute(sql.raw(`DROP TABLE IF EXISTS "${tableName}" CASCADE`));
67
- }
68
- } catch {}
69
- }
70
-
71
- /*
72
- Remove declarative groups, permissions and settings. Only when purging: these are what a
73
- reinstall would otherwise restore, and group memberships in particular represent real user
74
- state — every registered user of kempo-blog belongs to kempo-blog:member.
75
-
76
- userGroup and groupPermission both reference group.name, and groupPermission also references
77
- permission.name, none of them cascading. The membership and grant rows therefore have to go
78
- first or these deletes fail the foreign key — which they used to do silently, leaving every
79
- uninstalled extension's groups and permissions behind forever.
80
- */
81
- if(purgeData){
82
- const groupNames = (kempoConfig.groups || []).map(g => g.name);
83
- const permNames = (kempoConfig.permissions || []).map(p => p.name);
84
-
85
- try {
86
- if(groupNames.length){
87
- await db.delete(userGroup).where(inArray(userGroup.groupName, groupNames));
88
- await db.delete(groupPermission).where(inArray(groupPermission.groupName, groupNames));
89
- }
90
- if(permNames.length){
91
- await db.delete(groupPermission).where(inArray(groupPermission.permissionName, permNames));
92
- }
93
- if(groupNames.length){
94
- await db.delete(group).where(inArray(group.name, groupNames));
95
- }
96
- if(permNames.length){
97
- await db.delete(permission).where(inArray(permission.name, permNames));
98
- }
99
- } catch(error) {
100
- return [{ code: 500, msg: `Failed to remove extension groups and permissions: ${error.message}` }, null];
101
- }
102
-
103
- /*
104
- Ownership is encoded in the setting name as "extname:settingname" — the setting table has no
105
- owner column, so these have to be matched by prefix.
106
- */
107
- await db.delete(setting).where(like(setting.name, `${name}:%`));
108
- }
109
-
110
- try {
111
- await db.delete(hook).where(eq(hook.owner, name));
112
- await db.delete(dashboardWidget).where(eq(dashboardWidget.owner, name));
113
- await db.delete(extension).where(eq(extension.name, name));
114
- } catch(error) {
115
- return [{ code: 500, msg: 'Failed to remove extension' }, null];
116
- }
117
-
118
- clearHandlerCache();
119
- invalidateScopeCache();
120
- await triggerHook('extension:uninstalled', { name });
121
-
122
- return [null, { success: true }];
123
- };
124
-
1
+ /* The name callers import; the implementation, with its cascade over dependents, is in uninstallExtensionTree.js. */
2
+ export { default } from './uninstallExtensionTree.js';