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.
@@ -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 deriveVapidKeyPair(master) {
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, `vapid:private:${i}`, 32);
86
+ const privateKey = deriveBytes(master, `keypair:${curve}:${label}:private:${i}`, 32);
83
87
  try {
84
- const ecdh = createECDH('prime256v1');
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('Unable to derive a valid deterministic VAPID keypair');
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 === '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);
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
- module = await loadModule(pluginJs, import.meta.url);
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
- capabilities: await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability'),
102
- hooks: await loadConventionModules(path.join(packageDir, 'hooks'), 'lifecycle hook'),
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(
@@ -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
- edges.set(plugin.id, new Set());
27
- if (!enabled.has(plugin.id)) continue;
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: plugin.id, capability, reason: 'missing provider' });
36
+ missing.push({ pluginId, capability, reason: 'missing provider' });
32
37
  continue;
33
38
  }
34
- if (!enabled.has(provider.id)) {
39
+ const providerId = String(provider.id);
40
+ if (!enabled.has(providerId)) {
35
41
  missing.push({
36
- pluginId: plugin.id,
42
+ pluginId,
37
43
  capability,
38
- providerId: provider.id,
44
+ providerId,
39
45
  reason: 'provider disabled',
40
46
  });
41
47
  continue;
42
48
  }
43
- if (provider.id !== plugin.id) edges.get(provider.id).add(plugin.id);
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 { discoverRoutes } from '../router/routes.js';
2
- import { discoverJobs } from '../jobs/runner.js';
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.defaultEnabled === true;
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
- if (!plugin.routesDir) continue;
104
- const discovered = await discoverRoutes(plugin.routesDir, {
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
- if (!plugin.jobsDir) continue;
120
- const discovered = await discoverJobs(plugin.jobsDir);
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) {