@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.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -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
+ }
Binary file