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.
- package/README.md +3 -0
- package/apps/runtime/dist/agents/architect.js +6 -4
- package/apps/runtime/dist/app.js +38 -4
- package/apps/runtime/dist/auth/local-ticket.js +8 -5
- package/apps/runtime/dist/authoring/guide.js +10 -2
- package/apps/runtime/dist/cli/connect.js +531 -0
- package/apps/runtime/dist/cli/environment.js +8 -1
- package/apps/runtime/dist/cli/home.js +1 -0
- package/apps/runtime/dist/cli/main.js +17 -0
- package/apps/runtime/dist/cli/mcp.js +175 -0
- package/apps/runtime/dist/cli/setup.js +49 -0
- package/apps/runtime/dist/db/client.js +13 -0
- package/apps/runtime/dist/engine/runner.js +3 -0
- package/apps/runtime/dist/env.js +18 -4
- package/apps/runtime/dist/index.js +3 -0
- package/apps/runtime/dist/mcp/auth.js +26 -0
- package/apps/runtime/dist/mcp/flow-app.js +94 -0
- package/apps/runtime/dist/mcp/handler.js +15 -5
- package/apps/runtime/dist/mcp/seen.js +65 -0
- package/apps/runtime/dist/mcp/tools.js +158 -5
- package/apps/runtime/dist/plugins/discovery.js +144 -0
- package/apps/runtime/dist/plugins/events.js +40 -0
- package/apps/runtime/dist/plugins/loader.js +277 -0
- package/apps/runtime/dist/plugins/migrations.js +69 -0
- package/apps/runtime/dist/plugins/registry.js +105 -0
- package/apps/runtime/dist/routes/plugins.js +160 -0
- package/apps/runtime/dist/routes/runs.js +7 -3
- package/apps/runtime/dist/routes/studio.js +20 -0
- package/apps/runtime/dist/secrets/signing.js +21 -3
- package/apps/runtime/dist/services/runs.js +190 -28
- package/apps/runtime/dist/services/wizards.js +30 -4
- package/apps/runtime/dist/tools/index.js +2 -0
- package/apps/runtime/dist/tools/plugin.js +43 -0
- package/apps/web/dist/assets/ChangelogDialog-Co_9fs61.js +1 -0
- package/apps/web/dist/assets/ChatPanel-Br3nYkUc.js +1 -0
- package/apps/web/dist/assets/CreditsDialog-D3fZRqXH.js +1 -0
- package/apps/web/dist/assets/EditorPage-BSjQC6FE.js +2 -0
- package/apps/web/dist/assets/FlowPreview-CjUK5046.js +1 -0
- package/apps/web/dist/assets/HomePage-8v4HqIbP.js +1 -0
- package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +1 -0
- package/apps/web/dist/assets/{LocalRuntime-DKqEzptp.js → LocalRuntime-CTyLGC98.js} +2 -2
- package/apps/web/dist/assets/NewWizardPage-BBL2yenw.js +2 -0
- package/apps/web/dist/assets/PublicRunner-BwtoEVlE.js +1 -0
- package/apps/web/dist/assets/RunnerView-DOizlTad.js +2 -0
- package/apps/web/dist/assets/SettingsPage-CBDv04YS.js +2 -0
- package/apps/web/dist/assets/SetupPage-CfXTOaIm.js +1 -0
- package/apps/web/dist/assets/SharePage-CgByqrAF.js +1 -0
- package/apps/web/dist/assets/api-KMi35npD.js +10 -0
- package/apps/web/dist/assets/{arrow-left-UpjOBfQp.js → arrow-left-hTN1VoH1.js} +1 -1
- package/apps/web/dist/assets/{arrow-right-CucQycEy.js → arrow-right-6FyN3e7D.js} +1 -1
- package/apps/web/dist/assets/changelog-03JgixQA.js +1 -0
- package/apps/web/dist/assets/check-CUpby_J9.js +1 -0
- package/apps/web/dist/assets/{createLucideIcon-DBu2Ovxj.js → createLucideIcon-q3MZMmnf.js} +1 -1
- package/apps/web/dist/assets/definition-DndaH5Ow.js +55 -0
- package/apps/web/dist/assets/engenty-Cm8yZLON.js +1 -0
- package/apps/web/dist/assets/esm-rJY59Ixx.js +7 -0
- package/apps/web/dist/assets/{external-link-CRg6DVdO.js → external-link-MAL7vJYj.js} +1 -1
- package/apps/web/dist/assets/{fluffy-engenty-CJ3wCQgT.js → fluffy-engenty-D7rFrNcJ.js} +2 -2
- package/apps/web/dist/assets/index-B9YVjvVY.css +1 -0
- package/apps/web/dist/assets/index-BmpnE7IO.js +10 -0
- package/apps/web/dist/assets/jsx-runtime-D3jfb0Ew.js +1 -0
- package/apps/web/dist/assets/meta-4R1Lnr25.js +1 -0
- package/apps/web/dist/assets/{mic-CeqQ89Dj.js → mic-Bvz4mA2j.js} +1 -1
- package/apps/web/dist/assets/oss-credits-roB62QJu.js +1 -0
- package/apps/web/dist/assets/{outputs-CHe6KRLc.js → outputs-COE_nvAw.js} +2 -2
- package/apps/web/dist/assets/plug-CLjyCoHB.js +1 -0
- package/apps/web/dist/assets/{plus-HA210wXA.js → plus-Du-Jx8aI.js} +1 -1
- package/apps/web/dist/assets/react-dom-DenslgJm.js +1 -0
- package/apps/web/dist/assets/session-B6-6VomQ.js +1 -0
- package/apps/web/dist/assets/type-Bj9H3eOK.js +1 -0
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +1 -0
- package/apps/web/dist/assets/{upload-DsgLfQ3U.js → upload--J5bZhEM.js} +1 -1
- package/apps/web/dist/assets/{video-Dk1fuTDl.js → video-CjgmvRDk.js} +1 -1
- package/apps/web/dist/assets/{wand-sparkles-Bf97fs8T.js → wand-sparkles-CKxArWPY.js} +1 -1
- package/apps/web/dist/index.html +10 -7
- package/apps/web/dist/mcp/flow.html +748 -0
- package/modules/README.md +15 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +144 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.js +4 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +80 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.js +4 -0
- package/node_modules/@engenty-wizards/plugin-sdk/package.json +10 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +18 -6
- package/node_modules/@engenty-wizards/shared/dist/definition.js +22 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/use-wizard/SKILL.md +18 -0
- package/apps/web/dist/assets/ChangelogDialog-DURK75KD.js +0 -1
- package/apps/web/dist/assets/ChatPanel-DZhZJZCP.js +0 -1
- package/apps/web/dist/assets/CreditsDialog-CLUOVBLG.js +0 -1
- package/apps/web/dist/assets/EditorPage-PGCEQdiC.js +0 -2
- package/apps/web/dist/assets/FlowPreview-CqJUNW13.js +0 -1
- package/apps/web/dist/assets/HomePage-CRkDMNM2.js +0 -1
- package/apps/web/dist/assets/ImportWizard-DX1YtFPv.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-BPJrYi2m.js +0 -2
- package/apps/web/dist/assets/PublicRunner-B3T244Ry.js +0 -1
- package/apps/web/dist/assets/RunnerView-DgYaOHIH.js +0 -2
- package/apps/web/dist/assets/SettingsPage-LBVqEwXs.js +0 -2
- package/apps/web/dist/assets/SetupPage-DAyJs-hK.js +0 -1
- package/apps/web/dist/assets/SharePage-oKGavxJt.js +0 -1
- package/apps/web/dist/assets/api-DbPn6Dge.js +0 -10
- package/apps/web/dist/assets/changelog-BJkYErYt.js +0 -1
- package/apps/web/dist/assets/check-Bjx41xbB.js +0 -1
- package/apps/web/dist/assets/definition-cy7uvY5L.js +0 -55
- package/apps/web/dist/assets/engenty-CWIFpgKq.js +0 -1
- package/apps/web/dist/assets/esm-ZHEGGS6s.js +0 -7
- package/apps/web/dist/assets/index-BRy5sz4a.js +0 -10
- package/apps/web/dist/assets/index-CQ_q9iMO.css +0 -1
- package/apps/web/dist/assets/jsx-runtime-Dk72oS4N.js +0 -1
- package/apps/web/dist/assets/meta-2YMHa2hT.js +0 -1
- package/apps/web/dist/assets/oss-credits-cPuZUFHD.js +0 -1
- package/apps/web/dist/assets/type-BzYgVWKL.js +0 -1
- package/apps/web/dist/assets/ui-tO4ZXbfh.js +0 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { LOCAL_CLIENT_HEADER, LOCAL_MCP_HEADER, mintLocalTicket } from "../auth/local-ticket.js";
|
|
6
|
+
import { runningRuntime, settings } from "./start.js";
|
|
7
|
+
/**
|
|
8
|
+
* `wizards mcp`: this install's MCP server over stdio, for AI clients that start their
|
|
9
|
+
* servers as a command — Claude Desktop, Cursor, Codex and the ChatGPT app, VS Code, Windsurf,
|
|
10
|
+
* Gemini CLI, Goose, OpenClaw, LM Studio. Each message goes on to the running runtime's
|
|
11
|
+
* `/api/mcp`, signed with the data folder's secret, so no client's config holds a key and a new
|
|
12
|
+
* port after a restart changes nothing. When nothing runs, the runtime is started.
|
|
13
|
+
*
|
|
14
|
+
* stdout carries only MCP messages; everything else goes to stderr.
|
|
15
|
+
*/
|
|
16
|
+
const START_TIMEOUT_MS = 60_000;
|
|
17
|
+
/** The JSON-RPC messages of a response: a JSON body (one or a batch) or a server-sent stream. */
|
|
18
|
+
export function messagesOf(contentType, body) {
|
|
19
|
+
if (!body.trim()) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
if (contentType.includes("text/event-stream")) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const event of body.split(/\r?\n\r?\n/)) {
|
|
25
|
+
const data = event
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.filter((line) => line.startsWith("data:"))
|
|
28
|
+
.map((line) => line.slice(5).replace(/^ /, ""))
|
|
29
|
+
.join("\n");
|
|
30
|
+
if (data.trim()) {
|
|
31
|
+
out.push(JSON.parse(data));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const parsed = JSON.parse(body);
|
|
37
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
38
|
+
}
|
|
39
|
+
function readSecret(paths, dataDir) {
|
|
40
|
+
const given = process.env.APP_SECRET?.trim() || settings(paths).fileEnv.APP_SECRET?.trim();
|
|
41
|
+
if (given) {
|
|
42
|
+
return given;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(join(dataDir, "secret"), "utf8").trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Starts the runtime in the background, the way `wizards start --no-open` would. */
|
|
52
|
+
function startInBackground(paths) {
|
|
53
|
+
mkdirSync(paths.logs, { recursive: true });
|
|
54
|
+
const log = openSync(join(paths.logs, "runtime.log"), "a");
|
|
55
|
+
const child = spawn(process.execPath, [process.argv[1], "start", "--no-open"], {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: ["ignore", log, log],
|
|
58
|
+
env: process.env,
|
|
59
|
+
});
|
|
60
|
+
child.unref();
|
|
61
|
+
}
|
|
62
|
+
export async function mcp(paths) {
|
|
63
|
+
const { dataDir } = settings(paths);
|
|
64
|
+
let running = null;
|
|
65
|
+
let clientName = "";
|
|
66
|
+
let protocolVersion = "";
|
|
67
|
+
const runtime = () => {
|
|
68
|
+
running ??= (async () => {
|
|
69
|
+
const found = await runningRuntime(dataDir);
|
|
70
|
+
if (found) {
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
process.stderr.write("engenty wizards is not running; starting it …\n");
|
|
74
|
+
startInBackground(paths);
|
|
75
|
+
const began = Date.now();
|
|
76
|
+
while (Date.now() - began < START_TIMEOUT_MS) {
|
|
77
|
+
await new Promise((wait) => setTimeout(wait, 300));
|
|
78
|
+
const started = await runningRuntime(dataDir);
|
|
79
|
+
if (started) {
|
|
80
|
+
return started;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`engenty wizards did not start within a minute; see ${join(paths.logs, "runtime.log")}.`);
|
|
84
|
+
})();
|
|
85
|
+
running.catch(() => {
|
|
86
|
+
running = null;
|
|
87
|
+
});
|
|
88
|
+
return running;
|
|
89
|
+
};
|
|
90
|
+
const post = async (message) => {
|
|
91
|
+
const at = await runtime();
|
|
92
|
+
const secret = readSecret(paths, dataDir);
|
|
93
|
+
if (!secret) {
|
|
94
|
+
throw new Error("The data folder has no secret; start engenty wizards once.");
|
|
95
|
+
}
|
|
96
|
+
const response = await fetch(`http://127.0.0.1:${at.port}/api/mcp`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": "application/json",
|
|
100
|
+
accept: "application/json, text/event-stream",
|
|
101
|
+
[LOCAL_MCP_HEADER]: mintLocalTicket(secret, Date.now(), "local-mcp"),
|
|
102
|
+
...(clientName ? { [LOCAL_CLIENT_HEADER]: clientName } : {}),
|
|
103
|
+
...(protocolVersion ? { "mcp-protocol-version": protocolVersion } : {}),
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify(message),
|
|
106
|
+
});
|
|
107
|
+
const body = await response.text();
|
|
108
|
+
if (!response.ok && response.status !== 202) {
|
|
109
|
+
throw new Error(`engenty wizards answered ${response.status}: ${body.slice(0, 300)}`);
|
|
110
|
+
}
|
|
111
|
+
return messagesOf(response.headers.get("content-type") ?? "", body);
|
|
112
|
+
};
|
|
113
|
+
const write = (message) => {
|
|
114
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
115
|
+
};
|
|
116
|
+
const handle = async (message) => {
|
|
117
|
+
if (message.method === "initialize") {
|
|
118
|
+
const info = message.params?.clientInfo;
|
|
119
|
+
clientName = info?.title || info?.name || "";
|
|
120
|
+
}
|
|
121
|
+
let answers;
|
|
122
|
+
try {
|
|
123
|
+
answers = await post(message);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// The runtime went away (stopped, restarted on another port): once more, with a fresh look.
|
|
127
|
+
running = null;
|
|
128
|
+
try {
|
|
129
|
+
answers = await post(message);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
if (message.id !== undefined && message.method) {
|
|
133
|
+
write({
|
|
134
|
+
jsonrpc: "2.0",
|
|
135
|
+
id: message.id,
|
|
136
|
+
error: { code: -32603, message: error.message },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
process.stderr.write(`${error.message}\n`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const answer of answers) {
|
|
144
|
+
const version = answer.result?.protocolVersion;
|
|
145
|
+
if (message.method === "initialize" && typeof version === "string") {
|
|
146
|
+
protocolVersion = version;
|
|
147
|
+
}
|
|
148
|
+
write(answer);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const pending = new Set();
|
|
152
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY });
|
|
153
|
+
for await (const line of lines) {
|
|
154
|
+
if (!line.trim()) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
let message;
|
|
158
|
+
try {
|
|
159
|
+
message = JSON.parse(line);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
write({
|
|
163
|
+
jsonrpc: "2.0",
|
|
164
|
+
id: null,
|
|
165
|
+
error: { code: -32700, message: "Parse error" },
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Requests are answered as they finish: a long get_run never holds up the next call.
|
|
170
|
+
const job = handle(message).finally(() => pending.delete(job));
|
|
171
|
+
pending.add(job);
|
|
172
|
+
}
|
|
173
|
+
await Promise.all(pending);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
@@ -4,6 +4,7 @@ import { delimiter, join } from "node:path";
|
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
import { autostartState, setAutostart } from "./autostart.js";
|
|
6
6
|
import { installClient, installCommand } from "./clients.js";
|
|
7
|
+
import { appStates, connectApps } from "./connect.js";
|
|
7
8
|
import { runLogged } from "./exec.js";
|
|
8
9
|
import { installedByScript, isCheckout, packageVersion, readInstallNote, writeInstallNote, } from "./home.js";
|
|
9
10
|
import { CLIENTS, detectClients, findChrome, findFfmpeg, findOnPath, suggestedClient, vendorApp, } from "./machine.js";
|
|
@@ -176,6 +177,51 @@ async function autostartStep(paths, interactive) {
|
|
|
176
177
|
p.log.warn(`Starting at login could not be switched on. ${error.message}`);
|
|
177
178
|
}
|
|
178
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The wizards in the person's AI apps (Claude Desktop, Cursor, Codex …): offered for each app on
|
|
182
|
+
* this machine that does not have them yet. Asked, never assumed; `disconnect` undoes it.
|
|
183
|
+
*/
|
|
184
|
+
async function appsStep(paths, interactive) {
|
|
185
|
+
const states = await appStates(paths);
|
|
186
|
+
const connected = states.filter((s) => s.connected);
|
|
187
|
+
const offered = states.filter((s) => s.installed && !s.connected);
|
|
188
|
+
if (connected.length) {
|
|
189
|
+
p.log.success(`In your AI apps: ${connected.map((s) => s.name).join(", ")}`);
|
|
190
|
+
}
|
|
191
|
+
if (!offered.length) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!interactive) {
|
|
195
|
+
p.log.info(dim(`To use your wizards in ${offered.map((s) => s.name).join(", ")}: \`${command()} connect\``));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const answer = await p.multiselect({
|
|
199
|
+
message: "Use your wizards in these AI apps? (space to pick, enter to go on)",
|
|
200
|
+
options: offered.map((s) => ({
|
|
201
|
+
value: s.id,
|
|
202
|
+
label: s.name,
|
|
203
|
+
hint: s.widgets ? "shows runs as a widget" : "as text",
|
|
204
|
+
})),
|
|
205
|
+
initialValues: offered.map((s) => s.id),
|
|
206
|
+
required: false,
|
|
207
|
+
});
|
|
208
|
+
if (cancelled(answer)) {
|
|
209
|
+
return "cancelled";
|
|
210
|
+
}
|
|
211
|
+
if (!answer.length) {
|
|
212
|
+
p.log.info(dim(`Later: \`${command()} connect\``));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const { app, outcome } of await connectApps(paths, answer)) {
|
|
216
|
+
if (outcome.ok) {
|
|
217
|
+
p.log.success(`${app.name}: ${dim(tilde(outcome.message))}`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
p.log.warn(`${app.name}: ${outcome.message}${outcome.snippet ? `\n${outcome.snippet}` : ""}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
p.log.info(dim("Restart those apps to see the wizards in them."));
|
|
224
|
+
}
|
|
179
225
|
/** The line that puts `~/.local/bin` on the PATH, and the shell file it belongs in. */
|
|
180
226
|
function pathLine() {
|
|
181
227
|
const shell = process.env.SHELL ?? "";
|
|
@@ -244,6 +290,9 @@ export async function setup(paths, options) {
|
|
|
244
290
|
if ((await autostartStep(paths, interactive)) === "cancelled") {
|
|
245
291
|
return null;
|
|
246
292
|
}
|
|
293
|
+
if ((await appsStep(paths, interactive)) === "cancelled") {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
247
296
|
if ((await pathStep(interactive)) === "cancelled") {
|
|
248
297
|
return null;
|
|
249
298
|
}
|
|
@@ -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
|
}
|
package/apps/runtime/dist/env.js
CHANGED
|
@@ -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:
|
|
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 }) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { API_KEY_PREFIX, verifyLocalKey } from "../auth/keys.js";
|
|
2
|
+
import { LOCAL_CLIENT_HEADER, LOCAL_MCP_HEADER, localTicketExpiry } from "../auth/local-ticket.js";
|
|
2
3
|
import { env } from "../env.js";
|
|
3
4
|
import { managed, verifyKey, verifyToken } from "../manage.js";
|
|
4
5
|
import { LOCAL_TENANT } from "../tenants/tenant.js";
|
|
@@ -87,8 +88,33 @@ async function tokenAuth(token) {
|
|
|
87
88
|
};
|
|
88
89
|
return authInfoFor(who, token, claims.clientId);
|
|
89
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* A request from `wizards mcp` on this machine: signed with the data folder's secret,
|
|
93
|
+
* which only a runtime alone has. It acts as the admin, like a key from the settings.
|
|
94
|
+
*/
|
|
95
|
+
function localAuth(request, ticket) {
|
|
96
|
+
if (managed || !localTicketExpiry(env.authSecret, ticket, Date.now(), "local-mcp")) {
|
|
97
|
+
return challenge(401, "invalid_token", "This local signature is invalid or expired.");
|
|
98
|
+
}
|
|
99
|
+
const client = request.headers
|
|
100
|
+
.get(LOCAL_CLIENT_HEADER)
|
|
101
|
+
?.replace(/[^\p{L}\p{N} ._-]/gu, "")
|
|
102
|
+
.trim()
|
|
103
|
+
.slice(0, 60) || "MCP";
|
|
104
|
+
const who = {
|
|
105
|
+
userId: "local",
|
|
106
|
+
tenantId: LOCAL_TENANT,
|
|
107
|
+
client,
|
|
108
|
+
scopes: [...SCOPES],
|
|
109
|
+
};
|
|
110
|
+
return authInfoFor(who, ticket, "local");
|
|
111
|
+
}
|
|
90
112
|
/** Who an MCP or API request acts for: an API key (`x-api-key` or a `wz_…` bearer) or an OAuth access token. */
|
|
91
113
|
export async function authenticate(request) {
|
|
114
|
+
const local = request.headers.get(LOCAL_MCP_HEADER)?.trim();
|
|
115
|
+
if (local) {
|
|
116
|
+
return localAuth(request, local);
|
|
117
|
+
}
|
|
92
118
|
const header = request.headers.get("x-api-key")?.trim();
|
|
93
119
|
const token = bearer(request);
|
|
94
120
|
const key = header || (token?.startsWith(API_KEY_PREFIX) ? token : null);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { RESOURCE_MIME_TYPE, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
6
|
+
import { env } from "../env.js";
|
|
7
|
+
import { runTicket } from "../secrets/signing.js";
|
|
8
|
+
import { draftIssues, ownedWizard, publishedVersion } from "../services/wizards.js";
|
|
9
|
+
/**
|
|
10
|
+
* The flow widget (MCP Apps): a wizard as its diagram, and a run on it — where it stands, what
|
|
11
|
+
* it made, the page it waits for. Hosts that render MCP Apps (Claude Desktop, Cursor, VS Code,
|
|
12
|
+
* Goose, ChatGPT) show it in the chat; the others get the same tools as text.
|
|
13
|
+
*/
|
|
14
|
+
/** Built by the web app (`vite.mcp-app.config.ts`) into one HTML file with everything inline. */
|
|
15
|
+
const FLOW_APP_FILE = resolve(dirname(fileURLToPath(import.meta.url)), "../../../web/dist/mcp/flow.html");
|
|
16
|
+
const MISSING = `<!doctype html><meta charset="utf-8"><body style="font:14px system-ui;padding:16px">
|
|
17
|
+
The flow widget is not built: run <code>pnpm --filter @engenty-wizards/web build</code>.</body>`;
|
|
18
|
+
function readWidget() {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(FLOW_APP_FILE, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return MISSING;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const FLOW_APP_HTML = readWidget();
|
|
27
|
+
/**
|
|
28
|
+
* Hosts keep a widget by its URI, so the URI names the build: a new widget gets a new address
|
|
29
|
+
* instead of the cached old one.
|
|
30
|
+
*/
|
|
31
|
+
export const FLOW_APP_URI = `ui://engenty-wizards/flow-${createHash("sha256").update(FLOW_APP_HTML).digest("hex").slice(0, 10)}.html`;
|
|
32
|
+
export function registerFlowApp(server) {
|
|
33
|
+
const origin = new URL(env.appUrl).origin;
|
|
34
|
+
registerAppResource(server, "Wizard flow", FLOW_APP_URI, {
|
|
35
|
+
description: "A wizard as a diagram, and a run on it.",
|
|
36
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
37
|
+
}, async () => ({
|
|
38
|
+
contents: [
|
|
39
|
+
{
|
|
40
|
+
uri: FLOW_APP_URI,
|
|
41
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
42
|
+
text: FLOW_APP_HTML,
|
|
43
|
+
_meta: {
|
|
44
|
+
ui: {
|
|
45
|
+
// The run and its files are served by this runtime; the fonts by Google.
|
|
46
|
+
csp: {
|
|
47
|
+
connectDomains: [origin],
|
|
48
|
+
resourceDomains: [
|
|
49
|
+
origin,
|
|
50
|
+
"https://fonts.googleapis.com",
|
|
51
|
+
"https://fonts.gstatic.com",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
prefersBorder: true,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The widget's data for a wizard: the published version when there is one (that is what runs),
|
|
63
|
+
* else the draft. A run brings the definition it started on.
|
|
64
|
+
*/
|
|
65
|
+
export async function flowView(userId, wizardId, options = {}) {
|
|
66
|
+
const w = await ownedWizard(userId, wizardId);
|
|
67
|
+
const published = options.shows === "draft" ? null : await publishedVersion(w);
|
|
68
|
+
const shows = published ? "published" : "draft";
|
|
69
|
+
const definition = options.definition ?? published?.definition ?? w.draft;
|
|
70
|
+
return {
|
|
71
|
+
wizard: {
|
|
72
|
+
id: w.id,
|
|
73
|
+
title: definition.title,
|
|
74
|
+
description: definition.description,
|
|
75
|
+
avatar: definition.avatar,
|
|
76
|
+
shows,
|
|
77
|
+
published: w.publishedVersion !== null,
|
|
78
|
+
issues: shows === "draft" ? (await draftIssues(w)).length : undefined,
|
|
79
|
+
},
|
|
80
|
+
definition,
|
|
81
|
+
run: options.run,
|
|
82
|
+
runtime: options.run ? { base: env.appUrl, ticket: runTicket(options.run.runId) } : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** A tool's `_meta` that opens the widget with the tool's result. */
|
|
86
|
+
export const flowAppMeta = {
|
|
87
|
+
ui: { resourceUri: FLOW_APP_URI },
|
|
88
|
+
"openai/outputTemplate": FLOW_APP_URI,
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Where a result carries the widget's data. The definition stays out of `structuredContent`,
|
|
92
|
+
* which hosts hand to the model as well; `_meta` only reaches the widget.
|
|
93
|
+
*/
|
|
94
|
+
export const FLOW_VIEW_KEY = "engenty/flow";
|
|
@@ -3,11 +3,13 @@ import { withTenant } from "../db/client.js";
|
|
|
3
3
|
import { env } from "../env.js";
|
|
4
4
|
import { tenantStatus } from "../tenants/control.js";
|
|
5
5
|
import { authenticate, principalOf } from "./auth.js";
|
|
6
|
+
import { registerFlowApp } from "./flow-app.js";
|
|
7
|
+
import { noteRequest } from "./seen.js";
|
|
6
8
|
import { registerPrompts, registerTools } from "./tools.js";
|
|
7
|
-
const INSTRUCTIONS = `engenty wizards:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const INSTRUCTIONS = `engenty wizards: page-by-page AI wizards — the person answers pages, AI steps write, research and make images or videos, the person reviews, the result is files.
|
|
10
|
+
Use one: list_wizards, then run_wizard straight away — its widget shows the wizard itself (show_wizard only when the person wants to see a wizard before running it), then follow waitingFor (answer_page, review_step, answer_ask, get_run with waitSeconds). Runs spend credits.
|
|
11
|
+
Build one: get_authoring_guide (schema + design rules), a close starter with get_starter, create_wizard, edit_wizard until "issues" is empty, check with start_test_run + get_test_run. Publish only when the admin asks.
|
|
12
|
+
When a run needs the browser, hand over its browserUrl, the wizard's own run page. The studioUrl is for building only: never send someone who uses a wizard to the studio.`;
|
|
11
13
|
/** A wizard definition is a few KB; this leaves room and refuses anything far beyond it. */
|
|
12
14
|
const MAX_BODY_BYTES = 300_000;
|
|
13
15
|
/**
|
|
@@ -18,10 +20,11 @@ const MAX_BODY_BYTES = 300_000;
|
|
|
18
20
|
export function mcpHandler() {
|
|
19
21
|
const allowedHosts = [new URL(env.appUrl).hostname, "localhost", "127.0.0.1"];
|
|
20
22
|
const handler = createMcpHandler((ctx) => {
|
|
21
|
-
const server = new McpServer({ name: "engenty-wizards", version: "0.1.0" }, { instructions: INSTRUCTIONS, capabilities: { tools: {}, prompts: {} } });
|
|
23
|
+
const server = new McpServer({ name: "engenty-wizards", version: "0.1.0" }, { instructions: INSTRUCTIONS, capabilities: { tools: {}, prompts: {}, resources: {} } });
|
|
22
24
|
const who = principalOf(ctx.authInfo);
|
|
23
25
|
registerTools(server, who);
|
|
24
26
|
registerPrompts(server);
|
|
27
|
+
registerFlowApp(server);
|
|
25
28
|
return server;
|
|
26
29
|
}, { legacy: "stateless", maxRequestBodySize: MAX_BODY_BYTES });
|
|
27
30
|
return async (request) => {
|
|
@@ -37,6 +40,13 @@ export function mcpHandler() {
|
|
|
37
40
|
if ((await tenantStatus(who.tenantId)) === "suspended") {
|
|
38
41
|
return new Response(JSON.stringify({ error: "tenant_suspended" }), { status: 403 });
|
|
39
42
|
}
|
|
43
|
+
if (request.method === "POST") {
|
|
44
|
+
const body = await request
|
|
45
|
+
.clone()
|
|
46
|
+
.json()
|
|
47
|
+
.catch(() => null);
|
|
48
|
+
noteRequest(who.tenantId, authInfo.clientId, who.client, body);
|
|
49
|
+
}
|
|
40
50
|
return withTenant(who.tenantId, () => handler.fetch(request, { authInfo }));
|
|
41
51
|
};
|
|
42
52
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each AI client did with the MCP server since the runtime started: the test on the
|
|
3
|
+
* "Integrate" page waits for a client to show up, call a tool and show the widget. Kept in memory
|
|
4
|
+
* only; a restart starts the test over.
|
|
5
|
+
*/
|
|
6
|
+
/** The ids the studio lists the AI apps by, and the names they give themselves (`clientInfo`). */
|
|
7
|
+
const CLIENT_NAMES = [
|
|
8
|
+
// Claude Desktop calls itself claude-ai, as claude.ai does.
|
|
9
|
+
["claude-desktop", /claude[- ]?ai|claude desktop/i],
|
|
10
|
+
["claude-ai", /claude[- ]?ai/i],
|
|
11
|
+
["claude-code", /claude[- ]?code/i],
|
|
12
|
+
["codex", /codex/i],
|
|
13
|
+
["chatgpt", /openai|chatgpt/i],
|
|
14
|
+
["cursor", /cursor/i],
|
|
15
|
+
// Cursor calls itself cursor-vscode.
|
|
16
|
+
["vscode", /^(?!.*cursor).*(visual studio code|vs ?code)/i],
|
|
17
|
+
["windsurf", /windsurf|codeium/i],
|
|
18
|
+
["gemini", /gemini/i],
|
|
19
|
+
["goose", /goose/i],
|
|
20
|
+
["openclaw", /openclaw/i],
|
|
21
|
+
["lm-studio", /lm[- ]?studio/i],
|
|
22
|
+
];
|
|
23
|
+
/** The apps a client's name stands for; mostly one. */
|
|
24
|
+
export function appIdsOf(client) {
|
|
25
|
+
return CLIENT_NAMES.filter(([, name]) => name.test(client)).map(([id]) => id);
|
|
26
|
+
}
|
|
27
|
+
const seen = new Map();
|
|
28
|
+
/** The name a client gave on `initialize`, by its sign-in: later requests do not repeat it. */
|
|
29
|
+
const names = new Map();
|
|
30
|
+
/** Notes one MCP request: its JSON-RPC messages, from the client signed in as `signIn`. */
|
|
31
|
+
export function noteRequest(tenantId, signIn, client, body) {
|
|
32
|
+
const messages = (Array.isArray(body) ? body : [body]).filter((m) => Boolean(m) && typeof m === "object");
|
|
33
|
+
const info = messages.find((m) => m.method === "initialize")?.params?.clientInfo;
|
|
34
|
+
const given = info?.title || info?.name;
|
|
35
|
+
if (given) {
|
|
36
|
+
names.set(signIn, given);
|
|
37
|
+
}
|
|
38
|
+
const name = names.get(signIn) ?? client;
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
for (const id of appIdsOf(name)) {
|
|
41
|
+
const key = `${tenantId}\n${id}`;
|
|
42
|
+
const last = seen.get(key) ?? { at: now, toolAt: null, widgetAt: null };
|
|
43
|
+
last.at = now;
|
|
44
|
+
for (const m of messages) {
|
|
45
|
+
if (m.method === "tools/call") {
|
|
46
|
+
last.toolAt = now;
|
|
47
|
+
}
|
|
48
|
+
if (m.method === "resources/read" && String(m.params?.uri ?? "").startsWith("ui://")) {
|
|
49
|
+
last.widgetAt = now;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
seen.set(key, last);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** What the tenant's clients did, by app id. */
|
|
56
|
+
export function seenApps(tenantId) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [key, value] of seen) {
|
|
59
|
+
const [tenant, id] = key.split("\n");
|
|
60
|
+
if (tenant === tenantId) {
|
|
61
|
+
out[id] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|