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,69 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { openTenantClients } from "../db/client.js";
|
|
4
|
+
import { loadedPlugins } from "./registry.js";
|
|
5
|
+
/**
|
|
6
|
+
* A plugin's own tables. Each `.sql` file of the folders a plugin registered runs once per
|
|
7
|
+
* tenant database, in the order of the file names; `plugin_migration` records which ran. A
|
|
8
|
+
* database gets them when it opens, and the open ones get them when a plugin loads. Unloading a
|
|
9
|
+
* plugin leaves its tables and their rows where they are.
|
|
10
|
+
*/
|
|
11
|
+
const RECORD = `CREATE TABLE IF NOT EXISTS plugin_migration (
|
|
12
|
+
plugin TEXT NOT NULL,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
applied_at INTEGER NOT NULL,
|
|
15
|
+
PRIMARY KEY (plugin, name)
|
|
16
|
+
)`;
|
|
17
|
+
/** One at a time per database: a plugin that loads while a database opens must not run a file twice. */
|
|
18
|
+
const queues = new Map();
|
|
19
|
+
async function apply(client) {
|
|
20
|
+
const pending = loadedPlugins().filter((p) => p.migrations.length);
|
|
21
|
+
if (!pending.length) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await client.execute(RECORD);
|
|
25
|
+
const done = await client.execute("SELECT plugin, name FROM plugin_migration");
|
|
26
|
+
const ran = new Set(done.rows.map((row) => `${row.plugin}/${row.name}`));
|
|
27
|
+
for (const plugin of pending) {
|
|
28
|
+
for (const folder of plugin.migrations) {
|
|
29
|
+
const files = readdirSync(folder)
|
|
30
|
+
.filter((name) => name.endsWith(".sql"))
|
|
31
|
+
.sort();
|
|
32
|
+
for (const name of files) {
|
|
33
|
+
if (ran.has(`${plugin.source.id}/${name}`)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// The file and its record go in together or not at all.
|
|
37
|
+
const tx = await client.transaction("write");
|
|
38
|
+
try {
|
|
39
|
+
await tx.executeMultiple(readFileSync(join(folder, name), "utf8"));
|
|
40
|
+
await tx.execute({
|
|
41
|
+
sql: "INSERT INTO plugin_migration (plugin, name, applied_at) VALUES (?, ?, ?)",
|
|
42
|
+
args: [plugin.source.id, name, Date.now()],
|
|
43
|
+
});
|
|
44
|
+
await tx.commit();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
await tx.rollback().catch(() => undefined);
|
|
48
|
+
throw new Error(`Plugin "${plugin.source.id}", ${name}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
tx.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function migratePlugins(tenantId, client) {
|
|
58
|
+
const next = (queues.get(tenantId) ?? Promise.resolve())
|
|
59
|
+
.catch(() => undefined)
|
|
60
|
+
.then(() => apply(client));
|
|
61
|
+
queues.set(tenantId, next);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
/** After a plugin loaded: its tables for every database that is open already. */
|
|
65
|
+
export async function migrateOpenTenants() {
|
|
66
|
+
for (const [tenantId, client] of await openTenantClients()) {
|
|
67
|
+
await migratePlugins(tenantId, client).catch((err) => console.error(`[plugins] tables for tenant ${tenantId}:`, err.message));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { env } from "../env.js";
|
|
5
|
+
import { managed, tenantInfo } from "../manage.js";
|
|
6
|
+
const plugins = new Map();
|
|
7
|
+
let generations = 0;
|
|
8
|
+
export function nextGeneration() {
|
|
9
|
+
return ++generations;
|
|
10
|
+
}
|
|
11
|
+
export function emptyRecord(source) {
|
|
12
|
+
return {
|
|
13
|
+
source,
|
|
14
|
+
generation: nextGeneration(),
|
|
15
|
+
error: null,
|
|
16
|
+
routes: [],
|
|
17
|
+
tools: new Map(),
|
|
18
|
+
listeners: {},
|
|
19
|
+
migrations: [],
|
|
20
|
+
disposers: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function setPlugin(record) {
|
|
24
|
+
plugins.set(record.source.id, record);
|
|
25
|
+
}
|
|
26
|
+
export function dropPlugin(id) {
|
|
27
|
+
const record = plugins.get(id);
|
|
28
|
+
plugins.delete(id);
|
|
29
|
+
return record;
|
|
30
|
+
}
|
|
31
|
+
export function loadedPlugin(id) {
|
|
32
|
+
return plugins.get(id);
|
|
33
|
+
}
|
|
34
|
+
export function loadedPlugins() {
|
|
35
|
+
return [...plugins.values()];
|
|
36
|
+
}
|
|
37
|
+
/** `/items/:id` as a pattern for the address below the plugin's own. */
|
|
38
|
+
export function compileRoute(route) {
|
|
39
|
+
const params = [];
|
|
40
|
+
const path = route.path.startsWith("/") ? route.path : `/${route.path}`;
|
|
41
|
+
const source = path
|
|
42
|
+
.replace(/\/+$/, "")
|
|
43
|
+
.split("/")
|
|
44
|
+
.map((part) => {
|
|
45
|
+
if (part.startsWith(":")) {
|
|
46
|
+
params.push(part.slice(1));
|
|
47
|
+
return "([^/]+)";
|
|
48
|
+
}
|
|
49
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
})
|
|
51
|
+
.join("/");
|
|
52
|
+
return { route, pattern: new RegExp(`^${source}/?$`), params };
|
|
53
|
+
}
|
|
54
|
+
// --- which tenant has which plugin -----------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* The ids of the plugins a tenant has. A runtime that runs alone has every plugin it loaded.
|
|
57
|
+
* Managed, a tenant has the ones in `PLUGINS_DEFAULT` and the ones the Manage-App lists for it
|
|
58
|
+
* (`modules` of `GET /v1/tenants/:id`).
|
|
59
|
+
*/
|
|
60
|
+
export async function pluginIdsOf(tenantId) {
|
|
61
|
+
if (!managed) {
|
|
62
|
+
return new Set(plugins.keys());
|
|
63
|
+
}
|
|
64
|
+
const listed = await tenantInfo(tenantId)
|
|
65
|
+
.then((info) => info.modules ?? [])
|
|
66
|
+
.catch(() => []);
|
|
67
|
+
return new Set([...env.plugins.defaults, ...listed].filter((id) => plugins.has(id)));
|
|
68
|
+
}
|
|
69
|
+
export async function pluginsOf(tenantId) {
|
|
70
|
+
const ids = await pluginIdsOf(tenantId);
|
|
71
|
+
return loadedPlugins().filter((p) => ids.has(p.source.id));
|
|
72
|
+
}
|
|
73
|
+
/** The tools the tenant's plugins add, by the id a step lists them under. */
|
|
74
|
+
export async function pluginToolsOf(tenantId) {
|
|
75
|
+
return (await pluginsOf(tenantId)).flatMap((plugin) => [...plugin.tools.values()].map((tool) => ({
|
|
76
|
+
id: `${plugin.source.id}.${tool.name}`,
|
|
77
|
+
title: tool.title ?? tool.name,
|
|
78
|
+
description: tool.description,
|
|
79
|
+
})));
|
|
80
|
+
}
|
|
81
|
+
/** The built studio half as it is on disk now; null when the plugin has none. */
|
|
82
|
+
export function pluginAsset(record, kind) {
|
|
83
|
+
const file = record.source[kind];
|
|
84
|
+
if (!file) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return {
|
|
89
|
+
file,
|
|
90
|
+
rev: createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 12),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const changes = new EventEmitter();
|
|
98
|
+
changes.setMaxListeners(0);
|
|
99
|
+
export function announceChange(change) {
|
|
100
|
+
changes.emit("change", change);
|
|
101
|
+
}
|
|
102
|
+
export function onPluginChange(listener) {
|
|
103
|
+
changes.on("change", listener);
|
|
104
|
+
return () => changes.off("change", listener);
|
|
105
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { streamSSE } from "hono/streaming";
|
|
4
|
+
import { managed } from "../manage.js";
|
|
5
|
+
import { pluginProblems, reloadPlugin, rescanPlugins } from "../plugins/loader.js";
|
|
6
|
+
import { loadedPlugin, onPluginChange, pluginAsset, pluginIdsOf, pluginsOf, } from "../plugins/registry.js";
|
|
7
|
+
const admin = (user) => user.role === "owner" || user.role === "admin";
|
|
8
|
+
/** Plugins change while the app runs only where it runs alone: a reload would hit every tenant. */
|
|
9
|
+
const canReload = (user) => !managed && admin(user);
|
|
10
|
+
function view(plugin) {
|
|
11
|
+
const script = pluginAsset(plugin, "studio");
|
|
12
|
+
const styles = pluginAsset(plugin, "styles");
|
|
13
|
+
const { id, name, version, description } = plugin.source.manifest;
|
|
14
|
+
const address = (file, rev) => `/api/studio/plugins/-/assets/${id}/${file}?rev=${rev}`;
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
name,
|
|
18
|
+
version,
|
|
19
|
+
description: description ?? "",
|
|
20
|
+
generation: plugin.generation,
|
|
21
|
+
/** Why the server half did not load; the plugin then adds nothing on the server. */
|
|
22
|
+
error: plugin.error,
|
|
23
|
+
script: script ? address("client.js", script.rev) : null,
|
|
24
|
+
styles: styles ? address("client.css", styles.rev) : null,
|
|
25
|
+
tools: [...plugin.tools.values()].map((tool) => ({
|
|
26
|
+
id: `${id}.${tool.name}`,
|
|
27
|
+
title: tool.title ?? tool.name,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** A thrown error that says how to answer: `{ status, code }`, as the runtime's own errors do. */
|
|
32
|
+
function answerOf(err) {
|
|
33
|
+
const e = err;
|
|
34
|
+
return typeof e?.status === "number" &&
|
|
35
|
+
e.status >= 400 &&
|
|
36
|
+
e.status < 600 &&
|
|
37
|
+
typeof e.code === "string"
|
|
38
|
+
? { status: e.status, body: { error: String(e.message ?? e.code), code: e.code } }
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
async function dispatch(c) {
|
|
42
|
+
const user = c.get("user");
|
|
43
|
+
const id = c.req.param("id") ?? "";
|
|
44
|
+
const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
|
|
45
|
+
if (!plugin) {
|
|
46
|
+
return c.json({ error: "not found", code: "not_found" }, 404);
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(c.req.url);
|
|
49
|
+
const path = url.pathname.slice(url.pathname.indexOf(`/plugins/${id}`) + `/plugins/${id}`.length);
|
|
50
|
+
for (const { route, pattern, params } of plugin.routes) {
|
|
51
|
+
const match = route.method === c.req.method ? pattern.exec(path) : null;
|
|
52
|
+
if (!match) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (route.role === "admin" && !admin(user)) {
|
|
56
|
+
return c.json({ error: "forbidden", code: "forbidden" }, 403);
|
|
57
|
+
}
|
|
58
|
+
const request = {
|
|
59
|
+
request: c.req.raw,
|
|
60
|
+
path: path || "/",
|
|
61
|
+
params: Object.fromEntries(params.map((name, i) => [name, decodeURIComponent(match[i + 1])])),
|
|
62
|
+
query: url.searchParams,
|
|
63
|
+
user: {
|
|
64
|
+
id: user.id,
|
|
65
|
+
tenantId: user.tenantId,
|
|
66
|
+
role: user.role,
|
|
67
|
+
name: user.name,
|
|
68
|
+
email: user.email,
|
|
69
|
+
},
|
|
70
|
+
json: async () => {
|
|
71
|
+
const text = await c.req.text();
|
|
72
|
+
return (text ? JSON.parse(text) : {});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
const result = await route.handler(request);
|
|
77
|
+
return result instanceof Response ? result : c.json(result ?? null);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const answer = answerOf(err);
|
|
81
|
+
if (answer) {
|
|
82
|
+
return c.json(answer.body, answer.status);
|
|
83
|
+
}
|
|
84
|
+
console.error(`[plugin ${id}] ${route.method} ${path}:`, err);
|
|
85
|
+
return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return c.json({ error: "not found", code: "not_found" }, 404);
|
|
89
|
+
}
|
|
90
|
+
const TYPES = {
|
|
91
|
+
"client.js": { kind: "studio", type: "text/javascript; charset=utf-8" },
|
|
92
|
+
"client.css": { kind: "styles", type: "text/css; charset=utf-8" },
|
|
93
|
+
};
|
|
94
|
+
export const pluginRoutes = new Hono()
|
|
95
|
+
.get("/", async (c) => {
|
|
96
|
+
const user = c.get("user");
|
|
97
|
+
return c.json({
|
|
98
|
+
plugins: (await pluginsOf(user.tenantId)).map(view),
|
|
99
|
+
canReload: canReload(user),
|
|
100
|
+
/** What looked like a plugin and is none; for the person who installs them. */
|
|
101
|
+
problems: canReload(user) ? pluginProblems() : [],
|
|
102
|
+
});
|
|
103
|
+
})
|
|
104
|
+
// The built studio half. The address carries the file's revision, so it may be kept for good.
|
|
105
|
+
.get("/-/assets/:id/:file", async (c) => {
|
|
106
|
+
const user = c.get("user");
|
|
107
|
+
const file = c.req.param("file");
|
|
108
|
+
const kind = Object.hasOwn(TYPES, file) ? TYPES[file] : undefined;
|
|
109
|
+
const id = c.req.param("id");
|
|
110
|
+
const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
|
|
111
|
+
const asset = plugin && kind ? pluginAsset(plugin, kind.kind) : null;
|
|
112
|
+
if (!asset || !kind) {
|
|
113
|
+
return c.json({ error: "not found", code: "not_found" }, 404);
|
|
114
|
+
}
|
|
115
|
+
return c.body(new Uint8Array(await readFile(asset.file)), 200, {
|
|
116
|
+
"content-type": kind.type,
|
|
117
|
+
"cache-control": c.req.query("rev") === asset.rev ? "private, max-age=31536000, immutable" : "no-cache",
|
|
118
|
+
});
|
|
119
|
+
})
|
|
120
|
+
// Says when a plugin loaded again, so an open studio takes the new one without a page load.
|
|
121
|
+
.get("/-/events", (c) => {
|
|
122
|
+
if (managed) {
|
|
123
|
+
return c.json({ error: "not found", code: "not_found" }, 404);
|
|
124
|
+
}
|
|
125
|
+
return streamSSE(c, async (stream) => {
|
|
126
|
+
let closed = false;
|
|
127
|
+
const stop = onPluginChange((change) => {
|
|
128
|
+
void stream.writeSSE({ event: "change", data: JSON.stringify(change) });
|
|
129
|
+
});
|
|
130
|
+
stream.onAbort(() => {
|
|
131
|
+
closed = true;
|
|
132
|
+
stop();
|
|
133
|
+
});
|
|
134
|
+
await stream.writeSSE({ event: "ready", data: "{}" });
|
|
135
|
+
while (!closed) {
|
|
136
|
+
await stream.sleep(20_000);
|
|
137
|
+
if (!closed) {
|
|
138
|
+
await stream.writeSSE({ event: "ping", data: "{}" });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
})
|
|
143
|
+
.post("/-/reload", async (c) => {
|
|
144
|
+
if (!canReload(c.get("user"))) {
|
|
145
|
+
return c.json({ error: "forbidden", code: "forbidden" }, 403);
|
|
146
|
+
}
|
|
147
|
+
await rescanPlugins();
|
|
148
|
+
return c.json({ plugins: (await pluginsOf(c.get("user").tenantId)).map(view) });
|
|
149
|
+
})
|
|
150
|
+
.post("/-/reload/:id", async (c) => {
|
|
151
|
+
if (!canReload(c.get("user"))) {
|
|
152
|
+
return c.json({ error: "forbidden", code: "forbidden" }, 403);
|
|
153
|
+
}
|
|
154
|
+
const plugin = await reloadPlugin(c.req.param("id"));
|
|
155
|
+
return plugin
|
|
156
|
+
? c.json({ plugin: view(plugin) })
|
|
157
|
+
: c.json({ error: "not found", code: "not_found" }, 404);
|
|
158
|
+
})
|
|
159
|
+
.all("/:id", dispatch)
|
|
160
|
+
.all("/:id/*", dispatch);
|
|
@@ -21,7 +21,7 @@ import { hashIp, verifyTurnstile, wizardUnavailable } from "../limits.js";
|
|
|
21
21
|
import { ModelUnavailableError } from "../model-errors.js";
|
|
22
22
|
import { listDownload, renderDownload, stepHtml } from "../render/downloads.js";
|
|
23
23
|
import { HTML_RESPONSE_CSP } from "../render/guard.js";
|
|
24
|
-
import { verifySignedUrl } from "../secrets/signing.js";
|
|
24
|
+
import { runTicketValid, verifySignedUrl } from "../secrets/signing.js";
|
|
25
25
|
import { brandView } from "../services/brand.js";
|
|
26
26
|
import { ServiceError } from "../services/errors.js";
|
|
27
27
|
import { sharedRun, shareImage, shareRun, shareView, unshareRun } from "../services/shares.js";
|
|
@@ -62,8 +62,9 @@ function visitorId(c, create, embedded = false) {
|
|
|
62
62
|
return vid;
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
* The run, if the caller may see it: its visitor, the admin who owns the wizard,
|
|
66
|
-
* read-only file routes — a signed link
|
|
65
|
+
* The run, if the caller may see it: its visitor, the admin who owns the wizard, the widget of
|
|
66
|
+
* the owner's AI app with its run ticket, or — on the read-only file routes — a signed link
|
|
67
|
+
* handed out to the owner's MCP client.
|
|
67
68
|
*/
|
|
68
69
|
async function accessibleRun(c, runId, opts = {}) {
|
|
69
70
|
const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
|
|
@@ -73,6 +74,9 @@ async function accessibleRun(c, runId, opts = {}) {
|
|
|
73
74
|
if (opts.signed && verifySignedUrl(c.req.url)) {
|
|
74
75
|
return run;
|
|
75
76
|
}
|
|
77
|
+
if (runTicketValid(run.id, c.req.query("rt"))) {
|
|
78
|
+
return run;
|
|
79
|
+
}
|
|
76
80
|
const vid = visitorId(c, false);
|
|
77
81
|
if (vid && run.visitorId === vid) {
|
|
78
82
|
return run;
|
|
@@ -10,6 +10,8 @@ import { runProjectAssistant } from "../agents/project-assistant.js";
|
|
|
10
10
|
import { chatEngine, setChatEngine, subscriptionClients } from "../agents/subscription.js";
|
|
11
11
|
import { accountOverview, linkedAccount, startLink, unlink } from "../auth/account.js";
|
|
12
12
|
import { createLocalKey, deleteLocalKey, listLocalKeys } from "../auth/keys.js";
|
|
13
|
+
import { appStates, connectApps } from "../cli/connect.js";
|
|
14
|
+
import { layout } from "../cli/home.js";
|
|
13
15
|
import { connectorImportSchema, importConnector, listConnectors, previewSource, refreshConnector, registryService, removeConnector, searchRegistry, } from "../connectors/external.js";
|
|
14
16
|
import { balanceCredits, canSpend } from "../credits/credits.js";
|
|
15
17
|
import { estimateRun } from "../credits/estimate.js";
|
|
@@ -19,6 +21,7 @@ import { freshAuth, loginEnv } from "../harness/env.js";
|
|
|
19
21
|
import { detectHarness, detectHarnesses, HARNESS_IDS, harness, installSpec, } from "../harness/index.js";
|
|
20
22
|
import { closeTerminal, openTerminal, resizeTerminal, subscribeTerminal, writeTerminal, } from "../harness/terminal.js";
|
|
21
23
|
import { managed } from "../manage.js";
|
|
24
|
+
import { seenApps } from "../mcp/seen.js";
|
|
22
25
|
import { transcribeAudio } from "../media/transcribe.js";
|
|
23
26
|
import { embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnavailableError, saveLocalModels, textModel, } from "../models.js";
|
|
24
27
|
import { HTML_RESPONSE_CSP } from "../render/guard.js";
|
|
@@ -530,6 +533,23 @@ export const studio = new Hono()
|
|
|
530
533
|
await deleteLocalKey(c.req.param("id"));
|
|
531
534
|
return c.json({ ok: true });
|
|
532
535
|
})
|
|
536
|
+
// The person's AI apps on this machine (Claude Desktop, Cursor, Codex …) and whether they
|
|
537
|
+
// have this install's MCP server: the same as `wizards connect`.
|
|
538
|
+
.get("/local/apps", async (c) => (managed ? c.notFound() : c.json(await appStates(layout()))))
|
|
539
|
+
.post("/local/apps/:id", async (c) => {
|
|
540
|
+
if (managed) {
|
|
541
|
+
return c.notFound();
|
|
542
|
+
}
|
|
543
|
+
const { connect } = z.object({ connect: z.boolean() }).parse(await c.req.json());
|
|
544
|
+
const [done] = await connectApps(layout(), [c.req.param("id")], !connect);
|
|
545
|
+
if (!done) {
|
|
546
|
+
return c.json({ error: "Unknown app" }, 404);
|
|
547
|
+
}
|
|
548
|
+
// Not done is an answer too: the app's file is no plain JSON, here is what to paste.
|
|
549
|
+
return c.json(done.outcome);
|
|
550
|
+
})
|
|
551
|
+
// What each AI client did with the MCP server since the start: the test on "Integrate".
|
|
552
|
+
.get("/integrations/seen", (c) => c.json(seenApps(c.get("user").tenantId)))
|
|
533
553
|
.get("/local/models", (c) => (managed ? c.notFound() : c.json(localModelSettings())))
|
|
534
554
|
.put("/local/models", async (c) => {
|
|
535
555
|
if (managed) {
|
|
@@ -21,6 +21,11 @@ export function signedUrl(path, ttlSeconds = 3600) {
|
|
|
21
21
|
url.searchParams.set("sig", sign(payload(url, exp)));
|
|
22
22
|
return url.toString();
|
|
23
23
|
}
|
|
24
|
+
function sameSignature(data, sig) {
|
|
25
|
+
const expected = Buffer.from(sign(data));
|
|
26
|
+
const given = Buffer.from(sig);
|
|
27
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
28
|
+
}
|
|
24
29
|
export function verifySignedUrl(href) {
|
|
25
30
|
const url = new URL(href, env.appUrl);
|
|
26
31
|
const exp = url.searchParams.get("exp");
|
|
@@ -28,7 +33,20 @@ export function verifySignedUrl(href) {
|
|
|
28
33
|
if (!exp || !sig || Number(exp) * 1000 < Date.now()) {
|
|
29
34
|
return false;
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
return sameSignature(payload(url, exp), sig);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A ticket to one run's routes without a session: the widget an AI app shows runs on the host's
|
|
40
|
+
* origin and reaches the run with it. Sent as `?rt=`, since an EventSource sends no headers.
|
|
41
|
+
*/
|
|
42
|
+
export function runTicket(runId, ttlSeconds = 12 * 3600) {
|
|
43
|
+
const exp = String(Math.floor(Date.now() / 1000) + ttlSeconds);
|
|
44
|
+
return `${exp}.${sign(`run:${runId}|${exp}`)}`;
|
|
45
|
+
}
|
|
46
|
+
export function runTicketValid(runId, ticket) {
|
|
47
|
+
const [exp, sig] = (ticket ?? "").split(".");
|
|
48
|
+
if (!exp || !sig || Number(exp) * 1000 < Date.now()) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return sameSignature(`run:${runId}|${exp}`, sig);
|
|
34
52
|
}
|