@unoverse-platform/studio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
package/local/plugin.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local Studio's file-reading host, as a Vite plugin.
|
|
3
|
+
*
|
|
4
|
+
* The Studio screens (../screens) fetch a small set of catalog routes. In Canvas those
|
|
5
|
+
* are served by a running universe. Here they are served straight off the developer's
|
|
6
|
+
* disk by the SAME loaders the server uses (see catalog.ts), so the screens are
|
|
7
|
+
* unchanged and there is no backend: no Postgres, no Redis, no engine, no Docker.
|
|
8
|
+
*
|
|
9
|
+
* Routes mirror the server's shapes exactly, because the screens parse those shapes:
|
|
10
|
+
* GET /dev/orgs GET /dev/themes?org= GET /dev/components?org=
|
|
11
|
+
* GET /dev/templates?org= GET /dev/atoms
|
|
12
|
+
* GET /local/theme?ref= GET /local/definition?uri=
|
|
13
|
+
* GET /skills /skills/:name GET /prompt-blocks
|
|
14
|
+
* GET|POST /registry/state (no database locally, see below)
|
|
15
|
+
*
|
|
16
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { resolve } from "path";
|
|
20
|
+
import { readdirSync, existsSync, readFileSync, statSync } from "fs";
|
|
21
|
+
import type { Plugin, ViteDevServer } from "vite";
|
|
22
|
+
import { findProjectRoot, loadCatalog } from "./catalog.js";
|
|
23
|
+
import { loadLocalNodes, type LocalNodes } from "./nodes.js";
|
|
24
|
+
import { credentialsFromEnv, keyPresence, readEnv } from "./keys.js";
|
|
25
|
+
|
|
26
|
+
/** JSON response helper matching the server's content type. */
|
|
27
|
+
function json(res: import("http").ServerResponse, body: unknown, status = 200): void {
|
|
28
|
+
const text = JSON.stringify(body);
|
|
29
|
+
res.statusCode = status;
|
|
30
|
+
res.setHeader("content-type", "application/json");
|
|
31
|
+
res.end(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read a folder of markdown-with-frontmatter (skills, prompt blocks) into the shape
|
|
36
|
+
* the screens expect. Deliberately minimal: local Studio reads the developer's OWN
|
|
37
|
+
* files, and the frontmatter fields the screens display are name/description/category.
|
|
38
|
+
*/
|
|
39
|
+
interface MarkdownEntry {
|
|
40
|
+
/** Stable key the screens select on. Derived from the filename, like the server's. */
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
category?: string;
|
|
45
|
+
version?: string;
|
|
46
|
+
filePath: string;
|
|
47
|
+
tags: string[];
|
|
48
|
+
/** Prompt blocks read `content`; skills read `instructions`. Same text, both named. */
|
|
49
|
+
content: string;
|
|
50
|
+
instructions: string;
|
|
51
|
+
whenToUse?: string;
|
|
52
|
+
triggers?: string[];
|
|
53
|
+
contentHash: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readMarkdownLibrary(dir: string): MarkdownEntry[] {
|
|
57
|
+
if (!existsSync(dir)) return [];
|
|
58
|
+
const out: MarkdownEntry[] = [];
|
|
59
|
+
const walk = (d: string) => {
|
|
60
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
61
|
+
const p = resolve(d, e.name);
|
|
62
|
+
if (e.isDirectory()) {
|
|
63
|
+
walk(p);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!e.name.endsWith(".md")) continue;
|
|
67
|
+
const raw = readFileSync(p, "utf8");
|
|
68
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw);
|
|
69
|
+
const meta: Record<string, string> = {};
|
|
70
|
+
if (m) {
|
|
71
|
+
for (const line of m[1].split("\n")) {
|
|
72
|
+
const i = line.indexOf(":");
|
|
73
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const body = m ? raw.slice(m[0].length) : raw;
|
|
77
|
+
// Skills are a FOLDER holding SKILL.md, so the folder names them. Prompt blocks
|
|
78
|
+
// are single files, so the filename does. Either way the id must be unique.
|
|
79
|
+
const base = e.name.replace(/\.md$/, "");
|
|
80
|
+
const id = base.toUpperCase() === "SKILL" ? d.split("/").pop()! : base;
|
|
81
|
+
const list = (v?: string) =>
|
|
82
|
+
v ? v.replace(/^\[|\]$/g, "").split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean) : [];
|
|
83
|
+
out.push({
|
|
84
|
+
id,
|
|
85
|
+
name: meta.name || id,
|
|
86
|
+
description: meta.description ?? "",
|
|
87
|
+
category: meta.category,
|
|
88
|
+
version: meta.version,
|
|
89
|
+
filePath: p,
|
|
90
|
+
tags: list(meta.tags),
|
|
91
|
+
content: body,
|
|
92
|
+
instructions: body,
|
|
93
|
+
whenToUse: meta.whenToUse,
|
|
94
|
+
triggers: list(meta.triggers),
|
|
95
|
+
// The server hashes content to detect drift. Offline nothing consumes it, but
|
|
96
|
+
// the screens type it as required, so give them something honest and stable.
|
|
97
|
+
contentHash: String(statSync(p).mtimeMs),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
walk(dir);
|
|
102
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function localStudioHost(): Plugin {
|
|
106
|
+
let projectRoot: string;
|
|
107
|
+
let baseSrc: string;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name: "local-studio-host",
|
|
111
|
+
|
|
112
|
+
configureServer(server: ViteDevServer) {
|
|
113
|
+
projectRoot = findProjectRoot();
|
|
114
|
+
/**
|
|
115
|
+
* The loaders live in `@unoverse-platform/base`. Studio reuses them rather than
|
|
116
|
+
* growing a second implementation that would drift.
|
|
117
|
+
*
|
|
118
|
+
* RESOLVED THROUGH NODE, not by walking the tree. This used to be
|
|
119
|
+
* `resolve(root, "../unoverse/server/src")`, which was two problems in one: it
|
|
120
|
+
* reached into another app's source, so Studio could never be published, and it
|
|
121
|
+
* silently broke the moment those files moved into `base` — the paths pointed at
|
|
122
|
+
* nothing and no test covered the path.
|
|
123
|
+
*
|
|
124
|
+
* Asking Node where the package is means it works from a checkout OR from
|
|
125
|
+
* node_modules, which is what makes standalone Studio possible.
|
|
126
|
+
*/
|
|
127
|
+
baseSrc = resolve(
|
|
128
|
+
createRequire(import.meta.url).resolve("@unoverse-platform/base/package.json"),
|
|
129
|
+
"../src",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const ssr = (id: string) => server.ssrLoadModule(id) as Promise<Record<string, unknown>>;
|
|
133
|
+
const catalog = loadCatalog(baseSrc, ssr, projectRoot);
|
|
134
|
+
// Node packages load lazily: only when the Nodes section is first opened, so a
|
|
135
|
+
// developer authoring only design assets never pays for it at startup.
|
|
136
|
+
let nodesOnce: Promise<LocalNodes | null> | null = null;
|
|
137
|
+
const nodes = () => (nodesOnce ??= loadLocalNodes(baseSrc, ssr, projectRoot));
|
|
138
|
+
|
|
139
|
+
server.config.logger.info(`\n Studio reading: ${resolve(projectRoot, "rx")}\n`);
|
|
140
|
+
|
|
141
|
+
server.middlewares.use(async (req, res, next) => {
|
|
142
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
143
|
+
const path = url.pathname;
|
|
144
|
+
// ADD A ROUTE HERE TOO. A case in the switch below that is not in this list never
|
|
145
|
+
// runs: the request falls through to Vite, which answers with the app's HTML and a
|
|
146
|
+
// 200. That has now cost three separate bugs (the /api allowlist, the dev proxy for
|
|
147
|
+
// /.well-known and /publish), each of which looked like a missing route rather than
|
|
148
|
+
// a filtered one.
|
|
149
|
+
if (!/^\/(dev|local|skills|prompt-blocks|registry|nodes|keys|execute|publish)\b/.test(path)) return next();
|
|
150
|
+
|
|
151
|
+
/** Read a JSON request body. */
|
|
152
|
+
const body = async (): Promise<Record<string, unknown>> => {
|
|
153
|
+
const chunks: Buffer[] = [];
|
|
154
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
155
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
156
|
+
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const c = await catalog;
|
|
161
|
+
const org = url.searchParams.get("org") ?? undefined;
|
|
162
|
+
|
|
163
|
+
switch (true) {
|
|
164
|
+
// Where Studio is reading and writing, in absolute terms. Shown in the UI:
|
|
165
|
+
// a developer must be able to SEE which folder their keys and nodes come
|
|
166
|
+
// from, not be told to trust a relative path.
|
|
167
|
+
case path === "/local/info":
|
|
168
|
+
return json(res, {
|
|
169
|
+
projectRoot,
|
|
170
|
+
rx: resolve(projectRoot, "rx"),
|
|
171
|
+
nodes: resolve(projectRoot, "nodes"),
|
|
172
|
+
envFiles: [resolve(projectRoot, "..", ".env"), resolve(projectRoot, ".env")],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* PUBLISH, in two calls: plan then send.
|
|
177
|
+
*
|
|
178
|
+
* It runs HERE rather than in the browser because publishing reads the
|
|
179
|
+
* developer's disk: their definitions, and the design system they built on.
|
|
180
|
+
* A browser cannot, and a server that could would be a server this tool
|
|
181
|
+
* deliberately does not have.
|
|
182
|
+
*
|
|
183
|
+
* The token is passed per request and never stored. Studio holds it in the
|
|
184
|
+
* OIDC session for the tab (UniverseSession) and hands it over for the one
|
|
185
|
+
* call; local Studio writes no secret to disk (local/keys.ts), and connecting
|
|
186
|
+
* to a universe must not be the thing that breaks that.
|
|
187
|
+
*
|
|
188
|
+
* Same functions the CLI uses (`unoverse publish assets`), so the two cannot
|
|
189
|
+
* disagree about what a publish is.
|
|
190
|
+
*/
|
|
191
|
+
case path === "/publish/plan" || path === "/publish/send": {
|
|
192
|
+
const b = await body();
|
|
193
|
+
const project = String(b.project ?? "");
|
|
194
|
+
const universe = String(b.universe ?? "");
|
|
195
|
+
const token = String(b.token ?? "");
|
|
196
|
+
if (!project || !universe || !token)
|
|
197
|
+
return json(res, { error: "project, universe and token are required" }, 400);
|
|
198
|
+
|
|
199
|
+
// Loaded through Vite's SSR runner, like the catalog and node loaders above: a bare
|
|
200
|
+
// dynamic import of a .js path does not resolve to the .ts source. The type below
|
|
201
|
+
// mirrors the module's real exports — tsc can't follow an ssrLoadModule path.
|
|
202
|
+
type PublishItem = { kind: string; name: string; why?: string; mode?: string };
|
|
203
|
+
type PublishModule = {
|
|
204
|
+
lintForPublish(rxHome: string, project: string): Promise<{ problems: unknown[]; errors: unknown[] }>;
|
|
205
|
+
collectProject(rxHome: string, project: string): unknown;
|
|
206
|
+
planPublish(collected: unknown, universe: string, token: string): Promise<Record<string, PublishItem[]>>;
|
|
207
|
+
sendPublish(plan: unknown, universe: string, token: string): Promise<{ sent: PublishItem[]; failed?: PublishItem[] }>;
|
|
208
|
+
};
|
|
209
|
+
const items = (await ssr(`${baseSrc}/items/publish.ts`)) as PublishModule;
|
|
210
|
+
const rxHome = resolve(projectRoot, "rx");
|
|
211
|
+
|
|
212
|
+
// LINT FIRST, ALWAYS. Nothing leaves the machine until the rules pass: a
|
|
213
|
+
// developer should learn about a raw hex value here, not from an HTTP error.
|
|
214
|
+
const { problems, errors } = await items.lintForPublish(rxHome, project);
|
|
215
|
+
if (errors.length) return json(res, { blocked: true, problems, errors });
|
|
216
|
+
|
|
217
|
+
const collected = items.collectProject(rxHome, project);
|
|
218
|
+
const plan = await items.planPublish(collected, universe, token);
|
|
219
|
+
|
|
220
|
+
// The screen renders kinds and names. The plan carries every file's full
|
|
221
|
+
// contents, so sending it whole would push the entire project through the
|
|
222
|
+
// browser for a list, and again on send.
|
|
223
|
+
type Lean = { kind: string; name: string; why?: string; mode?: string };
|
|
224
|
+
const lean = (list: Lean[] = []) =>
|
|
225
|
+
list.map(({ kind, name, why, mode }) => ({ kind, name, why, mode }));
|
|
226
|
+
const leanPlan = Object.fromEntries(
|
|
227
|
+
Object.entries(plan as Record<string, Lean[]>).map(([k, v]) => [k, lean(v)]),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (path === "/publish/plan") return json(res, { problems, plan: leanPlan });
|
|
231
|
+
|
|
232
|
+
const result = await items.sendPublish(plan, universe, token);
|
|
233
|
+
return json(res, {
|
|
234
|
+
problems,
|
|
235
|
+
plan: leanPlan,
|
|
236
|
+
sent: lean(result.sent),
|
|
237
|
+
failed: lean(result.failed),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case path === "/dev/orgs":
|
|
242
|
+
return json(res, { orgs: c.themeOrgs() });
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* NEW PROJECT. The only thing Studio writes to disk that is not a definition.
|
|
246
|
+
*
|
|
247
|
+
* A project was previously creatable only by `bin/studio.mjs` at launch, and
|
|
248
|
+
* only when the folder was empty: it could make your first and never your
|
|
249
|
+
* second. A developer wanting another had to quit Studio and mkdir, which is
|
|
250
|
+
* the same "a tool telling you to do the thing it could do" that scaffolding
|
|
251
|
+
* was added to end.
|
|
252
|
+
*
|
|
253
|
+
* The CLI calls the SAME function (local/scaffold.mjs), so project one and
|
|
254
|
+
* project five are identical.
|
|
255
|
+
*/
|
|
256
|
+
case path === "/dev/projects" && req.method === "POST": {
|
|
257
|
+
const { createProject } = await import("./scaffold.mjs");
|
|
258
|
+
const name = String((await body()).name ?? "");
|
|
259
|
+
try {
|
|
260
|
+
createProject(projectRoot, name);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// A bad name or an existing folder is the developer's to fix, not a fault:
|
|
263
|
+
// 400 so the dialog can say what is wrong beside the field.
|
|
264
|
+
return json(res, { error: e instanceof Error ? e.message : String(e) }, 400);
|
|
265
|
+
}
|
|
266
|
+
return json(res, { name });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case path === "/dev/themes":
|
|
270
|
+
return json(res, { themes: c.listThemeNames(org ?? c.DEFAULT_ORG) });
|
|
271
|
+
|
|
272
|
+
case path === "/dev/components":
|
|
273
|
+
return json(res, {
|
|
274
|
+
components: c.listDefinitions("component", org).map((d) => {
|
|
275
|
+
const def = d as { name: string; props?: unknown };
|
|
276
|
+
return { ...def, inputs: c.inputPropKeys(def.props), states: c.listStates("component", def.name) };
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
case path === "/dev/templates":
|
|
281
|
+
return json(res, { templates: c.listTemplatesForWorkbench(org) });
|
|
282
|
+
|
|
283
|
+
case path === "/dev/atoms":
|
|
284
|
+
return json(res, { atoms: c.listDefinitions("atom") });
|
|
285
|
+
|
|
286
|
+
// The SDK renderer's two reads, served locally instead of over MCP.
|
|
287
|
+
case path === "/local/theme": {
|
|
288
|
+
const t = c.resolveTheme(url.searchParams.get("ref") ?? "light");
|
|
289
|
+
return t ? json(res, t) : json(res, { error: "theme not found" }, 404);
|
|
290
|
+
}
|
|
291
|
+
case path === "/local/definition": {
|
|
292
|
+
const uri = url.searchParams.get("uri") ?? "";
|
|
293
|
+
// Apps are addressed both ways (unoverse://apps/<org>/<id> and
|
|
294
|
+
// unoverse://templates/<org>/<id>); both load the template kind.
|
|
295
|
+
const kind = /\/(apps|templates)\//.test(uri) ? "template" : /\/atoms\//.test(uri) ? "atom" : "component";
|
|
296
|
+
const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "");
|
|
297
|
+
const d = c.loadDefinition(ref, kind);
|
|
298
|
+
return d ? json(res, d) : json(res, { error: `definition not found: ${uri}` }, 404);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case path === "/skills":
|
|
302
|
+
return json(res, { skills: readMarkdownLibrary(resolve(projectRoot, "prompts/skills")) });
|
|
303
|
+
|
|
304
|
+
case path.startsWith("/skills/"): {
|
|
305
|
+
const name = decodeURIComponent(path.slice("/skills/".length).replace(/\/file$/, ""));
|
|
306
|
+
const found = readMarkdownLibrary(resolve(projectRoot, "prompts/skills")).find((s) => s.name === name || s.id === name);
|
|
307
|
+
// SkillsView reads `d.skill`, matching the server's shape. Not the bare object.
|
|
308
|
+
return found ? json(res, { skill: found }) : json(res, { error: "skill not found" }, 404);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case path === "/prompt-blocks":
|
|
312
|
+
return json(res, { blocks: readMarkdownLibrary(resolve(projectRoot, "prompts/blocks")) });
|
|
313
|
+
|
|
314
|
+
// Registry toggles are a DEPLOYED concept: they persist to installed_plugins
|
|
315
|
+
// and decide what a running universe exposes. Local Studio has no database and
|
|
316
|
+
// no universe, so there is nothing to toggle. Report an empty state (the
|
|
317
|
+
// screens then fall back to their defaults) and accept writes as no-ops rather
|
|
318
|
+
// than erroring, so the shared screens need no local special-casing.
|
|
319
|
+
case path === "/registry/state":
|
|
320
|
+
if (req.method === "POST") return json(res, { ok: true });
|
|
321
|
+
return json(res, { entries: [] });
|
|
322
|
+
|
|
323
|
+
// ---- The developer's OWN nodes (their nodes/ folder). No marketplace. ----
|
|
324
|
+
|
|
325
|
+
case path === "/nodes": {
|
|
326
|
+
const n = await nodes();
|
|
327
|
+
if (!n) return json(res, { nodes: [], credentialTypes: [], reason: "no nodes/ folder in this project" });
|
|
328
|
+
return json(res, { nodes: n.list(), credentialTypes: n.credentialTypes() });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Semantic ranking needs embeddings, which need a model and a key. Offline
|
|
332
|
+
// the screen already falls back to a local substring match on `ranked:false`,
|
|
333
|
+
// so say so honestly rather than pretending to rank.
|
|
334
|
+
case path === "/nodes/search":
|
|
335
|
+
return json(res, { ranked: false, nodes: [] });
|
|
336
|
+
|
|
337
|
+
// Which env variable each credential field reads from, and whether it is
|
|
338
|
+
// set. Names only, never values. Studio never WRITES a secret: keys live in
|
|
339
|
+
// the developer's own .env, which they already have and already gitignore.
|
|
340
|
+
case path === "/keys": {
|
|
341
|
+
const n = await nodes();
|
|
342
|
+
const types = (n?.credentialTypes() ?? []) as { name: string; properties?: { name: string }[] }[];
|
|
343
|
+
return json(res, { present: keyPresence(projectRoot, types) });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Run one node against mock inputs, with the developer's own keys read
|
|
347
|
+
// from disk here (they never travel to the browser).
|
|
348
|
+
case path === "/execute" && req.method === "POST": {
|
|
349
|
+
const n = await nodes();
|
|
350
|
+
if (!n) return json(res, { error: "no nodes/ folder in this project" }, 404);
|
|
351
|
+
const b = await body();
|
|
352
|
+
const types = n.credentialTypes() as { name: string; properties?: { name: string }[] }[];
|
|
353
|
+
const keys = credentialsFromEnv(projectRoot, types);
|
|
354
|
+
try {
|
|
355
|
+
const out = await n.execute(String(b.nodeType), b.inputs ?? {}, b.config ?? {}, {
|
|
356
|
+
// The execution context a node reads its credentials from. Local
|
|
357
|
+
// testing only: one developer, one machine, their own keys.
|
|
358
|
+
credentials: keys,
|
|
359
|
+
workflowId: "studio-local",
|
|
360
|
+
executionId: `local-${Date.now()}`,
|
|
361
|
+
});
|
|
362
|
+
return json(res, out ?? {});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
// A failing node is the normal case while developing one. Report it as
|
|
365
|
+
// a result to display, not as a broken tool.
|
|
366
|
+
return json(res, { error: (err as Error).message, stack: (err as Error).stack }, 200);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
default:
|
|
371
|
+
return next();
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
server.config.logger.error(`[studio] ${path}: ${(err as Error).message}`);
|
|
375
|
+
return json(res, { error: (err as Error).message }, 500);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Definitions are fetched at runtime, so Vite never sees them in the module graph.
|
|
382
|
+
* Watch the developer's rx/ and prompts/ and force a reload on any change: edit in
|
|
383
|
+
* your IDE, Studio updates. This is the same watcher the old coupled studio had.
|
|
384
|
+
*/
|
|
385
|
+
handleHotUpdate({ server, file }) {
|
|
386
|
+
const watched = [resolve(projectRoot, "rx"), resolve(projectRoot, "prompts")];
|
|
387
|
+
if (watched.some((w) => file.startsWith(w))) {
|
|
388
|
+
server.ws.send({ type: "full-reload" });
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
configResolved() {
|
|
394
|
+
// Surface a clear failure at startup rather than an empty catalog later.
|
|
395
|
+
if (projectRoot && !existsSync(resolve(projectRoot, "rx"))) {
|
|
396
|
+
throw new Error(`No rx/ folder at ${projectRoot}`);
|
|
397
|
+
}
|
|
398
|
+
if (projectRoot) statSync(resolve(projectRoot, "rx"));
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for scaffold.mjs, which is plain ESM because `bin/studio.mjs` runs unbuilt and
|
|
3
|
+
* cannot transpile TypeScript. Same arrangement as the linters in `base`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Null when the name is usable, otherwise the reason to show beside the field. */
|
|
7
|
+
export function checkProjectName(name: string): string | null;
|
|
8
|
+
|
|
9
|
+
/** Create rx/<name>/ under `home`. Returns the created path. Throws if unusable. */
|
|
10
|
+
export function createProject(home: string, name: string): string;
|
|
11
|
+
|
|
12
|
+
/** The first project, plus prompts/ and nodes/ beside rx/. Returns the project path. */
|
|
13
|
+
export function createFirstProject(home: string, name: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating a project, from either end.
|
|
3
|
+
*
|
|
4
|
+
* A project (an "org" in rx terms) is `rx/<name>/` holding components, styles and
|
|
5
|
+
* templates. It is the unit a developer authors in and the unit that publishes: every row
|
|
6
|
+
* that lands in a universe carries its name in the `org` column.
|
|
7
|
+
*
|
|
8
|
+
* ONE IMPLEMENTATION, TWO CALLERS. `bin/studio.mjs` creates the FIRST project at launch,
|
|
9
|
+
* when there is nowhere to click; the Studio UI creates every one after that. Written
|
|
10
|
+
* twice, they would drift, and the difference would be invisible: two projects that look
|
|
11
|
+
* alike and disagree about what a starting component contains. So this file is plain ESM
|
|
12
|
+
* rather than TypeScript, because the CLI runs unbuilt and cannot transpile.
|
|
13
|
+
*
|
|
14
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A name that is safe as a folder AND legal as an item's `org`.
|
|
21
|
+
*
|
|
22
|
+
* Refuses rather than sanitises. Quietly turning "My Project" into "my-project" creates a
|
|
23
|
+
* folder the developer did not ask for and cannot find, and the name is about to travel
|
|
24
|
+
* into a database column and a primary key, where a surprise is expensive.
|
|
25
|
+
*/
|
|
26
|
+
export function checkProjectName(name) {
|
|
27
|
+
const n = String(name ?? "").trim();
|
|
28
|
+
if (!n) return "A name is required";
|
|
29
|
+
// RESERVED FIRST, so the message is the true reason. These folders under rx/ are never a
|
|
30
|
+
// developer's own project (collect.ts NOT_A_PROJECT), and a project named `marketplace`
|
|
31
|
+
// would shadow the design system in every loader that resolves one. Checked before the
|
|
32
|
+
// format rule because `_schema` fails that too, and "use lowercase letters" would send a
|
|
33
|
+
// developer off renaming when no name of that shape can ever be accepted.
|
|
34
|
+
if (["marketplace", "_schema", "orgs"].includes(n)) return `"${n}" is reserved`;
|
|
35
|
+
if (!/^[a-z][a-z0-9-]{1,38}$/.test(n))
|
|
36
|
+
return "Use lowercase letters, numbers and dashes, starting with a letter (2 to 39 characters)";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The component Studio opens on, so a new project is never an empty screen. */
|
|
41
|
+
const WELCOME = [
|
|
42
|
+
"unoverse: '1.0'",
|
|
43
|
+
"kind: component",
|
|
44
|
+
"name: Welcome",
|
|
45
|
+
"category: Content",
|
|
46
|
+
"description: A first component. Edit this file and Studio updates as you save.",
|
|
47
|
+
"root:",
|
|
48
|
+
" type: Stack",
|
|
49
|
+
" style: { gap: '4', padding: '6' }",
|
|
50
|
+
" children:",
|
|
51
|
+
" - type: Text",
|
|
52
|
+
" value: Hello from your first component",
|
|
53
|
+
" style: { fontSize: 'lg', color: 'text.primary' }",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create `rx/<name>/` under `home`, with its three authoring folders and one component.
|
|
59
|
+
*
|
|
60
|
+
* Refuses an existing name rather than merging into it: "create" that silently adopts a
|
|
61
|
+
* folder is how a developer ends up publishing someone else's work under their own project.
|
|
62
|
+
*/
|
|
63
|
+
export function createProject(home, name) {
|
|
64
|
+
const bad = checkProjectName(name);
|
|
65
|
+
if (bad) throw new Error(bad);
|
|
66
|
+
|
|
67
|
+
const root = join(home, "rx", name);
|
|
68
|
+
if (existsSync(root)) throw new Error(`rx/${name} already exists`);
|
|
69
|
+
|
|
70
|
+
for (const d of ["components/welcome", "templates", "styles"]) mkdirSync(join(root, d), { recursive: true });
|
|
71
|
+
writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The FIRST project, plus the folders that live beside rx/ rather than inside it.
|
|
77
|
+
*
|
|
78
|
+
* Skills, prompt blocks and nodes are per-developer and not per-project, so they are made
|
|
79
|
+
* once here and never by `createProject`.
|
|
80
|
+
*/
|
|
81
|
+
export function createFirstProject(home, name) {
|
|
82
|
+
const root = createProject(home, name);
|
|
83
|
+
for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
|
|
84
|
+
return root;
|
|
85
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unoverse-platform/studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"prepack": "node scripts/vendor-sdk.mjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
15
|
+
"@unoverse-platform/base": "^0.2.0",
|
|
16
|
+
"@unoverse-platform/marketplace": "*",
|
|
17
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
18
|
+
"oidc-client-ts": "^3.4.1",
|
|
19
|
+
"react": "^18.2.0",
|
|
20
|
+
"react-dom": "^18.2.0",
|
|
21
|
+
"react-oidc-context": "^3.1.0",
|
|
22
|
+
"tailwindcss": "^4.0.0",
|
|
23
|
+
"vite": "^6.0.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"bin": {
|
|
27
|
+
"unoverse-studio": "./bin/studio.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"screens",
|
|
33
|
+
"local",
|
|
34
|
+
"vendor",
|
|
35
|
+
"public",
|
|
36
|
+
"index.html",
|
|
37
|
+
"vite.config.ts",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/public/logo.png
ADDED
|
Binary file
|