arcway 0.1.30 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/bin/cli.js +2 -0
- package/server/bin/commands/bootstrap.js +21 -14
- package/server/bin/commands/vault.js +14 -0
- package/server/boot/index.js +20 -2
- package/server/config/loader.js +2 -2
- package/server/config/modules/mcp.js +2 -2
- package/server/config/modules/session.js +7 -1
- package/server/config/modules/vault.js +44 -0
- package/server/context.js +30 -5
- package/server/jobs/runner.js +29 -10
- package/server/jobs/worker-entry.js +9 -2
- package/server/lib/vault/index.js +10 -2
- package/server/lib/vault/jwt.js +34 -21
- package/server/lib/vault/secrets.js +64 -17
- package/server/plugins/bundle.js +122 -0
- package/server/plugins/discovery.js +45 -6
- package/server/plugins/graph.js +22 -8
- package/server/plugins/manager.js +196 -11
- package/server/plugins/manifest.js +27 -31
- package/server/router/api-router.js +4 -0
- package/server/router/routes.js +7 -2
- package/server/session/index.js +57 -15
- package/server/testing/index.js +1 -0
- package/server/config/modules/secrets.js +0 -145
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
|
|
1
|
+
import { createECDH, createHash, hkdfSync, randomBytes } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
const MASTER_VERSION = 'v1';
|
|
4
4
|
const MASTER_BYTES = 32;
|
|
5
5
|
const VAULT_KEY_BYTES = 32;
|
|
6
6
|
const HKDF_SALT = 'arcway:infra-secrets:v1';
|
|
7
7
|
|
|
8
|
-
const SECRET_NAMES = Object.freeze([
|
|
9
|
-
'session',
|
|
10
|
-
'plugin-vault',
|
|
11
|
-
'mcp-api',
|
|
12
|
-
'pty-api',
|
|
13
|
-
'pty-server-api',
|
|
14
|
-
'vapid',
|
|
15
|
-
]);
|
|
8
|
+
const SECRET_NAMES = Object.freeze(['session', 'plugin-vault', 'mcp-api', 'jwt']);
|
|
16
9
|
|
|
17
10
|
function base64Url(buffer) {
|
|
18
11
|
return Buffer.from(buffer).toString('base64url');
|
|
@@ -65,6 +58,13 @@ function deriveBytes(master, namespace, length = MASTER_BYTES) {
|
|
|
65
58
|
return Buffer.from(hkdfSync('sha256', key, HKDF_SALT, `arcway:${namespace}`, length));
|
|
66
59
|
}
|
|
67
60
|
|
|
61
|
+
function deriveKey(master, label) {
|
|
62
|
+
if (!label || typeof label !== 'string') {
|
|
63
|
+
throw new Error('deriveKey label must be a non-empty string');
|
|
64
|
+
}
|
|
65
|
+
return base64Url(deriveBytes(master, `app:${label}`, MASTER_BYTES));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
68
|
function formatVaultKey(bytes) {
|
|
69
69
|
const key = Buffer.from(bytes);
|
|
70
70
|
if (key.length !== VAULT_KEY_BYTES) {
|
|
@@ -77,11 +77,15 @@ function validateVaultKey(value, label = 'plugin-vault secret') {
|
|
|
77
77
|
return formatVaultKey(decodeVersionedSecret(value, label));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function
|
|
80
|
+
function deriveKeyPair(master, label, { curve = 'prime256v1' } = {}) {
|
|
81
|
+
if (!label || typeof label !== 'string') {
|
|
82
|
+
throw new Error('deriveKeyPair label must be a non-empty string');
|
|
83
|
+
}
|
|
84
|
+
|
|
81
85
|
for (let i = 0; i < 32; i += 1) {
|
|
82
|
-
const privateKey = deriveBytes(master, `
|
|
86
|
+
const privateKey = deriveBytes(master, `keypair:${curve}:${label}:private:${i}`, 32);
|
|
83
87
|
try {
|
|
84
|
-
const ecdh = createECDH(
|
|
88
|
+
const ecdh = createECDH(curve);
|
|
85
89
|
ecdh.setPrivateKey(privateKey);
|
|
86
90
|
return {
|
|
87
91
|
publicKey: base64Url(ecdh.getPublicKey(null, 'uncompressed')),
|
|
@@ -91,16 +95,14 @@ function deriveVapidKeyPair(master) {
|
|
|
91
95
|
// Try the next deterministic candidate.
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
|
-
throw new Error(
|
|
98
|
+
throw new Error(`Unable to derive a valid deterministic ${curve} keypair for label "${label}"`);
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
function deriveInfraSecret(master, name) {
|
|
98
102
|
if (name === 'session') return base64Url(deriveBytes(master, name, 48));
|
|
99
103
|
if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
|
|
100
104
|
if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
|
|
101
|
-
if (name === '
|
|
102
|
-
if (name === 'pty-server-api') return base64Url(deriveBytes(master, name, 48));
|
|
103
|
-
if (name === 'vapid') return deriveVapidKeyPair(master);
|
|
105
|
+
if (name === 'jwt') return base64Url(deriveBytes(master, name, 48));
|
|
104
106
|
throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -112,6 +114,47 @@ function deriveInfraSecrets(master) {
|
|
|
112
114
|
return values;
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
function normalizeMasterSecretRing(master) {
|
|
118
|
+
const masters = Array.isArray(master) ? master : [master];
|
|
119
|
+
const normalized = masters.filter(
|
|
120
|
+
(value) => value !== undefined && value !== null && value !== '',
|
|
121
|
+
);
|
|
122
|
+
if (normalized.length === 0) throw new Error('Arcway master secret keyring cannot be empty');
|
|
123
|
+
return normalized.map((value, index) =>
|
|
124
|
+
normalizeMasterSecret(value, `Arcway master secret keyring entry ${index + 1}`),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stableSessionPasswordId(sessionSecret) {
|
|
129
|
+
const digest = createHash('sha256').update(`arcway:session-key-id:v1:${sessionSecret}`).digest();
|
|
130
|
+
const id = digest.readUIntBE(0, 6);
|
|
131
|
+
return String(id === 0 ? 1 : id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function deriveInfraSecretRing(master) {
|
|
135
|
+
const masters = normalizeMasterSecretRing(master);
|
|
136
|
+
const entries = masters.map((value) => {
|
|
137
|
+
const secrets = deriveInfraSecrets(value);
|
|
138
|
+
return {
|
|
139
|
+
id: stableSessionPasswordId(secrets.session),
|
|
140
|
+
secrets,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const seenIds = new Set();
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (seenIds.has(entry.id)) {
|
|
147
|
+
throw new Error('Derived session key ids collided; rotate with different master secrets');
|
|
148
|
+
}
|
|
149
|
+
seenIds.add(entry.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
entries,
|
|
154
|
+
active: entries.at(-1),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
115
158
|
function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt, onRotate } = {}) {
|
|
116
159
|
if (typeof decrypt !== 'function') throw new Error('decryptWithRotation requires decrypt');
|
|
117
160
|
if (typeof encrypt !== 'function') throw new Error('decryptWithRotation requires encrypt');
|
|
@@ -138,12 +181,16 @@ function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt,
|
|
|
138
181
|
export {
|
|
139
182
|
SECRET_NAMES,
|
|
140
183
|
decryptWithRotation,
|
|
184
|
+
deriveKey,
|
|
185
|
+
deriveKeyPair,
|
|
141
186
|
deriveBytes,
|
|
142
187
|
deriveInfraSecret,
|
|
188
|
+
deriveInfraSecretRing,
|
|
143
189
|
deriveInfraSecrets,
|
|
144
|
-
deriveVapidKeyPair,
|
|
145
190
|
encodeMasterSecret,
|
|
146
191
|
formatVaultKey,
|
|
147
192
|
normalizeMasterSecret,
|
|
193
|
+
normalizeMasterSecretRing,
|
|
194
|
+
stableSessionPasswordId,
|
|
148
195
|
validateVaultKey,
|
|
149
196
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { build } from 'esbuild';
|
|
5
|
+
import { discoverFiles } from '../discovery.js';
|
|
6
|
+
import { loadModule } from '../module-loader.js';
|
|
7
|
+
|
|
8
|
+
let bundleCounter = 0;
|
|
9
|
+
|
|
10
|
+
async function exists(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(filePath);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err.code === 'ENOENT') return false;
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function importLine(name, filePath, namespace = true) {
|
|
21
|
+
const imported = namespace ? `* as ${name}` : name;
|
|
22
|
+
return `import ${imported} from ${JSON.stringify(filePath)};`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function moduleEntry(name, entry) {
|
|
26
|
+
return `{ name: ${JSON.stringify(entry.name)}, filePath: ${JSON.stringify(entry.filePath)}, relativePath: ${JSON.stringify(entry.relativePath)}, module: ${name} }`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadPluginBundle(packageDir) {
|
|
30
|
+
const bundleDir = path.join(packageDir, '.arcway', 'plugin-bundles');
|
|
31
|
+
await fs.mkdir(bundleDir, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const pluginJs = path.join(packageDir, 'plugin.js');
|
|
34
|
+
const exportsJs = path.join(packageDir, 'exports.js');
|
|
35
|
+
const routesDir = path.join(packageDir, 'routes');
|
|
36
|
+
const jobsDir = path.join(packageDir, 'jobs');
|
|
37
|
+
const capabilitiesDir = path.join(packageDir, 'capabilities');
|
|
38
|
+
const hooksDir = path.join(packageDir, 'hooks');
|
|
39
|
+
|
|
40
|
+
const hasPluginJs = await exists(pluginJs);
|
|
41
|
+
const hasExportsJs = await exists(exportsJs);
|
|
42
|
+
const routes = await discoverFiles(routesDir, { recursive: true });
|
|
43
|
+
const jobs = await discoverFiles(jobsDir, { recursive: true });
|
|
44
|
+
const capabilities = await discoverFiles(capabilitiesDir);
|
|
45
|
+
const hooks = await discoverFiles(hooksDir);
|
|
46
|
+
|
|
47
|
+
const lines = [];
|
|
48
|
+
if (hasPluginJs) lines.push(importLine('pluginModule', pluginJs));
|
|
49
|
+
if (hasExportsJs) lines.push(importLine('pluginExports', exportsJs, false));
|
|
50
|
+
|
|
51
|
+
for (const [index, entry] of routes.entries()) {
|
|
52
|
+
lines.push(importLine(`route${index}`, entry.filePath));
|
|
53
|
+
}
|
|
54
|
+
for (const [index, entry] of jobs.entries()) {
|
|
55
|
+
lines.push(importLine(`job${index}`, entry.filePath));
|
|
56
|
+
}
|
|
57
|
+
for (const [index, entry] of capabilities.entries()) {
|
|
58
|
+
lines.push(importLine(`capability${index}`, entry.filePath, false));
|
|
59
|
+
}
|
|
60
|
+
for (const [index, entry] of hooks.entries()) {
|
|
61
|
+
lines.push(importLine(`hook${index}`, entry.filePath, false));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push(`export const module = ${hasPluginJs ? 'pluginModule' : '{}'};`);
|
|
65
|
+
lines.push(
|
|
66
|
+
`export const exportsModule = ${hasExportsJs ? '{ default: pluginExports }' : 'null'};`,
|
|
67
|
+
);
|
|
68
|
+
lines.push(
|
|
69
|
+
`export const routes = [${routes.map((entry, index) => moduleEntry(`route${index}`, entry)).join(',')}];`,
|
|
70
|
+
);
|
|
71
|
+
lines.push(
|
|
72
|
+
`export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
|
|
73
|
+
);
|
|
74
|
+
lines.push(
|
|
75
|
+
`export const capabilities = {${capabilities
|
|
76
|
+
.map((entry, index) => {
|
|
77
|
+
const name = path.basename(entry.name, path.extname(entry.name));
|
|
78
|
+
return `${JSON.stringify(name)}: capability${index}`;
|
|
79
|
+
})
|
|
80
|
+
.join(',')}};`,
|
|
81
|
+
);
|
|
82
|
+
lines.push(
|
|
83
|
+
`export const hooks = {${hooks
|
|
84
|
+
.map((entry, index) => {
|
|
85
|
+
const name = path.basename(entry.name, path.extname(entry.name));
|
|
86
|
+
return `${JSON.stringify(name)}: hook${index}`;
|
|
87
|
+
})
|
|
88
|
+
.join(',')}};`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const counter = ++bundleCounter;
|
|
92
|
+
const entryPath = path.join(bundleDir, `entry-${counter}.mjs`);
|
|
93
|
+
const outFile = path.join(bundleDir, `bundle-${counter}.mjs`);
|
|
94
|
+
await fs.writeFile(entryPath, lines.join('\n'));
|
|
95
|
+
|
|
96
|
+
await build({
|
|
97
|
+
entryPoints: [entryPath],
|
|
98
|
+
outfile: outFile,
|
|
99
|
+
bundle: true,
|
|
100
|
+
format: 'esm',
|
|
101
|
+
platform: 'node',
|
|
102
|
+
target: 'node20',
|
|
103
|
+
logLevel: 'silent',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const bundled = await loadModule(outFile);
|
|
107
|
+
return {
|
|
108
|
+
module: bundled.module ?? {},
|
|
109
|
+
exportsModule: bundled.exportsModule ?? null,
|
|
110
|
+
routes: bundled.routes ?? [],
|
|
111
|
+
jobs: bundled.jobs ?? [],
|
|
112
|
+
capabilities: bundled.capabilities ?? {},
|
|
113
|
+
hooks: bundled.hooks ?? {},
|
|
114
|
+
artifact: outFile,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fileUrl(filePath) {
|
|
119
|
+
return pathToFileURL(filePath).href;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { fileUrl, loadPluginBundle };
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { discoverModules, discoverPackageDirectories } from '../discovery.js';
|
|
4
4
|
import { loadModule } from '../module-loader.js';
|
|
5
5
|
import { validateManifest } from './manifest.js';
|
|
6
|
+
import { loadPluginBundle } from './bundle.js';
|
|
6
7
|
|
|
7
8
|
async function exists(filePath) {
|
|
8
9
|
try {
|
|
@@ -30,6 +31,23 @@ function getManifest(module, manifestPath) {
|
|
|
30
31
|
return manifest;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function assertPlainObject(value, label) {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
36
|
+
throw new Error(`${label} must export an object`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getExports(module, exportsPath) {
|
|
41
|
+
const exported = module.default;
|
|
42
|
+
assertPlainObject(exported, `Plugin exports at ${exportsPath}`);
|
|
43
|
+
for (const [name, value] of Object.entries(exported)) {
|
|
44
|
+
if (typeof value !== 'function') {
|
|
45
|
+
throw new Error(`Plugin export ${name} at ${exportsPath} must be a function`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { ...exported };
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
// Load every `<dir>/<name>.js` and key the default export by file basename.
|
|
34
52
|
// Used for the capabilities/ and hooks/ folder conventions.
|
|
35
53
|
async function loadConventionModules(dir, label) {
|
|
@@ -43,7 +61,7 @@ async function loadConventionModules(dir, label) {
|
|
|
43
61
|
return map;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
async function loadPluginPackage(packageDir) {
|
|
64
|
+
async function loadPluginPackage(packageDir, { bundle = false } = {}) {
|
|
47
65
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
|
48
66
|
if (!(await exists(packageJsonPath))) {
|
|
49
67
|
throw new Error(`Plugin package at ${packageDir} must include package.json`);
|
|
@@ -52,13 +70,20 @@ async function loadPluginPackage(packageDir) {
|
|
|
52
70
|
|
|
53
71
|
const pluginJs = path.join(packageDir, 'plugin.js');
|
|
54
72
|
const pluginJson = path.join(packageDir, 'plugin.json');
|
|
73
|
+
const exportsJs = path.join(packageDir, 'exports.js');
|
|
55
74
|
let manifestPath;
|
|
56
75
|
let module = {};
|
|
76
|
+
let bundled = null;
|
|
57
77
|
let rawManifest;
|
|
58
78
|
|
|
59
79
|
if (await exists(pluginJs)) {
|
|
60
80
|
manifestPath = pluginJs;
|
|
61
|
-
|
|
81
|
+
if (bundle) {
|
|
82
|
+
bundled = await loadPluginBundle(packageDir);
|
|
83
|
+
module = bundled.module;
|
|
84
|
+
} else {
|
|
85
|
+
module = await loadModule(pluginJs, import.meta.url);
|
|
86
|
+
}
|
|
62
87
|
rawManifest = getManifest(module, pluginJs);
|
|
63
88
|
} else if (await exists(pluginJson)) {
|
|
64
89
|
manifestPath = pluginJson;
|
|
@@ -88,6 +113,12 @@ async function loadPluginPackage(packageDir) {
|
|
|
88
113
|
|
|
89
114
|
const routesDir = path.join(packageDir, 'routes');
|
|
90
115
|
const jobsDir = path.join(packageDir, 'jobs');
|
|
116
|
+
if (bundle && !bundled) bundled = await loadPluginBundle(packageDir);
|
|
117
|
+
const exportsModule = bundled
|
|
118
|
+
? bundled.exportsModule
|
|
119
|
+
: (await exists(exportsJs))
|
|
120
|
+
? await loadModule(exportsJs, import.meta.url)
|
|
121
|
+
: null;
|
|
91
122
|
|
|
92
123
|
return {
|
|
93
124
|
id: manifest.id,
|
|
@@ -95,15 +126,23 @@ async function loadPluginPackage(packageDir) {
|
|
|
95
126
|
packageJson,
|
|
96
127
|
manifest,
|
|
97
128
|
module,
|
|
129
|
+
bundle: bundled ? { artifact: bundled.artifact } : null,
|
|
98
130
|
manifestPath,
|
|
131
|
+
exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
|
|
99
132
|
routesDir: (await exists(routesDir)) ? routesDir : null,
|
|
100
133
|
jobsDir: (await exists(jobsDir)) ? jobsDir : null,
|
|
101
|
-
|
|
102
|
-
|
|
134
|
+
bundledRoutes: bundled?.routes ?? null,
|
|
135
|
+
bundledJobs: bundled?.jobs ?? null,
|
|
136
|
+
capabilities:
|
|
137
|
+
bundled?.capabilities ??
|
|
138
|
+
(await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability')),
|
|
139
|
+
hooks:
|
|
140
|
+
bundled?.hooks ??
|
|
141
|
+
(await loadConventionModules(path.join(packageDir, 'hooks'), 'lifecycle hook')),
|
|
103
142
|
};
|
|
104
143
|
}
|
|
105
144
|
|
|
106
|
-
async function discoverPlugins(dirs) {
|
|
145
|
+
async function discoverPlugins(dirs, options = {}) {
|
|
107
146
|
const plugins = [];
|
|
108
147
|
const seen = new Map();
|
|
109
148
|
|
|
@@ -113,7 +152,7 @@ async function discoverPlugins(dirs) {
|
|
|
113
152
|
label: 'plugin',
|
|
114
153
|
});
|
|
115
154
|
for (const entry of packages) {
|
|
116
|
-
const plugin = await loadPluginPackage(entry.dir);
|
|
155
|
+
const plugin = await loadPluginPackage(entry.dir, options);
|
|
117
156
|
if (!plugin) continue;
|
|
118
157
|
if (seen.has(plugin.id)) {
|
|
119
158
|
throw new Error(
|
package/server/plugins/graph.js
CHANGED
|
@@ -18,29 +18,43 @@ function buildProviderMap(plugins) {
|
|
|
18
18
|
function resolvePluginGraph(plugins, enabledIds) {
|
|
19
19
|
const allProviders = buildProviderMap(plugins);
|
|
20
20
|
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
|
21
|
-
const enabled = new Set(enabledIds);
|
|
21
|
+
const enabled = new Set([...enabledIds].map((id) => String(id)));
|
|
22
22
|
const missing = [];
|
|
23
23
|
const edges = new Map();
|
|
24
24
|
|
|
25
25
|
for (const plugin of plugins) {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const pluginId = String(plugin.id);
|
|
27
|
+
edges.set(pluginId, new Set());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const plugin of plugins) {
|
|
31
|
+
const pluginId = String(plugin.id);
|
|
32
|
+
if (!enabled.has(pluginId)) continue;
|
|
28
33
|
for (const capability of requiredCapabilities(plugin.manifest.requires)) {
|
|
29
34
|
const provider = allProviders.get(capability);
|
|
30
35
|
if (!provider) {
|
|
31
|
-
missing.push({ pluginId
|
|
36
|
+
missing.push({ pluginId, capability, reason: 'missing provider' });
|
|
32
37
|
continue;
|
|
33
38
|
}
|
|
34
|
-
|
|
39
|
+
const providerId = String(provider.id);
|
|
40
|
+
if (!enabled.has(providerId)) {
|
|
35
41
|
missing.push({
|
|
36
|
-
pluginId
|
|
42
|
+
pluginId,
|
|
37
43
|
capability,
|
|
38
|
-
providerId
|
|
44
|
+
providerId,
|
|
39
45
|
reason: 'provider disabled',
|
|
40
46
|
});
|
|
41
47
|
continue;
|
|
42
48
|
}
|
|
43
|
-
if (
|
|
49
|
+
if (providerId !== pluginId) {
|
|
50
|
+
const providerEdges = edges.get(providerId);
|
|
51
|
+
if (!providerEdges) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Plugin ${pluginId} requires capability ${capability} from unknown provider ${providerId}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
providerEdges.add(pluginId);
|
|
57
|
+
}
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { buildRoutesFromModules, discoverRoutes } from '../router/routes.js';
|
|
3
|
+
import { buildJobsFromModules, discoverJobs } from '../jobs/runner.js';
|
|
3
4
|
import { CapabilityRegistry } from './capabilities.js';
|
|
4
5
|
import { discoverPlugins } from './discovery.js';
|
|
5
6
|
import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
|
|
@@ -14,7 +15,7 @@ function pluginEnabledByDefault(plugin, enablements) {
|
|
|
14
15
|
if (Object.prototype.hasOwnProperty.call(enablements, plugin.id)) {
|
|
15
16
|
return enablements[plugin.id] === true;
|
|
16
17
|
}
|
|
17
|
-
return plugin.manifest.
|
|
18
|
+
return plugin.manifest.enabled !== false;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function capabilityFactory(plugin, capability) {
|
|
@@ -26,11 +27,64 @@ function capabilityFactory(plugin, capability) {
|
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function collectDependentIds(plugins, ids) {
|
|
31
|
+
const affected = new Set(ids);
|
|
32
|
+
const providers = buildProviderMap(plugins);
|
|
33
|
+
let changed = true;
|
|
34
|
+
|
|
35
|
+
while (changed) {
|
|
36
|
+
changed = false;
|
|
37
|
+
for (const plugin of plugins) {
|
|
38
|
+
if (affected.has(plugin.id)) continue;
|
|
39
|
+
for (const capability of requiredCapabilities(plugin.manifest.requires)) {
|
|
40
|
+
const provider = providers.get(capability);
|
|
41
|
+
if (provider && affected.has(provider.id)) {
|
|
42
|
+
affected.add(plugin.id);
|
|
43
|
+
changed = true;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return affected;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bindPluginExports(plugin, ctx) {
|
|
54
|
+
return new Proxy(
|
|
55
|
+
{},
|
|
56
|
+
{
|
|
57
|
+
get(_target, prop) {
|
|
58
|
+
if (typeof prop === 'symbol') return undefined;
|
|
59
|
+
if (prop === 'then') return undefined;
|
|
60
|
+
|
|
61
|
+
const fn = plugin.exports?.[prop];
|
|
62
|
+
if (!fn) {
|
|
63
|
+
throw new Error(`Plugin ${plugin.id} does not export function ${prop}`);
|
|
64
|
+
}
|
|
65
|
+
return (...args) => fn(ctx, ...args);
|
|
66
|
+
},
|
|
67
|
+
has(_target, prop) {
|
|
68
|
+
return typeof prop === 'string' && Object.hasOwn(plugin.exports ?? {}, prop);
|
|
69
|
+
},
|
|
70
|
+
ownKeys() {
|
|
71
|
+
return Object.keys(plugin.exports ?? {});
|
|
72
|
+
},
|
|
73
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
74
|
+
if (typeof prop !== 'string' || !Object.hasOwn(plugin.exports ?? {}, prop))
|
|
75
|
+
return undefined;
|
|
76
|
+
return { enumerable: true, configurable: true };
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
29
82
|
class PluginManager {
|
|
30
|
-
constructor(config, { log, runLifecycle = true } = {}) {
|
|
83
|
+
constructor(config, { log, runLifecycle = true, bundle = false } = {}) {
|
|
31
84
|
this.config = config ?? { enabled: false, dirs: [] };
|
|
32
85
|
this.log = log;
|
|
33
86
|
this.runLifecycle = runLifecycle;
|
|
87
|
+
this.bundle = bundle;
|
|
34
88
|
this.plugins = [];
|
|
35
89
|
this.enabled = new Set();
|
|
36
90
|
this.status = new Map();
|
|
@@ -41,7 +95,7 @@ class PluginManager {
|
|
|
41
95
|
async init() {
|
|
42
96
|
if (this.config.enabled !== true) return this;
|
|
43
97
|
|
|
44
|
-
this.plugins = await discoverPlugins(this.config.dirs);
|
|
98
|
+
this.plugins = await discoverPlugins(this.config.dirs, { bundle: this.bundle });
|
|
45
99
|
const enablements = configuredEnablements(this.config);
|
|
46
100
|
this.enabled = new Set(
|
|
47
101
|
this.plugins
|
|
@@ -97,17 +151,36 @@ class PluginManager {
|
|
|
97
151
|
return this.capabilities.inject(ctx, requires);
|
|
98
152
|
}
|
|
99
153
|
|
|
154
|
+
exportsFor(id, ctx) {
|
|
155
|
+
const plugin = this.getPlugin(id);
|
|
156
|
+
if (!plugin) throw new Error(`Unknown plugin: ${id}`);
|
|
157
|
+
if (this.status.get(plugin.id) !== 'enabled') {
|
|
158
|
+
throw new Error(`Plugin ${plugin.id} is not enabled`);
|
|
159
|
+
}
|
|
160
|
+
return bindPluginExports(plugin, ctx);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
buildContextProxy(ctx) {
|
|
164
|
+
return {
|
|
165
|
+
get: (id) => this.exportsFor(id, ctx),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
100
169
|
async discoverRoutes() {
|
|
101
170
|
const routes = [];
|
|
102
171
|
for (const plugin of this.enabledPlugins) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
prefix: `/plugins/${plugin.id}`,
|
|
172
|
+
const routeOptions = {
|
|
173
|
+
prefix: `/_plugins/${plugin.id}`,
|
|
106
174
|
plugin: {
|
|
107
175
|
id: plugin.id,
|
|
108
176
|
requires: plugin.manifest.requires,
|
|
109
177
|
},
|
|
110
|
-
}
|
|
178
|
+
};
|
|
179
|
+
const discovered = plugin.bundledRoutes
|
|
180
|
+
? buildRoutesFromModules(plugin.bundledRoutes, routeOptions)
|
|
181
|
+
: plugin.routesDir
|
|
182
|
+
? await discoverRoutes(plugin.routesDir, routeOptions)
|
|
183
|
+
: [];
|
|
111
184
|
routes.push(...discovered);
|
|
112
185
|
}
|
|
113
186
|
return routes;
|
|
@@ -116,8 +189,11 @@ class PluginManager {
|
|
|
116
189
|
async discoverJobs() {
|
|
117
190
|
const jobs = [];
|
|
118
191
|
for (const plugin of this.enabledPlugins) {
|
|
119
|
-
|
|
120
|
-
|
|
192
|
+
const discovered = plugin.bundledJobs
|
|
193
|
+
? buildJobsFromModules(plugin.bundledJobs)
|
|
194
|
+
: plugin.jobsDir
|
|
195
|
+
? await discoverJobs(plugin.jobsDir)
|
|
196
|
+
: [];
|
|
121
197
|
for (const job of discovered) {
|
|
122
198
|
const definition = {
|
|
123
199
|
...job.definition,
|
|
@@ -133,6 +209,115 @@ class PluginManager {
|
|
|
133
209
|
return jobs;
|
|
134
210
|
}
|
|
135
211
|
|
|
212
|
+
async reload(ids) {
|
|
213
|
+
if (this.config.enabled !== true) return { reloaded: [] };
|
|
214
|
+
|
|
215
|
+
const requested = ids ? new Set(Array.isArray(ids) ? ids : [ids]) : null;
|
|
216
|
+
const oldPlugins = this.plugins;
|
|
217
|
+
const oldEnabledIds = new Set(
|
|
218
|
+
oldPlugins
|
|
219
|
+
.filter((plugin) => this.status.get(plugin.id) === 'enabled')
|
|
220
|
+
.map((plugin) => plugin.id),
|
|
221
|
+
);
|
|
222
|
+
let affected = requested ? collectDependentIds(oldPlugins, requested) : new Set();
|
|
223
|
+
|
|
224
|
+
const discovered = await discoverPlugins(this.config.dirs, { bundle: this.bundle });
|
|
225
|
+
const discoveredIds = new Set(discovered.map((plugin) => plugin.id));
|
|
226
|
+
const enablements = configuredEnablements(this.config);
|
|
227
|
+
const nextEnabled = new Set();
|
|
228
|
+
for (const plugin of discovered) {
|
|
229
|
+
if (
|
|
230
|
+
this.enabled.has(plugin.id) ||
|
|
231
|
+
(!oldPlugins.some((old) => old.id === plugin.id) &&
|
|
232
|
+
pluginEnabledByDefault(plugin, enablements))
|
|
233
|
+
) {
|
|
234
|
+
nextEnabled.add(plugin.id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!requested) {
|
|
239
|
+
affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
|
|
240
|
+
} else {
|
|
241
|
+
for (const id of requested) {
|
|
242
|
+
if (!discoveredIds.has(id) && !oldPlugins.some((plugin) => plugin.id === id)) {
|
|
243
|
+
affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
affected = collectDependentIds(discovered, affected);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const oldAffectedEnabled = [...this.loadOrder]
|
|
251
|
+
.filter((plugin) => affected.has(plugin.id) && oldEnabledIds.has(plugin.id))
|
|
252
|
+
.reverse();
|
|
253
|
+
for (const plugin of oldAffectedEnabled) {
|
|
254
|
+
if (this.runLifecycle) await this.#runLifecycle(plugin, 'onDisable');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.plugins = discovered;
|
|
258
|
+
this.enabled = nextEnabled;
|
|
259
|
+
this.#rebuildGraph();
|
|
260
|
+
|
|
261
|
+
for (const plugin of this.loadOrder) {
|
|
262
|
+
if (!affected.has(plugin.id)) continue;
|
|
263
|
+
if (this.runLifecycle) {
|
|
264
|
+
await this.#runLifecycle(plugin, 'onLoad');
|
|
265
|
+
await this.#runLifecycle(plugin, 'onEnable');
|
|
266
|
+
}
|
|
267
|
+
this.log?.info?.(`Plugin reloaded: ${plugin.id}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
reloaded: [...affected].filter((id) => discoveredIds.has(id)),
|
|
272
|
+
removed: [...affected].filter((id) => !discoveredIds.has(id)),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pluginIdsForChangedFiles(events) {
|
|
277
|
+
const ids = new Set();
|
|
278
|
+
for (const event of events) {
|
|
279
|
+
const filePath = event.path;
|
|
280
|
+
const existing = this.plugins.find(
|
|
281
|
+
(plugin) => filePath === plugin.dir || filePath.startsWith(plugin.dir + path.sep),
|
|
282
|
+
);
|
|
283
|
+
if (existing) {
|
|
284
|
+
ids.add(existing.id);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const rootDir = (this.config.dirs ?? []).find((dir) => {
|
|
289
|
+
const relative = path.relative(dir, filePath);
|
|
290
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
291
|
+
});
|
|
292
|
+
if (!rootDir) continue;
|
|
293
|
+
const [packageDirName] = path.relative(rootDir, filePath).split(path.sep);
|
|
294
|
+
if (!packageDirName) continue;
|
|
295
|
+
if (!this.plugins.some((plugin) => plugin.id === packageDirName)) return null;
|
|
296
|
+
ids.add(packageDirName);
|
|
297
|
+
}
|
|
298
|
+
return ids;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
watch(fileWatcher, { apiRouter, jobRunner } = {}) {
|
|
302
|
+
if (this.config.enabled !== true || !fileWatcher) return;
|
|
303
|
+
fileWatcher.subscribe('plugins', {
|
|
304
|
+
dirs: this.config.dirs,
|
|
305
|
+
extensions: ['.js', '.json'],
|
|
306
|
+
debounceMs: 200,
|
|
307
|
+
onChange: async (events) => {
|
|
308
|
+
const changedIds = this.pluginIdsForChangedFiles(events);
|
|
309
|
+
await this.reload(changedIds);
|
|
310
|
+
await apiRouter?.reload?.();
|
|
311
|
+
await jobRunner?.reload?.();
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
this.log?.info?.('Plugins: watching for changes');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
unwatch(fileWatcher) {
|
|
318
|
+
fileWatcher?.unsubscribe?.('plugins');
|
|
319
|
+
}
|
|
320
|
+
|
|
136
321
|
#rebuildGraph() {
|
|
137
322
|
const providers = buildProviderMap(this.plugins);
|
|
138
323
|
for (const plugin of this.plugins) {
|