wizards 0.1.11 → 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.
Files changed (113) 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 +38 -4
  4. package/apps/runtime/dist/auth/local-ticket.js +8 -5
  5. package/apps/runtime/dist/authoring/guide.js +10 -2
  6. package/apps/runtime/dist/cli/connect.js +531 -0
  7. package/apps/runtime/dist/cli/environment.js +8 -1
  8. package/apps/runtime/dist/cli/home.js +1 -0
  9. package/apps/runtime/dist/cli/main.js +17 -0
  10. package/apps/runtime/dist/cli/mcp.js +175 -0
  11. package/apps/runtime/dist/cli/setup.js +49 -0
  12. package/apps/runtime/dist/db/client.js +13 -0
  13. package/apps/runtime/dist/engine/runner.js +3 -0
  14. package/apps/runtime/dist/env.js +18 -4
  15. package/apps/runtime/dist/index.js +3 -0
  16. package/apps/runtime/dist/mcp/auth.js +26 -0
  17. package/apps/runtime/dist/mcp/flow-app.js +94 -0
  18. package/apps/runtime/dist/mcp/handler.js +15 -5
  19. package/apps/runtime/dist/mcp/seen.js +65 -0
  20. package/apps/runtime/dist/mcp/tools.js +158 -5
  21. package/apps/runtime/dist/plugins/discovery.js +144 -0
  22. package/apps/runtime/dist/plugins/events.js +40 -0
  23. package/apps/runtime/dist/plugins/loader.js +277 -0
  24. package/apps/runtime/dist/plugins/migrations.js +69 -0
  25. package/apps/runtime/dist/plugins/registry.js +105 -0
  26. package/apps/runtime/dist/routes/plugins.js +160 -0
  27. package/apps/runtime/dist/routes/runs.js +7 -3
  28. package/apps/runtime/dist/routes/studio.js +20 -0
  29. package/apps/runtime/dist/secrets/signing.js +21 -3
  30. package/apps/runtime/dist/services/runs.js +190 -28
  31. package/apps/runtime/dist/services/wizards.js +30 -4
  32. package/apps/runtime/dist/tools/index.js +2 -0
  33. package/apps/runtime/dist/tools/plugin.js +43 -0
  34. package/apps/web/dist/assets/ChangelogDialog-Co_9fs61.js +1 -0
  35. package/apps/web/dist/assets/ChatPanel-Br3nYkUc.js +1 -0
  36. package/apps/web/dist/assets/CreditsDialog-D3fZRqXH.js +1 -0
  37. package/apps/web/dist/assets/EditorPage-BSjQC6FE.js +2 -0
  38. package/apps/web/dist/assets/FlowPreview-CjUK5046.js +1 -0
  39. package/apps/web/dist/assets/HomePage-8v4HqIbP.js +1 -0
  40. package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +1 -0
  41. package/apps/web/dist/assets/{LocalRuntime-DKqEzptp.js → LocalRuntime-CTyLGC98.js} +2 -2
  42. package/apps/web/dist/assets/NewWizardPage-BBL2yenw.js +2 -0
  43. package/apps/web/dist/assets/PublicRunner-BwtoEVlE.js +1 -0
  44. package/apps/web/dist/assets/RunnerView-DOizlTad.js +2 -0
  45. package/apps/web/dist/assets/SettingsPage-CBDv04YS.js +2 -0
  46. package/apps/web/dist/assets/SetupPage-CfXTOaIm.js +1 -0
  47. package/apps/web/dist/assets/SharePage-CgByqrAF.js +1 -0
  48. package/apps/web/dist/assets/api-KMi35npD.js +10 -0
  49. package/apps/web/dist/assets/{arrow-left-UpjOBfQp.js → arrow-left-hTN1VoH1.js} +1 -1
  50. package/apps/web/dist/assets/{arrow-right-CucQycEy.js → arrow-right-6FyN3e7D.js} +1 -1
  51. package/apps/web/dist/assets/changelog-03JgixQA.js +1 -0
  52. package/apps/web/dist/assets/check-CUpby_J9.js +1 -0
  53. package/apps/web/dist/assets/{createLucideIcon-DBu2Ovxj.js → createLucideIcon-q3MZMmnf.js} +1 -1
  54. package/apps/web/dist/assets/definition-DndaH5Ow.js +55 -0
  55. package/apps/web/dist/assets/engenty-Cm8yZLON.js +1 -0
  56. package/apps/web/dist/assets/esm-rJY59Ixx.js +7 -0
  57. package/apps/web/dist/assets/{external-link-CRg6DVdO.js → external-link-MAL7vJYj.js} +1 -1
  58. package/apps/web/dist/assets/{fluffy-engenty-CJ3wCQgT.js → fluffy-engenty-D7rFrNcJ.js} +2 -2
  59. package/apps/web/dist/assets/index-B9YVjvVY.css +1 -0
  60. package/apps/web/dist/assets/index-BmpnE7IO.js +10 -0
  61. package/apps/web/dist/assets/jsx-runtime-D3jfb0Ew.js +1 -0
  62. package/apps/web/dist/assets/meta-4R1Lnr25.js +1 -0
  63. package/apps/web/dist/assets/{mic-CeqQ89Dj.js → mic-Bvz4mA2j.js} +1 -1
  64. package/apps/web/dist/assets/oss-credits-roB62QJu.js +1 -0
  65. package/apps/web/dist/assets/{outputs-CHe6KRLc.js → outputs-COE_nvAw.js} +2 -2
  66. package/apps/web/dist/assets/plug-CLjyCoHB.js +1 -0
  67. package/apps/web/dist/assets/{plus-HA210wXA.js → plus-Du-Jx8aI.js} +1 -1
  68. package/apps/web/dist/assets/react-dom-DenslgJm.js +1 -0
  69. package/apps/web/dist/assets/session-B6-6VomQ.js +1 -0
  70. package/apps/web/dist/assets/type-Bj9H3eOK.js +1 -0
  71. package/apps/web/dist/assets/ui-Cs6lpsmj.js +1 -0
  72. package/apps/web/dist/assets/{upload-DsgLfQ3U.js → upload--J5bZhEM.js} +1 -1
  73. package/apps/web/dist/assets/{video-Dk1fuTDl.js → video-CjgmvRDk.js} +1 -1
  74. package/apps/web/dist/assets/{wand-sparkles-Bf97fs8T.js → wand-sparkles-CKxArWPY.js} +1 -1
  75. package/apps/web/dist/index.html +10 -7
  76. package/apps/web/dist/mcp/flow.html +748 -0
  77. package/modules/README.md +15 -0
  78. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +144 -0
  79. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.js +4 -0
  80. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +80 -0
  81. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.js +4 -0
  82. package/node_modules/@engenty-wizards/plugin-sdk/package.json +10 -0
  83. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +18 -6
  84. package/node_modules/@engenty-wizards/shared/dist/definition.js +22 -1
  85. package/package.json +6 -1
  86. package/plugin/.claude-plugin/plugin.json +1 -1
  87. package/plugin/skills/use-wizard/SKILL.md +18 -0
  88. package/apps/web/dist/assets/ChangelogDialog-DURK75KD.js +0 -1
  89. package/apps/web/dist/assets/ChatPanel-DZhZJZCP.js +0 -1
  90. package/apps/web/dist/assets/CreditsDialog-CLUOVBLG.js +0 -1
  91. package/apps/web/dist/assets/EditorPage-PGCEQdiC.js +0 -2
  92. package/apps/web/dist/assets/FlowPreview-CqJUNW13.js +0 -1
  93. package/apps/web/dist/assets/HomePage-CRkDMNM2.js +0 -1
  94. package/apps/web/dist/assets/ImportWizard-DX1YtFPv.js +0 -1
  95. package/apps/web/dist/assets/NewWizardPage-BPJrYi2m.js +0 -2
  96. package/apps/web/dist/assets/PublicRunner-B3T244Ry.js +0 -1
  97. package/apps/web/dist/assets/RunnerView-DgYaOHIH.js +0 -2
  98. package/apps/web/dist/assets/SettingsPage-LBVqEwXs.js +0 -2
  99. package/apps/web/dist/assets/SetupPage-DAyJs-hK.js +0 -1
  100. package/apps/web/dist/assets/SharePage-oKGavxJt.js +0 -1
  101. package/apps/web/dist/assets/api-DbPn6Dge.js +0 -10
  102. package/apps/web/dist/assets/changelog-BJkYErYt.js +0 -1
  103. package/apps/web/dist/assets/check-Bjx41xbB.js +0 -1
  104. package/apps/web/dist/assets/definition-cy7uvY5L.js +0 -55
  105. package/apps/web/dist/assets/engenty-CWIFpgKq.js +0 -1
  106. package/apps/web/dist/assets/esm-ZHEGGS6s.js +0 -7
  107. package/apps/web/dist/assets/index-BRy5sz4a.js +0 -10
  108. package/apps/web/dist/assets/index-CQ_q9iMO.css +0 -1
  109. package/apps/web/dist/assets/jsx-runtime-Dk72oS4N.js +0 -1
  110. package/apps/web/dist/assets/meta-2YMHa2hT.js +0 -1
  111. package/apps/web/dist/assets/oss-credits-cPuZUFHD.js +0 -1
  112. package/apps/web/dist/assets/type-BzYgVWKL.js +0 -1
  113. package/apps/web/dist/assets/ui-tO4ZXbfh.js +0 -1
@@ -3,13 +3,15 @@ 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";
9
10
  import { defaultProject, listProjects, ownedProject } from "../services/projects.js";
10
- import { startTestRun, testRunReport } from "../services/runs.js";
11
+ import { answerRunAsk, answerRunPage, controlRun, reviewRun, runReport, startRun, startTestRun, testRunReport, } from "../services/runs.js";
11
12
  import { checkDraftWidget } from "../services/widgets.js";
12
13
  import { createWizard, editWizard, listWizards, ownedWizard, parseDraft, publishWizard, shareUrl, studioUrl, updateWizardSettings, wizardState, writeDraft, } from "../services/wizards.js";
14
+ import { FLOW_VIEW_KEY, flowAppMeta, flowView } from "./flow-app.js";
13
15
  const definition = z
14
16
  .record(z.string(), z.unknown())
15
17
  .describe("The complete wizard JSON as described by get_authoring_guide.");
@@ -67,6 +69,31 @@ export function registerTools(server, who) {
67
69
  }
68
70
  }));
69
71
  };
72
+ /** A tool whose result also opens the flow widget, in hosts that render MCP Apps. */
73
+ const appTool = (name, scope, config, run) => {
74
+ if (!who.scopes.includes(scope)) {
75
+ return;
76
+ }
77
+ server.registerTool(name, {
78
+ title: config.title,
79
+ description: config.description,
80
+ inputSchema: config.input,
81
+ annotations: { readOnlyHint: config.readOnly ?? false, openWorldHint: false },
82
+ _meta: flowAppMeta,
83
+ }, (async (args) => {
84
+ try {
85
+ const { result, view } = await run(args);
86
+ return {
87
+ ...ok(result),
88
+ structuredContent: result,
89
+ _meta: { [FLOW_VIEW_KEY]: view },
90
+ };
91
+ }
92
+ catch (err) {
93
+ return failure(err);
94
+ }
95
+ }));
96
+ };
70
97
  // --- read ------------------------------------------------------------------
71
98
  tool("get_authoring_guide", "wizards:read", {
72
99
  title: "Authoring guide",
@@ -79,7 +106,7 @@ export function registerTools(server, who) {
79
106
  const project = projectId
80
107
  ? await ownedProject(who.userId, projectId)
81
108
  : await defaultProject(who.userId);
82
- return authoringGuide(project.mcpServers.map((s) => ({ id: s.id, name: s.name })), await listConnectors(project.id));
109
+ return authoringGuide(project.mcpServers.map((s) => ({ id: s.id, name: s.name })), await listConnectors(project.id), await pluginToolsOf(who.tenantId));
83
110
  });
84
111
  // --- connectors ------------------------------------------------------------
85
112
  tool("find_connectors", "wizards:read", {
@@ -274,9 +301,9 @@ Returns the new revision and the issues.`,
274
301
  };
275
302
  });
276
303
  // --- test runs ---------------------------------------------------------------
277
- tool("start_test_run", "runs:test", {
304
+ appTool("start_test_run", "runs:test", {
278
305
  title: "Start test run",
279
- description: "Run the draft once with your answers. SPENDS THE ADMIN'S CREDITS (short copy + image ~15, research ~150, video ~250). Pages are filled from `answers`, reviews accepted when acceptReviews is true. Follow it with get_test_run.",
306
+ description: "Run the draft once with your answers. SPENDS THE ADMIN'S CREDITS (short copy + image ~15, research ~150, video ~250). Pages are filled from `answers`, reviews accepted when acceptReviews is true. Follow it with get_test_run. Shows the flow with the run on it.",
280
307
  input: z.object({
281
308
  wizardId: z.string(),
282
309
  answers: z
@@ -285,7 +312,14 @@ Returns the new revision and the issues.`,
285
312
  .describe("Field id → value, for every page (field ids are unique in a wizard). Items fields take an array of row objects."),
286
313
  acceptReviews: z.boolean().default(true),
287
314
  }),
288
- }, async ({ wizardId, answers, acceptReviews }) => startTestRun(who.userId, wizardId, { answers, acceptReviews }));
315
+ }, async ({ wizardId, answers, acceptReviews }) => {
316
+ const { runId } = await startTestRun(who.userId, wizardId, { answers, acceptReviews });
317
+ const report = await runReport(who.userId, runId, 2, "test");
318
+ return {
319
+ result: report,
320
+ view: await flowView(who.userId, wizardId, { shows: "draft", run: report }),
321
+ };
322
+ });
289
323
  tool("get_test_run", "runs:test", {
290
324
  title: "Get test run",
291
325
  description: "Status, recent events, outputs (text clipped) with signed download links valid one hour, credits spent, and what the run waits for.",
@@ -301,6 +335,125 @@ Returns the new revision and the issues.`,
301
335
  }),
302
336
  readOnly: true,
303
337
  }, async ({ runId, waitSeconds }) => testRunReport(who.userId, runId, waitSeconds));
338
+ // --- using wizards: the flow, and runs of the published version -------------
339
+ appTool("show_wizard", "wizards:read", {
340
+ title: "Show wizard",
341
+ description: "Show a wizard as its flow diagram: its pages, AI steps, reviews and branches. The published version when there is one, else the draft. Also returns the steps as text.",
342
+ input: z.object({
343
+ wizardId: z.string(),
344
+ version: z.enum(["published", "draft"]).optional().describe("Default: published, if any."),
345
+ }),
346
+ readOnly: true,
347
+ }, async ({ wizardId, version }) => {
348
+ const view = await flowView(who.userId, wizardId, { shows: version });
349
+ return {
350
+ result: {
351
+ wizardId,
352
+ title: view.wizard.title,
353
+ shows: view.wizard.shows,
354
+ published: view.wizard.published,
355
+ steps: view.definition.steps.map((s) => ({
356
+ id: s.id,
357
+ type: s.type,
358
+ title: s.title,
359
+ ...(s.type === "page"
360
+ ? { fields: s.fields.map((f) => ({ id: f.id, label: f.label, kind: f.kind })) }
361
+ : {}),
362
+ })),
363
+ },
364
+ view,
365
+ };
366
+ });
367
+ appTool("run_wizard", "runs:test", {
368
+ title: "Run wizard",
369
+ description: `Start a run of a published wizard for the person, here in the chat. Spends credits like any run.
370
+ Start it right away — no show_wizard first: the widget shows the wizard's pages and the person can answer there. What the person already said goes to the first page with answer_page, using the field ids in waitingFor. Then follow waitingFor:
371
+ - page: ask the person for the fields with inChat=true, answer_page; fields with inChat=false (files, recordings, signatures) need the run page at browserUrl
372
+ - review: show the outputs, then review_step
373
+ - ask: answer_ask (allow/skip a change in a connected account) or the run page for a sign-in
374
+ While status is running, get_run with waitSeconds. Hosts with MCP Apps also show the run as a widget the person can answer in.`,
375
+ input: z.object({
376
+ wizardId: z.string(),
377
+ answers: z.record(z.string(), z.unknown()).default({}),
378
+ }),
379
+ }, async ({ wizardId, answers }) => {
380
+ const { runId, refused } = await startRun(who.userId, wizardId, answers);
381
+ const report = await runReport(who.userId, runId, 2);
382
+ return {
383
+ result: { ...report, ...(refused ? { refusedAnswers: refused } : {}) },
384
+ view: await flowView(who.userId, wizardId, { run: report }),
385
+ };
386
+ });
387
+ const waitSeconds = z
388
+ .number()
389
+ .int()
390
+ .min(0)
391
+ .max(45)
392
+ .default(0)
393
+ .describe("Wait up to this long while the run is still working.");
394
+ tool("get_run", "runs:test", {
395
+ title: "Get run",
396
+ description: "A run's status, what it waits for, its outputs (text clipped) with signed download links valid one hour, and the credits it spent.",
397
+ input: z.object({ runId: z.string(), waitSeconds }),
398
+ readOnly: true,
399
+ }, async ({ runId, waitSeconds }) => runReport(who.userId, runId, waitSeconds));
400
+ tool("answer_page", "runs:test", {
401
+ title: "Answer page",
402
+ description: "Fill the page a run waits for: field id → value (items: an array of row objects; toggle: true/false; multiselect: an array). Returns the run, after waitSeconds.",
403
+ input: z.object({
404
+ runId: z.string(),
405
+ stepId: z.string().describe("waitingFor.page"),
406
+ values: z.record(z.string(), z.unknown()),
407
+ waitSeconds,
408
+ }),
409
+ }, async ({ runId, stepId, values, waitSeconds }) => {
410
+ await answerRunPage(who.userId, runId, stepId, values);
411
+ return runReport(who.userId, runId, waitSeconds);
412
+ });
413
+ tool("review_step", "runs:test", {
414
+ title: "Review step",
415
+ description: "Answer the review a run waits for: accept (optionally with edited texts, step id → text, when the review is editable) or regenerate one step it shows, with a note what to change.",
416
+ input: z.object({
417
+ runId: z.string(),
418
+ stepId: z.string().describe("waitingFor.review"),
419
+ action: z.discriminatedUnion("type", [
420
+ z.object({
421
+ type: z.literal("accept"),
422
+ edits: z.record(z.string(), z.string()).optional(),
423
+ }),
424
+ z.object({
425
+ type: z.literal("regenerate"),
426
+ target: z.string().describe("One of waitingFor.show."),
427
+ note: z.string().max(2000),
428
+ }),
429
+ ]),
430
+ waitSeconds,
431
+ }),
432
+ }, async ({ runId, stepId, action, waitSeconds }) => {
433
+ await reviewRun(who.userId, runId, stepId, action);
434
+ return runReport(who.userId, runId, waitSeconds);
435
+ });
436
+ tool("answer_ask", "runs:test", {
437
+ title: "Answer ask",
438
+ description: "A running step asks before it changes something in a connected account (waitingFor.ask, kind confirm): allow it once, or skip it. Ask the person first.",
439
+ input: z.object({
440
+ runId: z.string(),
441
+ askId: z.string(),
442
+ answer: z.enum(["allow", "skip"]),
443
+ waitSeconds,
444
+ }),
445
+ }, async ({ runId, askId, answer, waitSeconds }) => {
446
+ await answerRunAsk(who.userId, runId, askId, answer);
447
+ return runReport(who.userId, runId, waitSeconds);
448
+ });
449
+ tool("control_run", "runs:test", {
450
+ title: "Control run",
451
+ description: "back: to the previous page or review. retry: a failed step again. cancel: stop the run.",
452
+ input: z.object({ runId: z.string(), action: z.enum(["back", "retry", "cancel"]) }),
453
+ }, async ({ runId, action }) => {
454
+ await controlRun(who.userId, runId, action);
455
+ return runReport(who.userId, runId);
456
+ });
304
457
  // --- workspace (widget code, libraries, reference data) --------------------
305
458
  tool("list_files", "wizards:read", {
306
459
  title: "List workspace files",
@@ -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
+ }
@@ -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
+ }