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
package/README.md CHANGED
@@ -177,6 +177,9 @@ More settings: [.env.example](.env.example).
177
177
  Development server, checks, how it is built, the managed mode:
178
178
  [docs/development.md](docs/development.md).
179
179
 
180
+ Plugins add tools for steps, routes with their own tables and pages in the studio, without
181
+ changing the runtime: [docs/plugins.md](docs/plugins.md).
182
+
180
183
  ## License
181
184
 
182
185
  [FSL-1.1-MIT](LICENSE): use it for yourself and your company, run it, change it — just don't
@@ -3,20 +3,22 @@ import { isTextMime } from "@engenty-wizards/shared/workspace";
3
3
  import { Agent } from "@mastra/core/agent";
4
4
  import { createTool } from "@mastra/core/tools";
5
5
  import { z } from "zod";
6
- import { connectorsLine, exampleWizard, mcpServersLine, PRINCIPLES, SCHEMA_DOC, WIDGET_GUIDE, } from "../authoring/guide.js";
6
+ import { connectorsLine, exampleWizard, mcpServersLine, PRINCIPLES, pluginToolsLine, SCHEMA_DOC, WIDGET_GUIDE, } from "../authoring/guide.js";
7
7
  import { wizardOpSchema } from "../authoring/ops.js";
8
8
  import { importFromRegistry, listConnectors, searchRegistry } from "../connectors/external.js";
9
9
  import { attachTools, costOf, gatewayTools, textModel } from "../models.js";
10
+ import { pluginToolsOf } from "../plugins/registry.js";
10
11
  import { htmlToMarkdown } from "../render/convert.js";
11
12
  import { ServiceError } from "../services/errors.js";
12
13
  import { deleteFile, listFiles, readFileText, writeFile } from "../services/files.js";
13
14
  import { checkDraftWidget } from "../services/widgets.js";
14
15
  import { editWizard, ownedWizard, writeDraft } from "../services/wizards.js";
16
+ import { currentTenant } from "../tenants/tenant.js";
15
17
  import { assertPublicUrl, safeFetch } from "../tools/net-guard.js";
16
18
  import { runScript } from "../widgets/script.js";
17
19
  import { thoughtLine } from "./thought.js";
18
20
  const MAX_STEPS = 45;
19
- function systemPrompt(mcp, connectors) {
21
+ function systemPrompt(mcp, connectors, pluginTools) {
20
22
  return `You design wizards for "engenty wizards": a page-by-page flow an end user walks through, where AI steps research, write, draw images, render video, build documents, dashboards and interactive widgets, or write into other systems.
21
23
 
22
24
  You talk to the ADMIN who builds the wizard. Answer in the admin's language, briefly and warmly. Never mention JSON, ids, schemas, files or templates to them — talk about pages, questions, steps, the widget and results.
@@ -27,7 +29,7 @@ ${WIDGET_GUIDE}
27
29
 
28
30
  ${mcpServersLine(mcp)}
29
31
 
30
- ${connectorsLine(connectors)}
32
+ ${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
31
33
 
32
34
  Example of a complete wizard:
33
35
  \`\`\`json
@@ -341,7 +343,7 @@ export async function runArchitect(input) {
341
343
  // The system prompt is long and identical on every turn: cache it.
342
344
  instructions: {
343
345
  role: "system",
344
- content: systemPrompt(input.mcpServers, input.connectors),
346
+ content: systemPrompt(input.mcpServers, input.connectors, await pluginToolsOf(currentTenant())),
345
347
  providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
346
348
  },
347
349
  model: resolved.model,
@@ -18,6 +18,7 @@ import { PLUGIN_NAME, pluginArchive, pluginMarketplace } from "./plugin.js";
18
18
  import { apiRoutes } from "./routes/api.js";
19
19
  import { internalRoutes } from "./routes/internal.js";
20
20
  import { marketplaceStudio } from "./routes/marketplace.js";
21
+ import { pluginRoutes } from "./routes/plugins.js";
21
22
  import { connectCallback, publicRoutes, runRoutes, shareRoutes } from "./routes/runs.js";
22
23
  import { studio } from "./routes/studio.js";
23
24
  import { wizardStream } from "./routes/wizard-stream.js";
@@ -99,6 +100,7 @@ app.on(["GET", "POST", "DELETE"], "/api/mcp", (c) => mcp(c.req.raw));
99
100
  app.on(["GET", "POST", "DELETE"], "/api/mcp/bridge/:token", (c) => bridgeRequest(c.req.param("token"), c.req.raw));
100
101
  app.route("/api/studio/wizards", wizardStream);
101
102
  app.route("/api/studio/marketplace", marketplaceStudio);
103
+ app.route("/api/studio/plugins", pluginRoutes);
102
104
  app.route("/api/studio", studio);
103
105
  app.route("/api/v1", apiRoutes);
104
106
  app.route("/api/internal", internalRoutes);
@@ -218,6 +218,14 @@ export function connectorsLine(connectors) {
218
218
  });
219
219
  return `Connectors a wizard of this project can use (built in, or imported from the integrations registry; find more with find_connectors + import_connector):\n${lines.join("\n")}`;
220
220
  }
221
+ /** The tools this app's plugins add; nothing where there are none. */
222
+ export function pluginToolsLine(tools) {
223
+ if (!tools.length) {
224
+ return "";
225
+ }
226
+ const lines = tools.map((t) => `- ${t.id} — ${t.description}`);
227
+ return `\n\nTools of this app's plugins. An agent step lists one in "tools" by its full id, next to the built-in ones; a wizard that uses one runs only where that plugin is installed:\n${lines.join("\n")}`;
228
+ }
221
229
  export function mcpServersLine(mcp) {
222
230
  return `Project MCP servers available to agent steps: ${mcp.length ? mcp.map((m) => `${m.id} (${m.name})`).join(", ") : "none — if the admin wants to write into an external system that needs one, build the step with http or browser, and tell them they can connect a server in the project settings"}.`;
223
231
  }
@@ -225,7 +233,7 @@ export function exampleWizard() {
225
233
  return JSON.stringify(EXAMPLE_WIZARD, null, 2);
226
234
  }
227
235
  /** The guide an MCP client reads before it writes a wizard. */
228
- export function authoringGuide(mcp, connectors = []) {
236
+ export function authoringGuide(mcp, connectors = [], pluginTools = []) {
229
237
  return `engenty wizards: a wizard is a page-by-page flow an end user walks through on a shared link. Pages ask questions; AI steps research, write, draw images, render video, build documents and dashboards, or write into other systems. You author the wizard as JSON; the person who asked you is the ADMIN, the people who later open the link are END USERS.
230
238
 
231
239
  ${SCHEMA_DOC}
@@ -234,7 +242,7 @@ ${WIDGET_GUIDE}
234
242
 
235
243
  ${mcpServersLine(mcp)}
236
244
 
237
- ${connectorsLine(connectors)}
245
+ ${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
238
246
 
239
247
  Models: a step names the KIND of model it needs, never a model. Pick the cheapest class that does the job:
240
248
  - "classifier": routing, yes/no, picking from options, pulling a few values out of text.
@@ -35,6 +35,8 @@ const INHERITED = new Set([
35
35
  "SANDBOX",
36
36
  "SANDBOX_ENABLED",
37
37
  "SANDBOX_IMAGE",
38
+ "PLUGINS_DIR",
39
+ "PLUGINS_WATCH",
38
40
  "ACCOUNT_URL",
39
41
  "ACCOUNT_GATEWAY_URL",
40
42
  "CLOUD_URL",
@@ -67,7 +69,10 @@ export function runtimePath(paths, env, node = process.execPath) {
67
69
  */
68
70
  export function runtimeEnv(paths, start, env = process.env, fileEnv = {}, platform = process.platform) {
69
71
  const secretsGiven = env.SECRETS !== undefined || fileEnv.SECRETS !== undefined;
72
+ const pluginsGiven = env.PLUGINS_DIR !== undefined || fileEnv.PLUGINS_DIR !== undefined;
70
73
  return {
74
+ // Plugins live beside the data, in the install's own folder.
75
+ ...(pluginsGiven ? {} : { PLUGINS_DIR: paths.plugins }),
71
76
  ...inherited(env),
72
77
  PATH: runtimePath(paths, env),
73
78
  NODE_ENV: "production",
@@ -21,6 +21,7 @@ export function layout(home = wizardsHome()) {
21
21
  tools: join(home, "tools"),
22
22
  clients: join(home, "clients"),
23
23
  bin: join(home, "bin"),
24
+ plugins: join(home, "plugins"),
24
25
  };
25
26
  }
26
27
  /** The folder this code was installed to: it holds `apps/`, `plugin/` and package.json. */
@@ -64,8 +64,21 @@ async function openTenant(id) {
64
64
  const db = makeTenantDb(client);
65
65
  // Every database is brought to the current schema when it is first opened.
66
66
  await migrate(db, { migrationsFolder: migrationsFolder("tenant") });
67
+ for (const hook of openHooks) {
68
+ await hook(id, client);
69
+ }
67
70
  return db;
68
71
  }
72
+ const openHooks = [];
73
+ /** Runs for every tenant database right after it was opened and migrated (plugins' own tables). */
74
+ export function onTenantOpen(hook) {
75
+ openHooks.push(hook);
76
+ }
77
+ /** The tenant databases this process has open, once each of them finished opening. */
78
+ export async function openTenantClients() {
79
+ await Promise.allSettled(opened.values());
80
+ return [...clients.entries()];
81
+ }
69
82
  /** The only way to a tenant's tables. Opens the database once per process and migrates it then. */
70
83
  export function tenantDb(id) {
71
84
  let db = opened.get(id);
@@ -10,6 +10,7 @@ import { saveAsset } from "../files/storage.js";
10
10
  import { managed, tenantInfo } from "../manage.js";
11
11
  import { hasFfmpeg } from "../media/ffmpeg.js";
12
12
  import { ModelUnavailableError } from "../models.js";
13
+ import { runEnded } from "../plugins/events.js";
13
14
  import { projectFiles } from "../services/project-files.js";
14
15
  import { projectProfile } from "../services/projects.js";
15
16
  import { listRows, scopeOf } from "../store/index.js";
@@ -112,6 +113,8 @@ async function updateRun(runId, patch) {
112
113
  // A run that ended needs no credits held for it any more.
113
114
  if (patch.status === "done" || patch.status === "failed" || patch.status === "cancelled") {
114
115
  await releaseRun(runId);
116
+ // Plugins hear of it; the run does not wait for them.
117
+ void runEnded(runId, patch.status);
115
118
  }
116
119
  signalChanged(runId);
117
120
  }
@@ -60,6 +60,11 @@ function agentOsInstalled() {
60
60
  return false;
61
61
  }
62
62
  }
63
+ /** A comma-separated list. */
64
+ const list = (key) => str(key)
65
+ .split(",")
66
+ .map((item) => item.trim())
67
+ .filter(Boolean);
63
68
  const num = (key, fallback) => {
64
69
  const v = Number(process.env[key]);
65
70
  return Number.isFinite(v) && v > 0 ? v : fallback;
@@ -99,10 +104,7 @@ export const env = {
99
104
  */
100
105
  signedOutUrl: str("SIGNED_OUT_URL"),
101
106
  /** More host names this server answers under, besides APP_URL's and the loopback names. */
102
- allowedHosts: str("ALLOWED_HOSTS")
103
- .split(",")
104
- .map((h) => h.trim())
105
- .filter(Boolean),
107
+ allowedHosts: list("ALLOWED_HOSTS"),
106
108
  dataDir,
107
109
  /** The control database; empty = a libSQL file in the data folder. */
108
110
  databaseUrl: str("DATABASE_URL", ""),
@@ -190,6 +192,18 @@ export const env = {
190
192
  secretAccessKey: str("S3_SECRET_ACCESS_KEY"),
191
193
  },
192
194
  turnstile: { siteKey: str("TURNSTILE_SITE_KEY"), secret: str("TURNSTILE_SECRET_KEY") },
195
+ /** Plugins (docs/plugins.md). Code in these folders runs with the runtime's own rights. */
196
+ plugins: {
197
+ /** Folders to look in, besides the `modules/` this runtime ships with. */
198
+ dirs: list("PLUGINS_DIR").map((dir) => resolve(dir)),
199
+ /** Managed: plugins every tenant has. The Manage-App switches further ones on per tenant. */
200
+ defaults: list("PLUGINS_DEFAULT"),
201
+ /**
202
+ * A plugin loads again when one of its files changes. Default: on where the runtime runs
203
+ * alone. A runtime of a Manage-App never reloads: it would hit every tenant.
204
+ */
205
+ watch: !manageUrl && str("PLUGINS_WATCH", "1") === "1",
206
+ },
193
207
  limits: {
194
208
  visitorRunsPerHour: num("LIMIT_VISITOR_RUNS_PER_HOUR", 6),
195
209
  defaultDailyRuns: num("LIMIT_DEFAULT_DAILY_RUNS", 50),
@@ -6,6 +6,7 @@ import { resumeInterruptedRuns } from "./engine/runner.js";
6
6
  import { basePath, env } from "./env.js";
7
7
  import { managed } from "./manage.js";
8
8
  import { loadCatalog, loadLocalModels } from "./models.js";
9
+ import { loadPlugins } from "./plugins/loader.js";
9
10
  import { closeBrowser } from "./render/chromium.js";
10
11
  import { clearRunning, writeRunning } from "./running.js";
11
12
  import { syncMarketplace } from "./services/marketplace.js";
@@ -14,6 +15,8 @@ import { purgeUnusedStores } from "./store/index.js";
14
15
  import { LOCAL_TENANT } from "./tenants/tenant.js";
15
16
  import { onRestart } from "./update.js";
16
17
  await migrateControlDb();
18
+ // Before any tenant database opens: each gets the plugins' own tables as it does.
19
+ await loadPlugins();
17
20
  if (managed) {
18
21
  // Every tenant database is brought to this release's schema; a new one migrates when first opened.
19
22
  void migrateAllTenants().then(({ migrated, failed }) => {
@@ -3,6 +3,7 @@ import { authoringGuide } from "../authoring/guide.js";
3
3
  import { wizardOpSchema } from "../authoring/ops.js";
4
4
  import { importFromRegistry, listConnectors, searchRegistry } from "../connectors/external.js";
5
5
  import { RunConflict } from "../engine/runner.js";
6
+ import { pluginToolsOf } from "../plugins/registry.js";
6
7
  import { ServiceError } from "../services/errors.js";
7
8
  import { deleteFile, listFiles, readFileText, writeFile } from "../services/files.js";
8
9
  import { marketplaceWizard, searchMarketplace } from "../services/marketplace.js";
@@ -79,7 +80,7 @@ export function registerTools(server, who) {
79
80
  const project = projectId
80
81
  ? await ownedProject(who.userId, projectId)
81
82
  : await defaultProject(who.userId);
82
- return authoringGuide(project.mcpServers.map((s) => ({ id: s.id, name: s.name })), await listConnectors(project.id));
83
+ return authoringGuide(project.mcpServers.map((s) => ({ id: s.id, name: s.name })), await listConnectors(project.id), await pluginToolsOf(who.tenantId));
83
84
  });
84
85
  // --- connectors ------------------------------------------------------------
85
86
  tool("find_connectors", "wizards:read", {
@@ -0,0 +1,144 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, extname, join, resolve, sep } from "node:path";
3
+ import { PLUGIN_ID } from "@engenty-wizards/shared/definition";
4
+ import { z } from "zod";
5
+ /**
6
+ * Finding plugins. A folder of plugins holds, in any mix:
7
+ *
8
+ * notify.ts one file: the server half, its id is the file's name
9
+ * contacts/engenty.plugin.json a folder with a manifest: `src/plugin.ts`, `dist/client.js`
10
+ * hello/index.ts a folder without one: the server half, its id is the folder's name
11
+ * node_modules/<package> packages installed there that carry a manifest
12
+ */
13
+ export const MANIFEST_FILE = "engenty.plugin.json";
14
+ /** Further fields (an engenty module's `provides`, `capabilities`, …) are kept as they are. */
15
+ const manifestSchema = z.looseObject({
16
+ id: z.string().regex(PLUGIN_ID),
17
+ name: z.string().min(1),
18
+ version: z.string().default("0.0.0"),
19
+ description: z.string().optional(),
20
+ server: z.string().optional(),
21
+ studio: z.string().optional(),
22
+ styles: z.string().optional(),
23
+ });
24
+ /** `.mjs` is left out: Node keeps such a file once imported, so a reload would not read it again. */
25
+ const CODE = new Set([".ts", ".js"]);
26
+ const SERVER_ENTRIES = ["src/plugin.ts", "src/plugin.js", "dist/plugin.js"];
27
+ const INDEX_ENTRIES = ["index.ts", "index.js"];
28
+ const isDir = (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
29
+ const firstExisting = (root, names) => names.map((name) => join(root, name)).find((path) => existsSync(path)) ?? null;
30
+ /** A path of the manifest must stay inside the plugin's folder. */
31
+ function inside(root, relative) {
32
+ const path = resolve(root, relative);
33
+ return path.startsWith(root + sep) && existsSync(path) ? path : null;
34
+ }
35
+ function fromManifest(root) {
36
+ const parsed = manifestSchema.safeParse(JSON.parse(readFileSync(join(root, MANIFEST_FILE), "utf8")));
37
+ if (!parsed.success) {
38
+ throw new Error(`${MANIFEST_FILE}: ${z.prettifyError(parsed.error)}`);
39
+ }
40
+ const manifest = parsed.data;
41
+ const entry = manifest.server
42
+ ? inside(root, manifest.server)
43
+ : firstExisting(root, SERVER_ENTRIES);
44
+ if (manifest.server && !entry) {
45
+ throw new Error(`"server": ${manifest.server} is not a file of the plugin.`);
46
+ }
47
+ return {
48
+ id: manifest.id,
49
+ manifest,
50
+ root,
51
+ entry,
52
+ studio: inside(root, manifest.studio ?? "dist/client.js"),
53
+ styles: inside(root, manifest.styles ?? "dist/client.css"),
54
+ single: false,
55
+ };
56
+ }
57
+ /** A plugin without a manifest: its id is the name of its file or folder. */
58
+ function bare(id, root, entry, single) {
59
+ if (!PLUGIN_ID.test(id)) {
60
+ throw new Error(`"${id}" is no plugin id: lower case, digits and "-", starting with a letter.`);
61
+ }
62
+ return {
63
+ id,
64
+ manifest: { id, name: id, version: "0.0.0" },
65
+ root,
66
+ entry,
67
+ studio: null,
68
+ styles: null,
69
+ single,
70
+ };
71
+ }
72
+ /** Packages below `node_modules` that are plugins: `<name>` and `@scope/<name>`. */
73
+ function packagesIn(nodeModules) {
74
+ const out = [];
75
+ for (const name of readdirSync(nodeModules).sort()) {
76
+ if (name.startsWith(".")) {
77
+ continue;
78
+ }
79
+ const path = join(nodeModules, name);
80
+ const candidates = name.startsWith("@")
81
+ ? readdirSync(path)
82
+ .sort()
83
+ .map((child) => join(path, child))
84
+ : [path];
85
+ out.push(...candidates.filter((dir) => existsSync(join(dir, MANIFEST_FILE))));
86
+ }
87
+ return out;
88
+ }
89
+ function sourceAt(path) {
90
+ if (!isDir(path)) {
91
+ const ext = extname(path);
92
+ return CODE.has(ext) && !path.endsWith(".d.ts")
93
+ ? bare(basename(path, ext), resolve(path, ".."), path, true)
94
+ : null;
95
+ }
96
+ if (existsSync(join(path, MANIFEST_FILE))) {
97
+ return fromManifest(path);
98
+ }
99
+ const index = firstExisting(path, INDEX_ENTRIES);
100
+ return index ? bare(basename(path), path, index, false) : null;
101
+ }
102
+ /** The plugins in these folders. Of two with one id the first found stays. */
103
+ export function discoverPlugins(dirs) {
104
+ const found = new Map();
105
+ const problems = [];
106
+ const take = (path) => {
107
+ try {
108
+ const source = sourceAt(path);
109
+ if (!source) {
110
+ return;
111
+ }
112
+ const first = found.get(source.id);
113
+ if (first) {
114
+ problems.push({
115
+ path,
116
+ message: `Plugin "${source.id}" is already loaded from ${first.root}.`,
117
+ });
118
+ return;
119
+ }
120
+ found.set(source.id, source);
121
+ }
122
+ catch (err) {
123
+ problems.push({ path, message: err.message });
124
+ }
125
+ };
126
+ for (const dir of dirs) {
127
+ if (!isDir(dir)) {
128
+ continue;
129
+ }
130
+ for (const name of readdirSync(dir).sort()) {
131
+ if (name.startsWith(".") || name.startsWith("_")) {
132
+ continue;
133
+ }
134
+ const path = join(dir, name);
135
+ if (name === "node_modules") {
136
+ packagesIn(path).forEach(take);
137
+ }
138
+ else {
139
+ take(path);
140
+ }
141
+ }
142
+ }
143
+ return { found: [...found.values()], problems };
144
+ }
@@ -0,0 +1,40 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { db, schema } from "../db/client.js";
3
+ import { currentTenant } from "../tenants/tenant.js";
4
+ import { pluginsOf } from "./registry.js";
5
+ /**
6
+ * Tells the tenant's plugins that a run reached its end. A plugin cannot fail a run: what a
7
+ * listener throws is logged and nothing more.
8
+ */
9
+ export async function runEnded(runId, status) {
10
+ try {
11
+ const event = `run.${status}`;
12
+ const listening = (await pluginsOf(currentTenant())).filter((p) => p.listeners[event]?.length);
13
+ if (!listening.length) {
14
+ return;
15
+ }
16
+ const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
17
+ const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
18
+ if (!run || !wizard) {
19
+ return;
20
+ }
21
+ const payload = {
22
+ run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
23
+ wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
24
+ values: run.state.values,
25
+ };
26
+ for (const plugin of listening) {
27
+ for (const listener of plugin.listeners[event] ?? []) {
28
+ try {
29
+ await listener(payload);
30
+ }
31
+ catch (err) {
32
+ console.error(`[plugin ${plugin.source.id}] ${event}:`, err);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ catch (err) {
38
+ console.error("[plugins] run event:", err);
39
+ }
40
+ }