wizards 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +3 -0
  2. package/apps/runtime/dist/agents/architect.js +6 -4
  3. package/apps/runtime/dist/app.js +2 -0
  4. package/apps/runtime/dist/authoring/guide.js +10 -2
  5. package/apps/runtime/dist/cli/environment.js +5 -0
  6. package/apps/runtime/dist/cli/home.js +1 -0
  7. package/apps/runtime/dist/db/client.js +13 -0
  8. package/apps/runtime/dist/engine/runner.js +3 -0
  9. package/apps/runtime/dist/env.js +18 -4
  10. package/apps/runtime/dist/index.js +3 -0
  11. package/apps/runtime/dist/mcp/tools.js +2 -1
  12. package/apps/runtime/dist/plugins/discovery.js +144 -0
  13. package/apps/runtime/dist/plugins/events.js +40 -0
  14. package/apps/runtime/dist/plugins/loader.js +277 -0
  15. package/apps/runtime/dist/plugins/migrations.js +69 -0
  16. package/apps/runtime/dist/plugins/registry.js +105 -0
  17. package/apps/runtime/dist/routes/plugins.js +160 -0
  18. package/apps/runtime/dist/services/wizards.js +29 -3
  19. package/apps/runtime/dist/tools/index.js +2 -0
  20. package/apps/runtime/dist/tools/plugin.js +43 -0
  21. package/apps/web/dist/assets/ChangelogDialog-COqPzrKT.js +1 -0
  22. package/apps/web/dist/assets/{ChatPanel-DZhZJZCP.js → ChatPanel-aQP8iz10.js} +1 -1
  23. package/apps/web/dist/assets/CreditsDialog-pRbTW7Xh.js +1 -0
  24. package/apps/web/dist/assets/EditorPage-xkq6pvw-.js +2 -0
  25. package/apps/web/dist/assets/FlowPreview-CDmcN1oX.js +1 -0
  26. package/apps/web/dist/assets/HomePage-DUAiS9pg.js +1 -0
  27. package/apps/web/dist/assets/ImportWizard-pzljVvcr.js +1 -0
  28. package/apps/web/dist/assets/{LocalRuntime-DKqEzptp.js → LocalRuntime-DPJMJlWc.js} +2 -2
  29. package/apps/web/dist/assets/NewWizardPage-TwYgdbKl.js +2 -0
  30. package/apps/web/dist/assets/PublicRunner-DoZdJuyW.js +1 -0
  31. package/apps/web/dist/assets/RunnerView-XKosfgO-.js +2 -0
  32. package/apps/web/dist/assets/SettingsPage-C0_peJuV.js +2 -0
  33. package/apps/web/dist/assets/SetupPage-BFQ0V__v.js +1 -0
  34. package/apps/web/dist/assets/SharePage-C1yg5bc2.js +1 -0
  35. package/apps/web/dist/assets/api-DijQGkp-.js +10 -0
  36. package/apps/web/dist/assets/{arrow-left-UpjOBfQp.js → arrow-left-hTN1VoH1.js} +1 -1
  37. package/apps/web/dist/assets/{arrow-right-CucQycEy.js → arrow-right-6FyN3e7D.js} +1 -1
  38. package/apps/web/dist/assets/changelog-sa4SXBrg.js +1 -0
  39. package/apps/web/dist/assets/check-CUpby_J9.js +1 -0
  40. package/apps/web/dist/assets/{createLucideIcon-DBu2Ovxj.js → createLucideIcon-q3MZMmnf.js} +1 -1
  41. package/apps/web/dist/assets/definition-DndaH5Ow.js +55 -0
  42. package/apps/web/dist/assets/engenty-Cm8yZLON.js +1 -0
  43. package/apps/web/dist/assets/esm-rJY59Ixx.js +7 -0
  44. package/apps/web/dist/assets/{external-link-CRg6DVdO.js → external-link-MAL7vJYj.js} +1 -1
  45. package/apps/web/dist/assets/{fluffy-engenty-CJ3wCQgT.js → fluffy-engenty-D7rFrNcJ.js} +2 -2
  46. package/apps/web/dist/assets/index-BAObuqzs.js +10 -0
  47. package/apps/web/dist/assets/index-BOl3401k.css +1 -0
  48. package/apps/web/dist/assets/jsx-runtime-D3jfb0Ew.js +1 -0
  49. package/apps/web/dist/assets/{meta-2YMHa2hT.js → meta-ijlGGCOr.js} +1 -1
  50. package/apps/web/dist/assets/{mic-CeqQ89Dj.js → mic-Bvz4mA2j.js} +1 -1
  51. package/apps/web/dist/assets/{oss-credits-cPuZUFHD.js → oss-credits-ClwxOPrf.js} +1 -1
  52. package/apps/web/dist/assets/{outputs-CHe6KRLc.js → outputs-37WFkpRS.js} +2 -2
  53. package/apps/web/dist/assets/{plus-HA210wXA.js → plus-Du-Jx8aI.js} +1 -1
  54. package/apps/web/dist/assets/react-dom-DenslgJm.js +1 -0
  55. package/apps/web/dist/assets/session-BerOchdC.js +1 -0
  56. package/apps/web/dist/assets/{type-BzYgVWKL.js → type-c_dt5Ne8.js} +1 -1
  57. package/apps/web/dist/assets/ui-DKc92qJ1.js +1 -0
  58. package/apps/web/dist/assets/{upload-DsgLfQ3U.js → upload--J5bZhEM.js} +1 -1
  59. package/apps/web/dist/assets/{video-Dk1fuTDl.js → video-CjgmvRDk.js} +1 -1
  60. package/apps/web/dist/assets/{wand-sparkles-Bf97fs8T.js → wand-sparkles-CKxArWPY.js} +1 -1
  61. package/apps/web/dist/index.html +9 -7
  62. package/modules/README.md +15 -0
  63. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +144 -0
  64. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.js +4 -0
  65. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +80 -0
  66. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.js +4 -0
  67. package/node_modules/@engenty-wizards/plugin-sdk/package.json +10 -0
  68. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +18 -6
  69. package/node_modules/@engenty-wizards/shared/dist/definition.js +22 -1
  70. package/package.json +5 -1
  71. package/apps/web/dist/assets/ChangelogDialog-DURK75KD.js +0 -1
  72. package/apps/web/dist/assets/CreditsDialog-CLUOVBLG.js +0 -1
  73. package/apps/web/dist/assets/EditorPage-PGCEQdiC.js +0 -2
  74. package/apps/web/dist/assets/FlowPreview-CqJUNW13.js +0 -1
  75. package/apps/web/dist/assets/HomePage-CRkDMNM2.js +0 -1
  76. package/apps/web/dist/assets/ImportWizard-DX1YtFPv.js +0 -1
  77. package/apps/web/dist/assets/NewWizardPage-BPJrYi2m.js +0 -2
  78. package/apps/web/dist/assets/PublicRunner-B3T244Ry.js +0 -1
  79. package/apps/web/dist/assets/RunnerView-DgYaOHIH.js +0 -2
  80. package/apps/web/dist/assets/SettingsPage-LBVqEwXs.js +0 -2
  81. package/apps/web/dist/assets/SetupPage-DAyJs-hK.js +0 -1
  82. package/apps/web/dist/assets/SharePage-oKGavxJt.js +0 -1
  83. package/apps/web/dist/assets/api-DbPn6Dge.js +0 -10
  84. package/apps/web/dist/assets/changelog-BJkYErYt.js +0 -1
  85. package/apps/web/dist/assets/check-Bjx41xbB.js +0 -1
  86. package/apps/web/dist/assets/definition-cy7uvY5L.js +0 -55
  87. package/apps/web/dist/assets/engenty-CWIFpgKq.js +0 -1
  88. package/apps/web/dist/assets/esm-ZHEGGS6s.js +0 -7
  89. package/apps/web/dist/assets/index-BRy5sz4a.js +0 -10
  90. package/apps/web/dist/assets/index-CQ_q9iMO.css +0 -1
  91. package/apps/web/dist/assets/jsx-runtime-Dk72oS4N.js +0 -1
  92. package/apps/web/dist/assets/ui-tO4ZXbfh.js +0 -1
@@ -0,0 +1,277 @@
1
+ import { existsSync, mkdirSync, watch } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { PLUGIN_TOOL_NAME } from "@engenty-wizards/shared/definition";
5
+ import { createJiti } from "jiti";
6
+ import { packageRoot } from "../cli/home.js";
7
+ import { db, onTenantOpen } from "../db/client.js";
8
+ import { env } from "../env.js";
9
+ import { discoverPlugins } from "./discovery.js";
10
+ import { migrateOpenTenants, migratePlugins } from "./migrations.js";
11
+ import { announceChange, compileRoute, dropPlugin, emptyRecord, loadedPlugin, loadedPlugins, setPlugin, } from "./registry.js";
12
+ /**
13
+ * Loading plugins (docs/plugins.md). A plugin's server half is TypeScript or JavaScript read
14
+ * from its file as it is: no build step, and a reload reads the files again. What a plugin
15
+ * imports of the runtime's own packages comes from the runtime's copies, wherever the plugin's
16
+ * file lies.
17
+ */
18
+ /** What ships with this runtime, then the folders of `PLUGINS_DIR`. */
19
+ export function pluginDirs() {
20
+ return [join(packageRoot, "modules"), ...env.plugins.dirs];
21
+ }
22
+ const own = (specifier) => fileURLToPath(import.meta.resolve(specifier));
23
+ /** The longer name first: an alias also matches what begins with it. */
24
+ function aliases() {
25
+ return {
26
+ "@engenty-wizards/plugin-sdk": own("@engenty-wizards/plugin-sdk"),
27
+ "drizzle-orm/sqlite-core": own("drizzle-orm/sqlite-core"),
28
+ "drizzle-orm": own("drizzle-orm"),
29
+ zod: own("zod"),
30
+ };
31
+ }
32
+ async function importFactory(entry) {
33
+ // No module cache: every load evaluates the plugin's files anew.
34
+ const jiti = createJiti(import.meta.url, { moduleCache: false, alias: aliases() });
35
+ const factory = await jiti.import(entry, { default: true });
36
+ if (typeof factory !== "function") {
37
+ throw new Error("The default export is not a function.");
38
+ }
39
+ return factory;
40
+ }
41
+ function apiFor(record) {
42
+ const { source } = record;
43
+ const tag = `[plugin ${source.id}]`;
44
+ return {
45
+ plugin: source.manifest,
46
+ resolvePath: (relative) => resolve(source.root, relative),
47
+ config: { get: (key) => process.env[key]?.trim() || undefined },
48
+ log: {
49
+ info: (...args) => console.log(tag, ...args),
50
+ warn: (...args) => console.warn(tag, ...args),
51
+ error: (...args) => console.error(tag, ...args),
52
+ },
53
+ server: {
54
+ registerHttpRoute(route) {
55
+ record.routes.push(compileRoute(route));
56
+ },
57
+ registerTool(tool) {
58
+ if (!PLUGIN_TOOL_NAME.test(tool.name)) {
59
+ throw new Error(`Tool "${tool.name}": a name is lower case, digits and "_".`);
60
+ }
61
+ if (record.tools.has(tool.name)) {
62
+ throw new Error(`Tool "${tool.name}" is registered twice.`);
63
+ }
64
+ record.tools.set(tool.name, tool);
65
+ },
66
+ registerMigrations(folder) {
67
+ record.migrations.push(resolve(source.root, folder));
68
+ },
69
+ on(event, listener) {
70
+ const listeners = (record.listeners[event] ?? []);
71
+ listeners.push(listener);
72
+ record.listeners[event] = listeners;
73
+ },
74
+ getTenantDb: () => db,
75
+ onUnload(dispose) {
76
+ record.disposers.push(dispose);
77
+ },
78
+ },
79
+ };
80
+ }
81
+ /** Runs what the plugin asked to have undone, the last first. */
82
+ async function dispose(record) {
83
+ for (const undo of record.disposers.splice(0).reverse()) {
84
+ try {
85
+ await undo();
86
+ }
87
+ catch (err) {
88
+ console.error(`[plugins] ${record.source.id}: undoing failed:`, err);
89
+ }
90
+ }
91
+ }
92
+ async function load(source) {
93
+ const record = emptyRecord(source);
94
+ if (source.entry) {
95
+ try {
96
+ const factory = await importFactory(source.entry);
97
+ await factory(apiFor(record));
98
+ }
99
+ catch (err) {
100
+ // Half a plugin is worse than none: what it registered before it failed goes as well.
101
+ await dispose(record);
102
+ record.routes = [];
103
+ record.tools.clear();
104
+ record.listeners = {};
105
+ record.migrations = [];
106
+ record.error = err?.message || String(err);
107
+ console.error(`[plugins] ${source.id} did not load:`, err);
108
+ }
109
+ }
110
+ setPlugin(record);
111
+ return record;
112
+ }
113
+ async function unload(id) {
114
+ const record = dropPlugin(id);
115
+ if (record) {
116
+ await dispose(record);
117
+ }
118
+ }
119
+ let problems = [];
120
+ /** Files and folders that look like a plugin and are none, as of the last look. */
121
+ export function pluginProblems() {
122
+ return problems;
123
+ }
124
+ function discover() {
125
+ const result = discoverPlugins(pluginDirs());
126
+ problems = result.problems;
127
+ for (const problem of problems) {
128
+ console.error(`[plugins] ${problem.path}: ${problem.message}`);
129
+ }
130
+ return result.found;
131
+ }
132
+ /** One load at a time: the watcher and a person may ask for a reload at the same moment. */
133
+ let queue = Promise.resolve();
134
+ function inTurn(work) {
135
+ const next = queue.catch(() => undefined).then(work);
136
+ queue = next;
137
+ return next;
138
+ }
139
+ let hooked = false;
140
+ /** At start: finds the plugins and loads each. Must run before a tenant database opens. */
141
+ export function loadPlugins() {
142
+ return inTurn(async () => {
143
+ if (!hooked) {
144
+ hooked = true;
145
+ // A plugin whose tables cannot be made must not keep the tenant's database from opening.
146
+ onTenantOpen((tenantId, client) => migratePlugins(tenantId, client).catch((err) => console.error(`[plugins] tables for tenant ${tenantId}:`, err.message)));
147
+ }
148
+ const found = discover();
149
+ for (const source of found) {
150
+ await load(source);
151
+ }
152
+ if (found.length) {
153
+ console.log(`plugins: ${found.map((s) => s.id).join(", ")}`);
154
+ }
155
+ await migrateOpenTenants();
156
+ if (env.plugins.watch) {
157
+ watchPlugins();
158
+ }
159
+ });
160
+ }
161
+ /** Loads one plugin again from its files; a plugin whose files are gone unloads. */
162
+ export function reloadPlugin(id) {
163
+ return inTurn(async () => {
164
+ const found = discover();
165
+ const source = found.find((s) => s.id === id);
166
+ await unload(id);
167
+ const record = source ? await load(source) : null;
168
+ // Its manifest may name another id now: under the old one it is gone, under the new one new.
169
+ await loadMissing(found);
170
+ await migrateOpenTenants();
171
+ announceChange({ id });
172
+ return record;
173
+ });
174
+ }
175
+ /** Loads what was found and is not loaded yet. */
176
+ async function loadMissing(found) {
177
+ const missing = found.filter((source) => !loadedPlugin(source.id));
178
+ for (const source of missing) {
179
+ await load(source);
180
+ }
181
+ return missing.length > 0;
182
+ }
183
+ /**
184
+ * Looks again and leaves what is loaded alone: plugins that are new load, plugins whose files
185
+ * went unload.
186
+ */
187
+ export function findNewPlugins() {
188
+ return inTurn(async () => {
189
+ const found = discover();
190
+ const gone = loadedPlugins().filter((p) => !found.some((s) => s.id === p.source.id));
191
+ for (const plugin of gone) {
192
+ await unload(plugin.source.id);
193
+ }
194
+ if ((await loadMissing(found)) || gone.length) {
195
+ await migrateOpenTenants();
196
+ announceChange({ id: null });
197
+ }
198
+ });
199
+ }
200
+ /** Loads every plugin anew, new ones included, and unloads what is gone. */
201
+ export function rescanPlugins() {
202
+ return inTurn(async () => {
203
+ const found = discover();
204
+ for (const loaded of loadedPlugins()) {
205
+ await unload(loaded.source.id);
206
+ }
207
+ for (const source of found) {
208
+ await load(source);
209
+ }
210
+ await migrateOpenTenants();
211
+ announceChange({ id: null });
212
+ });
213
+ }
214
+ /** The studio half was built again: the server half stays as it is, the studio fetches the files. */
215
+ function refreshStudioHalf(id) {
216
+ return inTurn(async () => {
217
+ const record = loadedPlugin(id);
218
+ const source = discover().find((s) => s.id === id);
219
+ if (record && source) {
220
+ record.source = { ...record.source, studio: source.studio, styles: source.styles };
221
+ announceChange({ id });
222
+ }
223
+ });
224
+ }
225
+ const watchers = [];
226
+ /**
227
+ * Reloads a plugin when one of its files changes. `ui/` is left alone: it only counts once it
228
+ * is built, and a change below `dist/` is that build.
229
+ */
230
+ function watchPlugins() {
231
+ for (const watcher of watchers.splice(0)) {
232
+ watcher.close();
233
+ }
234
+ const timers = new Map();
235
+ const soon = (key, run) => {
236
+ clearTimeout(timers.get(key));
237
+ timers.set(key, setTimeout(() => {
238
+ timers.delete(key);
239
+ run().catch((err) => console.error("[plugins]", err));
240
+ }, 150));
241
+ };
242
+ for (const dir of pluginDirs()) {
243
+ // A folder of this install's own plugins is made, so the first plugin put there is seen.
244
+ if (env.plugins.dirs.includes(dir)) {
245
+ try {
246
+ mkdirSync(dir, { recursive: true });
247
+ }
248
+ catch {
249
+ // a place that cannot be written to
250
+ }
251
+ }
252
+ if (!existsSync(dir)) {
253
+ continue;
254
+ }
255
+ const watcher = watch(dir, { recursive: true }, (_event, file) => {
256
+ const parts = file?.toString().split(sep) ?? [];
257
+ if (!parts.length || parts.includes("node_modules") || parts.some((p) => p.startsWith("."))) {
258
+ return;
259
+ }
260
+ const top = join(dir, parts[0]);
261
+ const plugin = loadedPlugins().find((p) => p.source.single ? p.source.entry === top : p.source.root === top);
262
+ const { id, entry } = plugin?.source ?? {};
263
+ if (!id) {
264
+ // A plugin that is new here, or what is left of one that went. The others stay loaded.
265
+ soon("*", findNewPlugins);
266
+ }
267
+ else if (parts[1] === "dist" && !entry?.startsWith(join(top, "dist") + sep)) {
268
+ soon(`${id}/studio`, () => refreshStudioHalf(id));
269
+ }
270
+ else if (parts[1] !== "ui") {
271
+ soon(id, () => reloadPlugin(id));
272
+ }
273
+ });
274
+ watcher.unref();
275
+ watchers.push(watcher);
276
+ }
277
+ }
@@ -0,0 +1,69 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { openTenantClients } from "../db/client.js";
4
+ import { loadedPlugins } from "./registry.js";
5
+ /**
6
+ * A plugin's own tables. Each `.sql` file of the folders a plugin registered runs once per
7
+ * tenant database, in the order of the file names; `plugin_migration` records which ran. A
8
+ * database gets them when it opens, and the open ones get them when a plugin loads. Unloading a
9
+ * plugin leaves its tables and their rows where they are.
10
+ */
11
+ const RECORD = `CREATE TABLE IF NOT EXISTS plugin_migration (
12
+ plugin TEXT NOT NULL,
13
+ name TEXT NOT NULL,
14
+ applied_at INTEGER NOT NULL,
15
+ PRIMARY KEY (plugin, name)
16
+ )`;
17
+ /** One at a time per database: a plugin that loads while a database opens must not run a file twice. */
18
+ const queues = new Map();
19
+ async function apply(client) {
20
+ const pending = loadedPlugins().filter((p) => p.migrations.length);
21
+ if (!pending.length) {
22
+ return;
23
+ }
24
+ await client.execute(RECORD);
25
+ const done = await client.execute("SELECT plugin, name FROM plugin_migration");
26
+ const ran = new Set(done.rows.map((row) => `${row.plugin}/${row.name}`));
27
+ for (const plugin of pending) {
28
+ for (const folder of plugin.migrations) {
29
+ const files = readdirSync(folder)
30
+ .filter((name) => name.endsWith(".sql"))
31
+ .sort();
32
+ for (const name of files) {
33
+ if (ran.has(`${plugin.source.id}/${name}`)) {
34
+ continue;
35
+ }
36
+ // The file and its record go in together or not at all.
37
+ const tx = await client.transaction("write");
38
+ try {
39
+ await tx.executeMultiple(readFileSync(join(folder, name), "utf8"));
40
+ await tx.execute({
41
+ sql: "INSERT INTO plugin_migration (plugin, name, applied_at) VALUES (?, ?, ?)",
42
+ args: [plugin.source.id, name, Date.now()],
43
+ });
44
+ await tx.commit();
45
+ }
46
+ catch (err) {
47
+ await tx.rollback().catch(() => undefined);
48
+ throw new Error(`Plugin "${plugin.source.id}", ${name}: ${err.message}`);
49
+ }
50
+ finally {
51
+ tx.close();
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ export function migratePlugins(tenantId, client) {
58
+ const next = (queues.get(tenantId) ?? Promise.resolve())
59
+ .catch(() => undefined)
60
+ .then(() => apply(client));
61
+ queues.set(tenantId, next);
62
+ return next;
63
+ }
64
+ /** After a plugin loaded: its tables for every database that is open already. */
65
+ export async function migrateOpenTenants() {
66
+ for (const [tenantId, client] of await openTenantClients()) {
67
+ await migratePlugins(tenantId, client).catch((err) => console.error(`[plugins] tables for tenant ${tenantId}:`, err.message));
68
+ }
69
+ }
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { readFileSync } from "node:fs";
4
+ import { env } from "../env.js";
5
+ import { managed, tenantInfo } from "../manage.js";
6
+ const plugins = new Map();
7
+ let generations = 0;
8
+ export function nextGeneration() {
9
+ return ++generations;
10
+ }
11
+ export function emptyRecord(source) {
12
+ return {
13
+ source,
14
+ generation: nextGeneration(),
15
+ error: null,
16
+ routes: [],
17
+ tools: new Map(),
18
+ listeners: {},
19
+ migrations: [],
20
+ disposers: [],
21
+ };
22
+ }
23
+ export function setPlugin(record) {
24
+ plugins.set(record.source.id, record);
25
+ }
26
+ export function dropPlugin(id) {
27
+ const record = plugins.get(id);
28
+ plugins.delete(id);
29
+ return record;
30
+ }
31
+ export function loadedPlugin(id) {
32
+ return plugins.get(id);
33
+ }
34
+ export function loadedPlugins() {
35
+ return [...plugins.values()];
36
+ }
37
+ /** `/items/:id` as a pattern for the address below the plugin's own. */
38
+ export function compileRoute(route) {
39
+ const params = [];
40
+ const path = route.path.startsWith("/") ? route.path : `/${route.path}`;
41
+ const source = path
42
+ .replace(/\/+$/, "")
43
+ .split("/")
44
+ .map((part) => {
45
+ if (part.startsWith(":")) {
46
+ params.push(part.slice(1));
47
+ return "([^/]+)";
48
+ }
49
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ })
51
+ .join("/");
52
+ return { route, pattern: new RegExp(`^${source}/?$`), params };
53
+ }
54
+ // --- which tenant has which plugin -----------------------------------------------------
55
+ /**
56
+ * The ids of the plugins a tenant has. A runtime that runs alone has every plugin it loaded.
57
+ * Managed, a tenant has the ones in `PLUGINS_DEFAULT` and the ones the Manage-App lists for it
58
+ * (`modules` of `GET /v1/tenants/:id`).
59
+ */
60
+ export async function pluginIdsOf(tenantId) {
61
+ if (!managed) {
62
+ return new Set(plugins.keys());
63
+ }
64
+ const listed = await tenantInfo(tenantId)
65
+ .then((info) => info.modules ?? [])
66
+ .catch(() => []);
67
+ return new Set([...env.plugins.defaults, ...listed].filter((id) => plugins.has(id)));
68
+ }
69
+ export async function pluginsOf(tenantId) {
70
+ const ids = await pluginIdsOf(tenantId);
71
+ return loadedPlugins().filter((p) => ids.has(p.source.id));
72
+ }
73
+ /** The tools the tenant's plugins add, by the id a step lists them under. */
74
+ export async function pluginToolsOf(tenantId) {
75
+ return (await pluginsOf(tenantId)).flatMap((plugin) => [...plugin.tools.values()].map((tool) => ({
76
+ id: `${plugin.source.id}.${tool.name}`,
77
+ title: tool.title ?? tool.name,
78
+ description: tool.description,
79
+ })));
80
+ }
81
+ /** The built studio half as it is on disk now; null when the plugin has none. */
82
+ export function pluginAsset(record, kind) {
83
+ const file = record.source[kind];
84
+ if (!file) {
85
+ return null;
86
+ }
87
+ try {
88
+ return {
89
+ file,
90
+ rev: createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 12),
91
+ };
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ const changes = new EventEmitter();
98
+ changes.setMaxListeners(0);
99
+ export function announceChange(change) {
100
+ changes.emit("change", change);
101
+ }
102
+ export function onPluginChange(listener) {
103
+ changes.on("change", listener);
104
+ return () => changes.off("change", listener);
105
+ }
@@ -0,0 +1,160 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { Hono } from "hono";
3
+ import { streamSSE } from "hono/streaming";
4
+ import { managed } from "../manage.js";
5
+ import { pluginProblems, reloadPlugin, rescanPlugins } from "../plugins/loader.js";
6
+ import { loadedPlugin, onPluginChange, pluginAsset, pluginIdsOf, pluginsOf, } from "../plugins/registry.js";
7
+ const admin = (user) => user.role === "owner" || user.role === "admin";
8
+ /** Plugins change while the app runs only where it runs alone: a reload would hit every tenant. */
9
+ const canReload = (user) => !managed && admin(user);
10
+ function view(plugin) {
11
+ const script = pluginAsset(plugin, "studio");
12
+ const styles = pluginAsset(plugin, "styles");
13
+ const { id, name, version, description } = plugin.source.manifest;
14
+ const address = (file, rev) => `/api/studio/plugins/-/assets/${id}/${file}?rev=${rev}`;
15
+ return {
16
+ id,
17
+ name,
18
+ version,
19
+ description: description ?? "",
20
+ generation: plugin.generation,
21
+ /** Why the server half did not load; the plugin then adds nothing on the server. */
22
+ error: plugin.error,
23
+ script: script ? address("client.js", script.rev) : null,
24
+ styles: styles ? address("client.css", styles.rev) : null,
25
+ tools: [...plugin.tools.values()].map((tool) => ({
26
+ id: `${id}.${tool.name}`,
27
+ title: tool.title ?? tool.name,
28
+ })),
29
+ };
30
+ }
31
+ /** A thrown error that says how to answer: `{ status, code }`, as the runtime's own errors do. */
32
+ function answerOf(err) {
33
+ const e = err;
34
+ return typeof e?.status === "number" &&
35
+ e.status >= 400 &&
36
+ e.status < 600 &&
37
+ typeof e.code === "string"
38
+ ? { status: e.status, body: { error: String(e.message ?? e.code), code: e.code } }
39
+ : null;
40
+ }
41
+ async function dispatch(c) {
42
+ const user = c.get("user");
43
+ const id = c.req.param("id") ?? "";
44
+ const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
45
+ if (!plugin) {
46
+ return c.json({ error: "not found", code: "not_found" }, 404);
47
+ }
48
+ const url = new URL(c.req.url);
49
+ const path = url.pathname.slice(url.pathname.indexOf(`/plugins/${id}`) + `/plugins/${id}`.length);
50
+ for (const { route, pattern, params } of plugin.routes) {
51
+ const match = route.method === c.req.method ? pattern.exec(path) : null;
52
+ if (!match) {
53
+ continue;
54
+ }
55
+ if (route.role === "admin" && !admin(user)) {
56
+ return c.json({ error: "forbidden", code: "forbidden" }, 403);
57
+ }
58
+ const request = {
59
+ request: c.req.raw,
60
+ path: path || "/",
61
+ params: Object.fromEntries(params.map((name, i) => [name, decodeURIComponent(match[i + 1])])),
62
+ query: url.searchParams,
63
+ user: {
64
+ id: user.id,
65
+ tenantId: user.tenantId,
66
+ role: user.role,
67
+ name: user.name,
68
+ email: user.email,
69
+ },
70
+ json: async () => {
71
+ const text = await c.req.text();
72
+ return (text ? JSON.parse(text) : {});
73
+ },
74
+ };
75
+ try {
76
+ const result = await route.handler(request);
77
+ return result instanceof Response ? result : c.json(result ?? null);
78
+ }
79
+ catch (err) {
80
+ const answer = answerOf(err);
81
+ if (answer) {
82
+ return c.json(answer.body, answer.status);
83
+ }
84
+ console.error(`[plugin ${id}] ${route.method} ${path}:`, err);
85
+ return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
86
+ }
87
+ }
88
+ return c.json({ error: "not found", code: "not_found" }, 404);
89
+ }
90
+ const TYPES = {
91
+ "client.js": { kind: "studio", type: "text/javascript; charset=utf-8" },
92
+ "client.css": { kind: "styles", type: "text/css; charset=utf-8" },
93
+ };
94
+ export const pluginRoutes = new Hono()
95
+ .get("/", async (c) => {
96
+ const user = c.get("user");
97
+ return c.json({
98
+ plugins: (await pluginsOf(user.tenantId)).map(view),
99
+ canReload: canReload(user),
100
+ /** What looked like a plugin and is none; for the person who installs them. */
101
+ problems: canReload(user) ? pluginProblems() : [],
102
+ });
103
+ })
104
+ // The built studio half. The address carries the file's revision, so it may be kept for good.
105
+ .get("/-/assets/:id/:file", async (c) => {
106
+ const user = c.get("user");
107
+ const file = c.req.param("file");
108
+ const kind = Object.hasOwn(TYPES, file) ? TYPES[file] : undefined;
109
+ const id = c.req.param("id");
110
+ const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
111
+ const asset = plugin && kind ? pluginAsset(plugin, kind.kind) : null;
112
+ if (!asset || !kind) {
113
+ return c.json({ error: "not found", code: "not_found" }, 404);
114
+ }
115
+ return c.body(new Uint8Array(await readFile(asset.file)), 200, {
116
+ "content-type": kind.type,
117
+ "cache-control": c.req.query("rev") === asset.rev ? "private, max-age=31536000, immutable" : "no-cache",
118
+ });
119
+ })
120
+ // Says when a plugin loaded again, so an open studio takes the new one without a page load.
121
+ .get("/-/events", (c) => {
122
+ if (managed) {
123
+ return c.json({ error: "not found", code: "not_found" }, 404);
124
+ }
125
+ return streamSSE(c, async (stream) => {
126
+ let closed = false;
127
+ const stop = onPluginChange((change) => {
128
+ void stream.writeSSE({ event: "change", data: JSON.stringify(change) });
129
+ });
130
+ stream.onAbort(() => {
131
+ closed = true;
132
+ stop();
133
+ });
134
+ await stream.writeSSE({ event: "ready", data: "{}" });
135
+ while (!closed) {
136
+ await stream.sleep(20_000);
137
+ if (!closed) {
138
+ await stream.writeSSE({ event: "ping", data: "{}" });
139
+ }
140
+ }
141
+ });
142
+ })
143
+ .post("/-/reload", async (c) => {
144
+ if (!canReload(c.get("user"))) {
145
+ return c.json({ error: "forbidden", code: "forbidden" }, 403);
146
+ }
147
+ await rescanPlugins();
148
+ return c.json({ plugins: (await pluginsOf(c.get("user").tenantId)).map(view) });
149
+ })
150
+ .post("/-/reload/:id", async (c) => {
151
+ if (!canReload(c.get("user"))) {
152
+ return c.json({ error: "forbidden", code: "forbidden" }, 403);
153
+ }
154
+ const plugin = await reloadPlugin(c.req.param("id"));
155
+ return plugin
156
+ ? c.json({ plugin: view(plugin) })
157
+ : c.json({ error: "not found", code: "not_found" }, 404);
158
+ })
159
+ .all("/:id", dispatch)
160
+ .all("/:id/*", dispatch);
@@ -1,10 +1,11 @@
1
- import { validateWizard, wizardSchema, } from "@engenty-wizards/shared/definition";
1
+ import { pluginToolOf, validateWizard, wizardSchema, } from "@engenty-wizards/shared/definition";
2
2
  import { and, desc, eq, sql } from "drizzle-orm";
3
3
  import { nanoid } from "nanoid";
4
4
  import { applyOps, OpError } from "../authoring/ops.js";
5
5
  import { listConnectors } from "../connectors/external.js";
6
6
  import { db, schema } from "../db/client.js";
7
7
  import { env } from "../env.js";
8
+ import { pluginToolsOf } from "../plugins/registry.js";
8
9
  import { dropLinks, putLink } from "../tenants/control.js";
9
10
  import { currentTenant } from "../tenants/tenant.js";
10
11
  import { changedSteps, emitDraftChanged } from "./draft-events.js";
@@ -98,9 +99,34 @@ async function connectorIssues(projectId, draft) {
98
99
  }
99
100
  return issues;
100
101
  }
101
- /** Everything wrong with a draft: its structure, its workspace files, its project's connectors. */
102
+ /** Tools of plugins the draft's steps list that this tenant does not have. */
103
+ async function pluginIssues(draft) {
104
+ const issues = [];
105
+ let known = null;
106
+ for (const step of draft.steps) {
107
+ for (const id of step.type === "agent" ? step.tools : []) {
108
+ const named = pluginToolOf(id);
109
+ if (!named) {
110
+ continue;
111
+ }
112
+ known ??= new Set((await pluginToolsOf(currentTenant())).map((tool) => tool.id));
113
+ if (!known.has(id)) {
114
+ issues.push({
115
+ stepId: step.id,
116
+ message: `Step "${step.id}": tool "${id}" is not available here — the plugin "${named.plugin}" is not installed or has no such tool.`,
117
+ });
118
+ }
119
+ }
120
+ }
121
+ return issues;
122
+ }
123
+ /** Everything wrong with a draft: its structure, its workspace files, its project's connectors and plugins. */
102
124
  async function checkDraft(projectId, draft, files) {
103
- return [...validateWizard(draft, files), ...(await connectorIssues(projectId, draft))];
125
+ return [
126
+ ...validateWizard(draft, files),
127
+ ...(await connectorIssues(projectId, draft)),
128
+ ...(await pluginIssues(draft)),
129
+ ];
104
130
  }
105
131
  /** Validator issues of the draft, checked against its workspace and the project's connectors. */
106
132
  export async function draftIssues(w) {
@@ -13,6 +13,7 @@ import { browserTools } from "./browser.js";
13
13
  import { connectorTools } from "./connector.js";
14
14
  import { mailTools } from "./mail.js";
15
15
  import { assertPublicUrl, safeFetch } from "./net-guard.js";
16
+ import { pluginTools } from "./plugin.js";
16
17
  import { projectTools } from "./project.js";
17
18
  import { clip, FileKeeper } from "./shared.js";
18
19
  import { storeTools } from "./store.js";
@@ -95,6 +96,7 @@ export async function buildStepTools(step, ctx, resolved, uploads) {
95
96
  Object.assign(tools, projectTools(ctx));
96
97
  Object.assign(tools, mailTools(step, ctx, files));
97
98
  Object.assign(tools, await connectorTools(step, ctx, files));
99
+ Object.assign(tools, await pluginTools(step, ctx));
98
100
  const sandbox = sandboxCapabilities();
99
101
  if (allowed.has("sandbox") && sandbox) {
100
102
  const timeoutMs = (seconds) => Math.min((seconds ?? 120) * 1000, 600_000);