arcway 0.1.27 → 0.1.28

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.
@@ -0,0 +1,149 @@
1
+ import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
2
+
3
+ const MASTER_VERSION = 'v1';
4
+ const MASTER_BYTES = 32;
5
+ const VAULT_KEY_BYTES = 32;
6
+ const HKDF_SALT = 'arcway:infra-secrets:v1';
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
+ ]);
16
+
17
+ function base64Url(buffer) {
18
+ return Buffer.from(buffer).toString('base64url');
19
+ }
20
+
21
+ function encodeMasterSecret(key = randomBytes(MASTER_BYTES)) {
22
+ if (!Buffer.isBuffer(key) || key.length !== MASTER_BYTES) {
23
+ throw new Error(`Arcway master secret must be ${MASTER_BYTES} bytes`);
24
+ }
25
+ return `${MASTER_VERSION}:${key.toString('base64')}`;
26
+ }
27
+
28
+ function decodeVersionedSecret(value, label = 'Arcway master secret') {
29
+ const trimmed = String(value ?? '').trim();
30
+ const [version, encoded] = trimmed.split(':');
31
+ if (version !== MASTER_VERSION || !encoded || trimmed.split(':').length !== 2) {
32
+ throw new Error(
33
+ `${label} must use ${MASTER_VERSION}:<base64-32-byte-key> or a 32+ character string`,
34
+ );
35
+ }
36
+
37
+ const key = Buffer.from(encoded, 'base64');
38
+ if (key.length !== MASTER_BYTES || key.toString('base64') !== encoded) {
39
+ throw new Error(`${label} must decode to ${MASTER_BYTES} bytes`);
40
+ }
41
+ return key;
42
+ }
43
+
44
+ function normalizeMasterSecret(value, label = 'Arcway master secret') {
45
+ if (Buffer.isBuffer(value)) {
46
+ if (value.length < MASTER_BYTES)
47
+ throw new Error(`${label} must be at least ${MASTER_BYTES} bytes`);
48
+ return value;
49
+ }
50
+
51
+ if (typeof value !== 'string') {
52
+ throw new Error(`${label} must be a string or Buffer`);
53
+ }
54
+
55
+ const trimmed = value.trim();
56
+ if (trimmed.startsWith(`${MASTER_VERSION}:`)) return decodeVersionedSecret(trimmed, label);
57
+ if (trimmed.length < MASTER_BYTES) {
58
+ throw new Error(`${label} must be at least ${MASTER_BYTES} characters`);
59
+ }
60
+ return Buffer.from(trimmed, 'utf8');
61
+ }
62
+
63
+ function deriveBytes(master, namespace, length = MASTER_BYTES) {
64
+ const key = normalizeMasterSecret(master);
65
+ return Buffer.from(hkdfSync('sha256', key, HKDF_SALT, `arcway:${namespace}`, length));
66
+ }
67
+
68
+ function formatVaultKey(bytes) {
69
+ const key = Buffer.from(bytes);
70
+ if (key.length !== VAULT_KEY_BYTES) {
71
+ throw new Error(`Vault key material must be ${VAULT_KEY_BYTES} bytes`);
72
+ }
73
+ return `${MASTER_VERSION}:${key.toString('base64')}`;
74
+ }
75
+
76
+ function validateVaultKey(value, label = 'plugin-vault secret') {
77
+ return formatVaultKey(decodeVersionedSecret(value, label));
78
+ }
79
+
80
+ function deriveVapidKeyPair(master) {
81
+ for (let i = 0; i < 32; i += 1) {
82
+ const privateKey = deriveBytes(master, `vapid:private:${i}`, 32);
83
+ try {
84
+ const ecdh = createECDH('prime256v1');
85
+ ecdh.setPrivateKey(privateKey);
86
+ return {
87
+ publicKey: base64Url(ecdh.getPublicKey(null, 'uncompressed')),
88
+ privateKey: base64Url(privateKey),
89
+ };
90
+ } catch {
91
+ // Try the next deterministic candidate.
92
+ }
93
+ }
94
+ throw new Error('Unable to derive a valid deterministic VAPID keypair');
95
+ }
96
+
97
+ function deriveInfraSecret(master, name) {
98
+ if (name === 'session') return base64Url(deriveBytes(master, name, 48));
99
+ if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
100
+ if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
101
+ if (name === 'pty-api') return base64Url(deriveBytes(master, name, 48));
102
+ if (name === 'pty-server-api') return base64Url(deriveBytes(master, name, 48));
103
+ if (name === 'vapid') return deriveVapidKeyPair(master);
104
+ throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
105
+ }
106
+
107
+ function deriveInfraSecrets(master) {
108
+ const values = {};
109
+ for (const name of SECRET_NAMES) {
110
+ values[name] = deriveInfraSecret(master, name);
111
+ }
112
+ return values;
113
+ }
114
+
115
+ function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt, onRotate } = {}) {
116
+ if (typeof decrypt !== 'function') throw new Error('decryptWithRotation requires decrypt');
117
+ if (typeof encrypt !== 'function') throw new Error('decryptWithRotation requires encrypt');
118
+ if (!current) throw new Error('decryptWithRotation requires current secret');
119
+
120
+ try {
121
+ return { plaintext: decrypt(ciphertext, current), rotated: false };
122
+ } catch (currentError) {
123
+ const previousValues = Array.isArray(previous)
124
+ ? previous.filter(Boolean)
125
+ : [previous].filter(Boolean);
126
+ for (const previousSecret of previousValues) {
127
+ try {
128
+ const plaintext = decrypt(ciphertext, previousSecret);
129
+ const rotatedCiphertext = encrypt(plaintext, current);
130
+ onRotate?.(rotatedCiphertext);
131
+ return { plaintext, rotated: true, rotatedCiphertext };
132
+ } catch {}
133
+ }
134
+ throw currentError;
135
+ }
136
+ }
137
+
138
+ export {
139
+ SECRET_NAMES,
140
+ decryptWithRotation,
141
+ deriveBytes,
142
+ deriveInfraSecret,
143
+ deriveInfraSecrets,
144
+ deriveVapidKeyPair,
145
+ encodeMasterSecret,
146
+ formatVaultKey,
147
+ normalizeMasterSecret,
148
+ validateVaultKey,
149
+ };
@@ -0,0 +1,66 @@
1
+ import { requiredCapabilities } from './manifest.js';
2
+
3
+ class CapabilityNotAvailable extends Error {
4
+ constructor(capability, reason = 'missing or disabled') {
5
+ super(`Capability not available: ${capability} (${reason})`);
6
+ this.name = 'CapabilityNotAvailable';
7
+ this.capability = capability;
8
+ this.reason = reason;
9
+ }
10
+ }
11
+
12
+ class CapabilityRegistry {
13
+ #providers = new Map();
14
+
15
+ register(pluginId, capability, factory) {
16
+ if (typeof factory !== 'function') {
17
+ throw new Error(
18
+ `Capability ${capability} from plugin ${pluginId} must be registered with a factory`,
19
+ );
20
+ }
21
+ if (this.#providers.has(capability)) {
22
+ const existing = this.#providers.get(capability);
23
+ throw new Error(
24
+ `Capability ${capability} is already provided by plugin ${existing.pluginId}`,
25
+ );
26
+ }
27
+ this.#providers.set(capability, { pluginId, factory, enabled: true });
28
+ }
29
+
30
+ setPluginEnabled(pluginId, enabled) {
31
+ for (const provider of this.#providers.values()) {
32
+ if (provider.pluginId === pluginId) provider.enabled = enabled;
33
+ }
34
+ }
35
+
36
+ has(capability) {
37
+ const provider = this.#providers.get(capability);
38
+ return provider?.enabled === true;
39
+ }
40
+
41
+ inject(ctx, requires) {
42
+ for (const capability of requiredCapabilities(requires)) {
43
+ const provider = this.#providers.get(capability);
44
+ if (!provider) throw new CapabilityNotAvailable(capability, 'missing provider');
45
+ if (!provider.enabled) throw new CapabilityNotAvailable(capability, 'provider disabled');
46
+ ctx[capability] = provider.factory({
47
+ capability,
48
+ providerPluginId: provider.pluginId,
49
+ req: ctx.req,
50
+ session: ctx.req?.session,
51
+ ctx,
52
+ });
53
+ }
54
+ return ctx;
55
+ }
56
+
57
+ list() {
58
+ return [...this.#providers.entries()].map(([capability, provider]) => ({
59
+ capability,
60
+ pluginId: provider.pluginId,
61
+ enabled: provider.enabled,
62
+ }));
63
+ }
64
+ }
65
+
66
+ export { CapabilityNotAvailable, CapabilityRegistry };
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { discoverPackageDirectories } from '../discovery.js';
4
+ import { loadModule } from '../module-loader.js';
5
+ import { validateManifest } from './manifest.js';
6
+
7
+ async function exists(filePath) {
8
+ try {
9
+ await fs.access(filePath);
10
+ return true;
11
+ } catch (err) {
12
+ if (err.code === 'ENOENT') return false;
13
+ throw err;
14
+ }
15
+ }
16
+
17
+ async function readJson(filePath, label) {
18
+ try {
19
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
20
+ } catch (err) {
21
+ throw new Error(`Failed to read ${label} at ${filePath}: ${err}`);
22
+ }
23
+ }
24
+
25
+ function getManifest(module, manifestPath) {
26
+ const manifest = module.manifest ?? module.default?.manifest ?? module.default;
27
+ if (!manifest || typeof manifest !== 'object') {
28
+ throw new Error(`Plugin manifest at ${manifestPath} must export a manifest object`);
29
+ }
30
+ return manifest;
31
+ }
32
+
33
+ async function loadPluginPackage(packageDir) {
34
+ const packageJsonPath = path.join(packageDir, 'package.json');
35
+ if (!(await exists(packageJsonPath))) {
36
+ throw new Error(`Plugin package at ${packageDir} must include package.json`);
37
+ }
38
+ const packageJson = await readJson(packageJsonPath, 'package.json');
39
+
40
+ const pluginJs = path.join(packageDir, 'plugin.js');
41
+ const pluginJson = path.join(packageDir, 'plugin.json');
42
+ let manifestPath;
43
+ let module = {};
44
+ let rawManifest;
45
+
46
+ if (await exists(pluginJs)) {
47
+ manifestPath = pluginJs;
48
+ module = await loadModule(pluginJs, import.meta.url);
49
+ rawManifest = getManifest(module, pluginJs);
50
+ } else if (await exists(pluginJson)) {
51
+ manifestPath = pluginJson;
52
+ rawManifest = await readJson(pluginJson, 'plugin.json');
53
+ } else {
54
+ throw new Error(`Plugin package at ${packageDir} must include plugin.js or plugin.json`);
55
+ }
56
+
57
+ const manifest = validateManifest(
58
+ {
59
+ version: packageJson.version,
60
+ name: packageJson.name,
61
+ ...rawManifest,
62
+ },
63
+ { packageName: packageJson.name },
64
+ );
65
+
66
+ const routesDir = path.join(packageDir, 'routes');
67
+ const jobsDir = path.join(packageDir, 'jobs');
68
+ const migrationsDir =
69
+ typeof manifest.contributes.migrations === 'string'
70
+ ? path.join(packageDir, manifest.contributes.migrations)
71
+ : path.join(packageDir, 'migrations');
72
+
73
+ return {
74
+ id: manifest.id,
75
+ dir: packageDir,
76
+ packageJson,
77
+ manifest,
78
+ module,
79
+ manifestPath,
80
+ routesDir: (await exists(routesDir)) ? routesDir : null,
81
+ jobsDir: (await exists(jobsDir)) ? jobsDir : null,
82
+ migrationsDir: (await exists(migrationsDir)) ? migrationsDir : null,
83
+ };
84
+ }
85
+
86
+ async function discoverPlugins(dirs) {
87
+ const plugins = [];
88
+ const seen = new Map();
89
+
90
+ for (const dir of dirs ?? []) {
91
+ const packages = await discoverPackageDirectories(dir, {
92
+ markerFiles: ['plugin.js', 'plugin.json'],
93
+ label: 'plugin',
94
+ });
95
+ for (const entry of packages) {
96
+ const plugin = await loadPluginPackage(entry.dir);
97
+ if (seen.has(plugin.id)) {
98
+ throw new Error(
99
+ `Duplicate plugin id ${plugin.id} in ${seen.get(plugin.id)} and ${plugin.dir}`,
100
+ );
101
+ }
102
+ seen.set(plugin.id, plugin.dir);
103
+ plugins.push(plugin);
104
+ }
105
+ }
106
+
107
+ plugins.sort((a, b) => a.id.localeCompare(b.id));
108
+ return plugins;
109
+ }
110
+
111
+ export { discoverPlugins, loadPluginPackage };
@@ -0,0 +1,114 @@
1
+ import { requiredCapabilities } from './manifest.js';
2
+
3
+ function buildProviderMap(plugins) {
4
+ const providers = new Map();
5
+ for (const plugin of plugins) {
6
+ for (const capability of plugin.manifest.provides) {
7
+ if (providers.has(capability)) {
8
+ throw new Error(
9
+ `Capability ${capability} is provided by both ${providers.get(capability).id} and ${plugin.id}`,
10
+ );
11
+ }
12
+ providers.set(capability, plugin);
13
+ }
14
+ }
15
+ return providers;
16
+ }
17
+
18
+ function resolvePluginGraph(plugins, enabledIds) {
19
+ const allProviders = buildProviderMap(plugins);
20
+ const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
21
+ const enabled = new Set(enabledIds);
22
+ const missing = [];
23
+ const edges = new Map();
24
+
25
+ for (const plugin of plugins) {
26
+ edges.set(plugin.id, new Set());
27
+ if (!enabled.has(plugin.id)) continue;
28
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
29
+ const provider = allProviders.get(capability);
30
+ if (!provider) {
31
+ missing.push({ pluginId: plugin.id, capability, reason: 'missing provider' });
32
+ continue;
33
+ }
34
+ if (!enabled.has(provider.id)) {
35
+ missing.push({
36
+ pluginId: plugin.id,
37
+ capability,
38
+ providerId: provider.id,
39
+ reason: 'provider disabled',
40
+ });
41
+ continue;
42
+ }
43
+ if (provider.id !== plugin.id) edges.get(provider.id).add(plugin.id);
44
+ }
45
+ }
46
+
47
+ if (missing.length > 0) {
48
+ const first = missing[0];
49
+ const provider = first.providerId ? ` from ${first.providerId}` : '';
50
+ throw new Error(
51
+ `Plugin ${first.pluginId} requires capability ${first.capability}${provider}: ${first.reason}`,
52
+ );
53
+ }
54
+
55
+ const indegree = new Map([...enabled].map((id) => [id, 0]));
56
+ for (const [providerId, dependents] of edges) {
57
+ if (!enabled.has(providerId)) continue;
58
+ for (const dependentId of dependents) {
59
+ if (enabled.has(dependentId)) indegree.set(dependentId, indegree.get(dependentId) + 1);
60
+ }
61
+ }
62
+
63
+ const ready = [...indegree.entries()]
64
+ .filter(([, degree]) => degree === 0)
65
+ .map(([id]) => id)
66
+ .sort();
67
+ const orderedIds = [];
68
+
69
+ while (ready.length > 0) {
70
+ const id = ready.shift();
71
+ orderedIds.push(id);
72
+ for (const dependentId of [...(edges.get(id) ?? [])].sort()) {
73
+ if (!enabled.has(dependentId)) continue;
74
+ indegree.set(dependentId, indegree.get(dependentId) - 1);
75
+ if (indegree.get(dependentId) === 0) ready.push(dependentId);
76
+ }
77
+ ready.sort();
78
+ }
79
+
80
+ if (orderedIds.length !== enabled.size) {
81
+ throw new Error('Plugin capability graph contains a cycle');
82
+ }
83
+
84
+ return {
85
+ providers: allProviders,
86
+ order: orderedIds.map((id) => byId.get(id)),
87
+ edges,
88
+ };
89
+ }
90
+
91
+ function cascadeDisabled(plugins, disabledIds) {
92
+ const providers = buildProviderMap(plugins);
93
+ const disabled = new Map([...disabledIds].map((id) => [id, 'disabled']));
94
+ let changed = true;
95
+
96
+ while (changed) {
97
+ changed = false;
98
+ for (const plugin of plugins) {
99
+ if (disabled.has(plugin.id)) continue;
100
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
101
+ const provider = providers.get(capability);
102
+ if (!provider || disabled.has(provider.id)) {
103
+ disabled.set(plugin.id, `disabled (missing dependency: ${capability})`);
104
+ changed = true;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ return disabled;
112
+ }
113
+
114
+ export { buildProviderMap, cascadeDisabled, resolvePluginGraph };
@@ -0,0 +1,210 @@
1
+ import { discoverRoutes } from '../router/routes.js';
2
+ import { discoverJobs } from '../jobs/runner.js';
3
+ import { CapabilityRegistry } from './capabilities.js';
4
+ import { discoverPlugins } from './discovery.js';
5
+ import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
6
+ import { requiredCapabilities } from './manifest.js';
7
+
8
+ function configuredEnablements(config) {
9
+ const enablements = config?.enablements ?? config?.enabledPlugins ?? {};
10
+ return enablements && typeof enablements === 'object' ? enablements : {};
11
+ }
12
+
13
+ function pluginEnabledByDefault(plugin, enablements) {
14
+ if (Object.prototype.hasOwnProperty.call(enablements, plugin.id)) {
15
+ return enablements[plugin.id] === true;
16
+ }
17
+ return plugin.manifest.defaultEnabled === true;
18
+ }
19
+
20
+ function capabilityFactory(plugin, capability) {
21
+ return (
22
+ plugin.module.capabilities?.[capability] ??
23
+ plugin.module.default?.capabilities?.[capability] ??
24
+ plugin.module[`create${capability[0]?.toUpperCase() ?? ''}${capability.slice(1)}`]
25
+ );
26
+ }
27
+
28
+ class PluginManager {
29
+ constructor(config, { log, runLifecycle = true } = {}) {
30
+ this.config = config ?? { enabled: false, dirs: [] };
31
+ this.log = log;
32
+ this.runLifecycle = runLifecycle;
33
+ this.plugins = [];
34
+ this.enabled = new Set();
35
+ this.status = new Map();
36
+ this.loadOrder = [];
37
+ this.capabilities = new CapabilityRegistry();
38
+ }
39
+
40
+ async init() {
41
+ if (this.config.enabled !== true) return this;
42
+
43
+ this.plugins = await discoverPlugins(this.config.dirs);
44
+ const enablements = configuredEnablements(this.config);
45
+ this.enabled = new Set(
46
+ this.plugins
47
+ .filter((plugin) => pluginEnabledByDefault(plugin, enablements))
48
+ .map((plugin) => plugin.id),
49
+ );
50
+
51
+ this.#rebuildGraph();
52
+
53
+ for (const plugin of this.loadOrder) {
54
+ if (this.runLifecycle) {
55
+ await this.#runLifecycle(plugin, 'onLoad');
56
+ await this.#runLifecycle(plugin, 'onEnable');
57
+ }
58
+ this.log?.info?.(`Plugin enabled: ${plugin.id}`);
59
+ }
60
+ return this;
61
+ }
62
+
63
+ get enabledPlugins() {
64
+ return this.loadOrder;
65
+ }
66
+
67
+ getPlugin(id) {
68
+ return this.plugins.find((plugin) => plugin.id === id) ?? null;
69
+ }
70
+
71
+ async enable(id) {
72
+ const plugin = this.getPlugin(id);
73
+ if (!plugin) throw new Error(`Unknown plugin: ${id}`);
74
+ this.enabled.add(id);
75
+ this.#rebuildGraph();
76
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onEnable');
77
+ }
78
+
79
+ async runEnabledLifecycle() {
80
+ for (const plugin of this.loadOrder) {
81
+ await this.#runLifecycle(plugin, 'onLoad');
82
+ await this.#runLifecycle(plugin, 'onEnable');
83
+ }
84
+ this.runLifecycle = true;
85
+ }
86
+
87
+ async disable(id) {
88
+ const plugin = this.getPlugin(id);
89
+ if (!plugin) throw new Error(`Unknown plugin: ${id}`);
90
+ this.enabled.delete(id);
91
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onDisable');
92
+ this.#rebuildGraph();
93
+ }
94
+
95
+ injectCapabilities(ctx, requires) {
96
+ return this.capabilities.inject(ctx, requires);
97
+ }
98
+
99
+ async discoverRoutes() {
100
+ const routes = [];
101
+ for (const plugin of this.enabledPlugins) {
102
+ if (!plugin.routesDir) continue;
103
+ const discovered = await discoverRoutes(plugin.routesDir, {
104
+ prefix: `/plugins/${plugin.id}`,
105
+ plugin: {
106
+ id: plugin.id,
107
+ requires: plugin.manifest.requires,
108
+ },
109
+ });
110
+ routes.push(...discovered);
111
+ }
112
+ return routes;
113
+ }
114
+
115
+ async discoverJobs() {
116
+ const jobs = [];
117
+ for (const plugin of this.enabledPlugins) {
118
+ if (!plugin.jobsDir) continue;
119
+ const discovered = await discoverJobs(plugin.jobsDir);
120
+ for (const job of discovered) {
121
+ const definition = {
122
+ ...job.definition,
123
+ name: `plugins/${plugin.id}/${job.definition.name}`,
124
+ plugin: {
125
+ id: plugin.id,
126
+ requires: plugin.manifest.requires,
127
+ },
128
+ };
129
+ jobs.push({ ...job, definition, filePath: null });
130
+ }
131
+ }
132
+ return jobs;
133
+ }
134
+
135
+ migrations() {
136
+ return this.enabledPlugins
137
+ .filter((plugin) => plugin.migrationsDir)
138
+ .map((plugin) => ({
139
+ pluginId: plugin.id,
140
+ dir: plugin.migrationsDir,
141
+ contributes: plugin.manifest.contributes.migrations,
142
+ }));
143
+ }
144
+
145
+ #rebuildGraph() {
146
+ const providers = buildProviderMap(this.plugins);
147
+ for (const plugin of this.plugins) {
148
+ if (!this.enabled.has(plugin.id)) continue;
149
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
150
+ if (!providers.has(capability)) {
151
+ throw new Error(
152
+ `Plugin ${plugin.id} requires capability ${capability}: missing provider`,
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ const disabled = cascadeDisabled(
159
+ this.plugins,
160
+ this.plugins.filter((plugin) => !this.enabled.has(plugin.id)).map((plugin) => plugin.id),
161
+ );
162
+ this.status = new Map();
163
+ for (const plugin of this.plugins) {
164
+ this.status.set(plugin.id, disabled.get(plugin.id) ?? 'enabled');
165
+ }
166
+
167
+ const effectiveEnabled = new Set(
168
+ this.plugins
169
+ .filter((plugin) => this.status.get(plugin.id) === 'enabled')
170
+ .map((plugin) => plugin.id),
171
+ );
172
+ const graph = resolvePluginGraph(this.plugins, effectiveEnabled);
173
+ this.loadOrder = graph.order;
174
+
175
+ this.capabilities = new CapabilityRegistry();
176
+ for (const plugin of this.loadOrder) {
177
+ for (const capability of plugin.manifest.provides) {
178
+ const factory = capabilityFactory(plugin, capability);
179
+ if (!factory) {
180
+ throw new Error(
181
+ `Plugin ${plugin.id} provides ${capability} but does not export a factory`,
182
+ );
183
+ }
184
+ this.capabilities.register(plugin.id, capability, factory);
185
+ }
186
+ }
187
+ for (const plugin of this.plugins) {
188
+ this.capabilities.setPluginEnabled(plugin.id, this.status.get(plugin.id) === 'enabled');
189
+ }
190
+ }
191
+
192
+ async #runLifecycle(plugin, name) {
193
+ const hook = plugin.manifest.lifecycle?.[name];
194
+ if (!hook) return;
195
+ const fn =
196
+ typeof hook === 'function' ? hook : (plugin.module[hook] ?? plugin.module.default?.[hook]);
197
+ if (typeof fn !== 'function') {
198
+ throw new Error(`Plugin ${plugin.id} lifecycle.${name} does not resolve to a function`);
199
+ }
200
+ await fn({ plugin, manager: this });
201
+ }
202
+ }
203
+
204
+ async function createPluginManager(config, options) {
205
+ const manager = new PluginManager(config, options);
206
+ await manager.init();
207
+ return manager;
208
+ }
209
+
210
+ export { PluginManager, createPluginManager };