@unoverse-platform/studio 0.1.3 → 0.1.5
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/index.html +1 -0
- package/local/authoring.ts +101 -0
- package/local/catalog.ts +6 -0
- package/local/mcp.ts +260 -0
- package/local/plugin.ts +249 -7
- package/local/scaffold.mjs +14 -2
- package/local/source.ts +230 -0
- package/package.json +15 -2
- package/public/favicon.svg +4 -0
- package/screens/AtomsView.tsx +42 -11
- package/screens/ComponentsView.tsx +175 -18
- package/screens/SourceView.tsx +784 -0
- package/screens/TemplatesView.tsx +16 -4
- package/screens/index.ts +3 -0
- package/screens/source/FileTree.tsx +131 -0
- package/screens/source/completion.ts +152 -0
- package/screens/source/highlight.ts +72 -0
- package/screens/source/inspect.ts +103 -0
- package/screens/source/picker.ts +89 -0
- package/src/host.ts +26 -1
- package/vendor/sdk/appEngine.tsx +1 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +101 -12
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/client.ts +33 -6
- package/vendor/sdk/lib/primitives.tsx +33 -4
- package/vendor/sdk/lib/render.tsx +19 -4
- package/vendor/sdk/lib/streamed.tsx +12 -47
- package/vendor/sdk/lib/template.tsx +3 -3
- package/vendor/skills/unoverse-create/SKILL.md +88 -0
- package/vendor/skills/unoverse-create/references/agent-skill.md +65 -0
- package/vendor/skills/unoverse-create/references/block.md +43 -0
- package/vendor/skills/unoverse-create/references/component.md +152 -0
- package/vendor/skills/unoverse-create/references/node.md +175 -0
- package/vendor/skills/unoverse-create/references/template.md +115 -0
- package/vendor/skills/unoverse-create/references/workflow.md +52 -0
package/local/plugin.ts
CHANGED
|
@@ -16,12 +16,53 @@
|
|
|
16
16
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
17
17
|
*/
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
19
20
|
import { resolve } from "path";
|
|
20
21
|
import { readdirSync, existsSync, readFileSync, statSync } from "fs";
|
|
21
22
|
import type { Plugin, ViteDevServer } from "vite";
|
|
22
23
|
import { findProjectRoot, loadCatalog } from "./catalog.js";
|
|
23
24
|
import { loadLocalNodes, type LocalNodes } from "./nodes.js";
|
|
24
25
|
import { credentialsFromEnv, keyPresence, readEnv } from "./keys.js";
|
|
26
|
+
import { readSource, saveSource, sourceFiles, writableRoots } from "./source.js";
|
|
27
|
+
import { atomNames, vocabulary } from "./authoring.js";
|
|
28
|
+
import { buildTools, handleMcp, mintPairingCode } from "./mcp.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A definition URI's home on disk: a component's folder, or a template's.
|
|
32
|
+
*
|
|
33
|
+
* Resolution is the loaders', not this file's: `componentFolder` already knows a
|
|
34
|
+
* component lives in either tier and that names are unique across them. Templates are
|
|
35
|
+
* manifest-only under their own project, which `projectDir` locates.
|
|
36
|
+
*/
|
|
37
|
+
function definitionHome(
|
|
38
|
+
uri: string,
|
|
39
|
+
c: { componentFolder: (ref: string) => string | null; projectDir: (n: string) => string; listOrgs: () => string[] },
|
|
40
|
+
projectRoot: string,
|
|
41
|
+
): string | null {
|
|
42
|
+
const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "");
|
|
43
|
+
if (/\/(apps|templates)\//.test(uri)) {
|
|
44
|
+
const i = ref.indexOf("/");
|
|
45
|
+
const [org, name] = i === -1 ? [null, ref] : [ref.slice(0, i), ref.slice(i + 1)];
|
|
46
|
+
const orgs = org ? [org] : c.listOrgs();
|
|
47
|
+
for (const o of orgs) {
|
|
48
|
+
const dir = resolve(c.projectDir(o), "templates", name.toLowerCase());
|
|
49
|
+
if (existsSync(dir)) return dir;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (/\/atoms\//.test(uri)) {
|
|
54
|
+
const flat = resolve(projectRoot, "rx/atoms", `${ref.toLowerCase()}.json`);
|
|
55
|
+
return existsSync(flat) ? flat : null;
|
|
56
|
+
}
|
|
57
|
+
const folder = c.componentFolder(ref);
|
|
58
|
+
if (folder) return folder;
|
|
59
|
+
// A flat, folderless component: `rx/components/<name>.json` in either tier.
|
|
60
|
+
for (const dir of [resolve(projectRoot, "rx/marketplace/components"), resolve(projectRoot, "rx/components")]) {
|
|
61
|
+
const flat = resolve(dir, `${ref.toLowerCase()}.json`);
|
|
62
|
+
if (existsSync(flat)) return flat;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
25
66
|
|
|
26
67
|
/** JSON response helper matching the server's content type. */
|
|
27
68
|
function json(res: import("http").ServerResponse, body: unknown, status = 200): void {
|
|
@@ -102,9 +143,27 @@ function readMarkdownLibrary(dir: string): MarkdownEntry[] {
|
|
|
102
143
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
144
|
}
|
|
104
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Files this server has just written, so the watcher can tell Studio's own save apart from
|
|
148
|
+
* the developer's other editor. The window only has to outlast the filesystem event, which
|
|
149
|
+
* arrives in milliseconds; a stale entry costs one skipped reload and nothing else.
|
|
150
|
+
*/
|
|
151
|
+
const recentSelfWrites = new Map<string, number>();
|
|
152
|
+
const SELF_WRITE_WINDOW_MS = 2000;
|
|
153
|
+
|
|
105
154
|
export function localStudioHost(): Plugin {
|
|
106
155
|
let projectRoot: string;
|
|
107
156
|
let baseSrc: string;
|
|
157
|
+
/** The node the developer last picked in the preview, for an agent to read. */
|
|
158
|
+
let selection: Record<string, unknown> | null = null;
|
|
159
|
+
/** The agent tool surface, built on first use so the catalog is loaded by then. */
|
|
160
|
+
let mcpTools: ReturnType<typeof buildTools> | null = null;
|
|
161
|
+
/** This package's own root, for the vendored authoring skill. */
|
|
162
|
+
// Derived from this module's own URL, never RESOLVED: `createRequire().resolve("./plugin.js")`
|
|
163
|
+
// throws here, because Vite loads the TypeScript source and no sibling .js exists. Throwing
|
|
164
|
+
// at plugin construction kills the config load, which stops the dev server restarting at
|
|
165
|
+
// all, and the symptom is every new route 404ing with no error in the browser.
|
|
166
|
+
const appRoot = resolve(fileURLToPath(import.meta.url), "../..");
|
|
108
167
|
|
|
109
168
|
return {
|
|
110
169
|
name: "local-studio-host",
|
|
@@ -138,6 +197,22 @@ export function localStudioHost(): Plugin {
|
|
|
138
197
|
|
|
139
198
|
server.config.logger.info(`\n Studio reading: ${resolve(projectRoot, "rx")}\n`);
|
|
140
199
|
|
|
200
|
+
/**
|
|
201
|
+
* WATCH THE PROJECT EXPLICITLY. Vite watches its own root, which is this package,
|
|
202
|
+
* and the developer's project is somewhere else entirely: another folder in this
|
|
203
|
+
* monorepo, or the parent of node_modules once Studio is installed. So the
|
|
204
|
+
* `handleHotUpdate` hook below never saw a definition change and the "edit in your
|
|
205
|
+
* IDE, Studio updates" it promised did not happen (verified 2026-08-01: the watcher
|
|
206
|
+
* held 19 directories, all under packages/studio, and a touch of a real rx/ file
|
|
207
|
+
* raised no event).
|
|
208
|
+
*
|
|
209
|
+
* Adding the paths puts them in the same watcher, so the existing hook fires.
|
|
210
|
+
*/
|
|
211
|
+
for (const dir of ["rx", "prompts"]) {
|
|
212
|
+
const full = resolve(projectRoot, dir);
|
|
213
|
+
if (existsSync(full)) server.watcher.add(full);
|
|
214
|
+
}
|
|
215
|
+
|
|
141
216
|
server.middlewares.use(async (req, res, next) => {
|
|
142
217
|
const url = new URL(req.url ?? "/", "http://local");
|
|
143
218
|
const path = url.pathname;
|
|
@@ -146,7 +221,7 @@ export function localStudioHost(): Plugin {
|
|
|
146
221
|
// 200. That has now cost three separate bugs (the /api allowlist, the dev proxy for
|
|
147
222
|
// /.well-known and /publish), each of which looked like a missing route rather than
|
|
148
223
|
// a filtered one.
|
|
149
|
-
if (!/^\/(dev|local|skills|prompt-blocks|registry|nodes|keys|execute|publish)\b/.test(path)) return next();
|
|
224
|
+
if (!/^\/(dev|local|mcp|skills|prompt-blocks|registry|nodes|keys|execute|publish)\b/.test(path)) return next();
|
|
150
225
|
|
|
151
226
|
/** Read a JSON request body. */
|
|
152
227
|
const body = async (): Promise<Record<string, unknown>> => {
|
|
@@ -298,6 +373,166 @@ export function localStudioHost(): Plugin {
|
|
|
298
373
|
return d ? json(res, d) : json(res, { error: `definition not found: ${uri}` }, 404);
|
|
299
374
|
}
|
|
300
375
|
|
|
376
|
+
/**
|
|
377
|
+
* SOURCE VIEW (read-only at this step).
|
|
378
|
+
*
|
|
379
|
+
* The screens render a definition; this serves the FILES behind it, so a
|
|
380
|
+
* developer can read what they are looking at without leaving Studio. The
|
|
381
|
+
* writable flag on every file comes from source.ts, the single place that
|
|
382
|
+
* decides it, so the pane never invents its own idea of what may be edited.
|
|
383
|
+
*
|
|
384
|
+
* GET /local/source?uri=unoverse://components/<name> the file list
|
|
385
|
+
* GET /local/source?path=<abs> a skill or block
|
|
386
|
+
* GET /local/source/file?path=<abs> one file's text
|
|
387
|
+
*/
|
|
388
|
+
case path === "/local/source": {
|
|
389
|
+
const roots = writableRoots(projectRoot, c);
|
|
390
|
+
const direct = url.searchParams.get("path");
|
|
391
|
+
if (direct) return json(res, { files: sourceFiles(direct, roots, projectRoot) });
|
|
392
|
+
|
|
393
|
+
const uri = url.searchParams.get("uri") ?? "";
|
|
394
|
+
const home = definitionHome(uri, c, projectRoot);
|
|
395
|
+
if (!home) return json(res, { error: `no source for ${uri}` }, 404);
|
|
396
|
+
return json(res, { home, files: sourceFiles(home, roots, projectRoot) });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case path === "/local/source/file" && req.method !== "POST": {
|
|
400
|
+
const p = url.searchParams.get("path") ?? "";
|
|
401
|
+
const found = readSource(p, writableRoots(projectRoot, c), projectRoot);
|
|
402
|
+
return found ? json(res, found) : json(res, { error: "not readable" }, 404);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* THE WRITE CHOKEPOINT. Every write local Studio performs goes through here:
|
|
407
|
+
* the developer typing in the source pane, and the connected agent calling
|
|
408
|
+
* saveDefinition. One gate, one rule set, no second implementation.
|
|
409
|
+
*
|
|
410
|
+
* It refuses installed assets outright and rolls back a save that fails lint
|
|
411
|
+
* (source.ts saveSource). The linter is the SAME library publish and the CLI
|
|
412
|
+
* use, loaded the same way, so the three cannot disagree about a rule.
|
|
413
|
+
*/
|
|
414
|
+
case path === "/local/source/file" && req.method === "POST": {
|
|
415
|
+
const b = await body();
|
|
416
|
+
const p = String(b.path ?? "");
|
|
417
|
+
if (!p || typeof b.text !== "string") return json(res, { error: "path and text are required" }, 400);
|
|
418
|
+
|
|
419
|
+
type LintModule = { lintDefinitions(rxHome: string): { problems: { level?: string; file?: string }[] } };
|
|
420
|
+
const lintMod = (await ssr(`${baseSrc}/lint/rx/index.mjs`)) as unknown as LintModule;
|
|
421
|
+
|
|
422
|
+
// Marked BEFORE the write, so the filesystem event can never beat the mark.
|
|
423
|
+
recentSelfWrites.set(resolve(p), Date.now());
|
|
424
|
+
const result = await saveSource(
|
|
425
|
+
p,
|
|
426
|
+
b.text as string,
|
|
427
|
+
writableRoots(projectRoot, c),
|
|
428
|
+
projectRoot,
|
|
429
|
+
(rxHome) => lintMod.lintDefinitions(rxHome),
|
|
430
|
+
);
|
|
431
|
+
return json(res, result, result.saved ? 200 : result.error === "lint errors" ? 422 : 403);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* DIAGNOSTICS FOR UNSAVED TEXT, from the one linter.
|
|
436
|
+
*
|
|
437
|
+
* The editor posts what the developer has typed and the shared library judges
|
|
438
|
+
* it through an overlay, so a squiggle in the pane is the same rule publish
|
|
439
|
+
* and CI apply. Findings for other files come back separately: a developer
|
|
440
|
+
* fixing this file should not be shouted at about that one.
|
|
441
|
+
*/
|
|
442
|
+
case path === "/local/lint/draft": {
|
|
443
|
+
const b = await body();
|
|
444
|
+
const p = String(b.path ?? "");
|
|
445
|
+
if (!p || typeof b.text !== "string") return json(res, { error: "path and text are required" }, 400);
|
|
446
|
+
|
|
447
|
+
type LintModule = {
|
|
448
|
+
lintDefinitions(
|
|
449
|
+
rxHome: string,
|
|
450
|
+
options?: { overlay?: Record<string, string> },
|
|
451
|
+
): { problems: { level?: string; file?: string; line?: number; msg?: string }[] };
|
|
452
|
+
};
|
|
453
|
+
const lintMod = (await ssr(`${baseSrc}/lint/rx/index.mjs`)) as unknown as LintModule;
|
|
454
|
+
const abs = resolve(p);
|
|
455
|
+
const { problems } = lintMod.lintDefinitions(resolve(projectRoot, "rx"), { overlay: { [abs]: b.text as string } });
|
|
456
|
+
return json(res, {
|
|
457
|
+
findings: problems.filter((f) => f.file && resolve(f.file) === abs),
|
|
458
|
+
elsewhere: problems.filter((f) => !f.file || resolve(f.file) !== abs).length,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* THE CLOSED VOCABULARY, for completion. Data, not rules: the same sets the
|
|
464
|
+
* linter enforces, plus this project's own token, atom and component names.
|
|
465
|
+
*/
|
|
466
|
+
case path === "/local/vocabulary": {
|
|
467
|
+
const vocabMod = (await ssr(`${baseSrc}/lint/rx/vocabulary.mjs`)) as unknown as Parameters<typeof vocabulary>[0];
|
|
468
|
+
const theme = c.resolveTheme(url.searchParams.get("theme") ?? "light");
|
|
469
|
+
return json(
|
|
470
|
+
res,
|
|
471
|
+
vocabulary(vocabMod, theme, atomNames(projectRoot), c.listDefinitions("component") as { name?: string }[]),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* WHAT THE DEVELOPER IS POINTING AT.
|
|
477
|
+
*
|
|
478
|
+
* The editor posts the node it selected; an agent reads it back. "Make this
|
|
479
|
+
* bigger" is only answerable if something outside the developer's head knows
|
|
480
|
+
* what "this" is, and the picker is how they say it: a click on the rendered
|
|
481
|
+
* element resolves to a file, a line and the node's own definition.
|
|
482
|
+
*
|
|
483
|
+
* IN MEMORY, AND LAST WINS. It describes what is on screen right now, so it
|
|
484
|
+
* belongs to the running server and not to disk.
|
|
485
|
+
*/
|
|
486
|
+
case path === "/local/selection" && req.method === "POST": {
|
|
487
|
+
selection = { ...(await body()), at: new Date().toISOString() };
|
|
488
|
+
return json(res, { ok: true });
|
|
489
|
+
}
|
|
490
|
+
case path === "/local/selection":
|
|
491
|
+
return json(res, selection ?? { empty: true });
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* CONNECT AN AGENT. Mints the code the developer pastes into Claude, the same
|
|
495
|
+
* flow Canvas uses. Ten minutes, single use, burned on redeem.
|
|
496
|
+
*/
|
|
497
|
+
case path === "/local/pair" && req.method === "POST":
|
|
498
|
+
return json(res, mintPairingCode());
|
|
499
|
+
|
|
500
|
+
/** The MCP endpoint itself. Same origin, same port, no separate install. */
|
|
501
|
+
case path === "/mcp" && req.method === "POST": {
|
|
502
|
+
mcpTools ??= buildTools(
|
|
503
|
+
{
|
|
504
|
+
projectRoot,
|
|
505
|
+
catalog: async () => (await catalog) as never,
|
|
506
|
+
sourceFiles: (uri) => {
|
|
507
|
+
const home = definitionHome(uri, c, projectRoot);
|
|
508
|
+
return home ? sourceFiles(home, writableRoots(projectRoot, c), projectRoot) : [];
|
|
509
|
+
},
|
|
510
|
+
readSource: (p) => readSource(p, writableRoots(projectRoot, c), projectRoot),
|
|
511
|
+
saveSource: async (p, text) => {
|
|
512
|
+
type LintModule = { lintDefinitions(rxHome: string): { problems: { level?: string; file?: string }[] } };
|
|
513
|
+
const lintMod = (await ssr(`${baseSrc}/lint/rx/index.mjs`)) as unknown as LintModule;
|
|
514
|
+
recentSelfWrites.set(resolve(p), Date.now());
|
|
515
|
+
return saveSource(p, text, writableRoots(projectRoot, c), projectRoot, (rxHome) =>
|
|
516
|
+
lintMod.lintDefinitions(rxHome),
|
|
517
|
+
);
|
|
518
|
+
},
|
|
519
|
+
lintProject: async () => {
|
|
520
|
+
type LintModule = { lintDefinitions(rxHome: string): { problems: unknown[] } };
|
|
521
|
+
const lintMod = (await ssr(`${baseSrc}/lint/rx/index.mjs`)) as unknown as LintModule;
|
|
522
|
+
return lintMod.lintDefinitions(resolve(projectRoot, "rx"));
|
|
523
|
+
},
|
|
524
|
+
selection: () => selection,
|
|
525
|
+
vocabulary: async () => {
|
|
526
|
+
const vocabMod = (await ssr(`${baseSrc}/lint/rx/vocabulary.mjs`)) as unknown as Parameters<typeof vocabulary>[0];
|
|
527
|
+
return vocabulary(vocabMod, c.resolveTheme("light"), atomNames(projectRoot), c.listDefinitions("component") as { name?: string }[]);
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
appRoot,
|
|
531
|
+
);
|
|
532
|
+
await handleMcp(req, res, await body(), mcpTools);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
301
536
|
case path === "/skills":
|
|
302
537
|
return json(res, { skills: readMarkdownLibrary(resolve(projectRoot, "prompts/skills")) });
|
|
303
538
|
|
|
@@ -379,15 +614,22 @@ export function localStudioHost(): Plugin {
|
|
|
379
614
|
|
|
380
615
|
/**
|
|
381
616
|
* Definitions are fetched at runtime, so Vite never sees them in the module graph.
|
|
382
|
-
* Watch the developer's rx/ and prompts/ and
|
|
383
|
-
*
|
|
617
|
+
* Watch the developer's rx/ and prompts/ and reload on any change: edit in your IDE,
|
|
618
|
+
* Studio updates.
|
|
619
|
+
*
|
|
620
|
+
* A SAVE FROM STUDIO'S OWN EDITOR IS NOT AN EXTERNAL EDIT. Reloading the page on it
|
|
621
|
+
* would throw away the cursor, the scroll position, the open file and any unsaved work
|
|
622
|
+
* in another tab, every time the developer pressed Cmd+S. The editor already refreshes
|
|
623
|
+
* the preview itself, so a write this server just performed is skipped here. Anything
|
|
624
|
+
* else, which means the developer's other editor, still reloads.
|
|
384
625
|
*/
|
|
385
626
|
handleHotUpdate({ server, file }) {
|
|
386
627
|
const watched = [resolve(projectRoot, "rx"), resolve(projectRoot, "prompts")];
|
|
387
|
-
if (watched.some((w) => file.startsWith(w)))
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
628
|
+
if (!watched.some((w) => file.startsWith(w))) return;
|
|
629
|
+
const self = recentSelfWrites.get(resolve(file));
|
|
630
|
+
if (self !== undefined && Date.now() - self < SELF_WRITE_WINDOW_MS) return [];
|
|
631
|
+
server.ws.send({ type: "full-reload" });
|
|
632
|
+
return [];
|
|
391
633
|
},
|
|
392
634
|
|
|
393
635
|
configResolved() {
|
package/local/scaffold.mjs
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync } from "node:fs";
|
|
17
|
+
import { join, dirname, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* A name that is safe as a folder AND legal as an item's `org`.
|
|
@@ -81,5 +82,16 @@ export function createProject(home, name) {
|
|
|
81
82
|
export function createFirstProject(home, name) {
|
|
82
83
|
const root = createProject(home, name);
|
|
83
84
|
for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// The Claude Code authoring skill, so "open the folder and describe what you want"
|
|
87
|
+
// works in every Studio project from its first minute. Vendored into the published
|
|
88
|
+
// app (vendor/skills, written by prepack); read from the repo when running as source.
|
|
89
|
+
const app = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
90
|
+
const skill = [join(app, "vendor/skills/unoverse-create"), resolve(app, "../../.claude/skills/unoverse-create")]
|
|
91
|
+
.find((d) => existsSync(d));
|
|
92
|
+
if (skill) {
|
|
93
|
+
mkdirSync(join(home, ".claude/skills"), { recursive: true });
|
|
94
|
+
cpSync(skill, join(home, ".claude/skills/unoverse-create"), { recursive: true });
|
|
95
|
+
}
|
|
84
96
|
return root;
|
|
85
97
|
}
|
package/local/source.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOURCE ACCESS: which files local Studio may show, and which it may write.
|
|
3
|
+
*
|
|
4
|
+
* Studio previews definitions. The source view shows the file behind one, and the write
|
|
5
|
+
* route (step 3) saves it back. Both ask this module first, so there is ONE answer to
|
|
6
|
+
* "may this be written" and it cannot drift between the human typing in the pane and a
|
|
7
|
+
* connected agent calling saveDefinition.
|
|
8
|
+
*
|
|
9
|
+
* THE WRITABLE SET IS NAMED, NOT INFERRED. A path check alone is wrong in this monorepo:
|
|
10
|
+
* the design-system source lives on disk INSIDE the project root (definitions.ts
|
|
11
|
+
* `marketplaceDir` prefers `rx/marketplace/<kind>` when it exists), so "under projectRoot"
|
|
12
|
+
* would happily let a developer edit the design system. Installed assets are read-only
|
|
13
|
+
* everywhere: an edit to one is invisible to every other machine and lost on the next
|
|
14
|
+
* install, and §Design-system resolution is what keeps the token contract stable.
|
|
15
|
+
*
|
|
16
|
+
* So the writable set is exactly the developer's own work:
|
|
17
|
+
* rx/<project>/** every project listOrgs() reports, via projectDir()
|
|
18
|
+
* prompts/** skills and prompt blocks
|
|
19
|
+
* nodes/** their own node packages
|
|
20
|
+
* Everything else is readable and not writable, the marketplace and anything resolved out
|
|
21
|
+
* of node_modules included.
|
|
22
|
+
*
|
|
23
|
+
* See docs/architecture/LOCAL_STUDIO.md §Authoring in Studio.
|
|
24
|
+
*/
|
|
25
|
+
import { resolve, sep, relative } from "path";
|
|
26
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
27
|
+
|
|
28
|
+
/** The reason a path is not writable. Shown to the developer and returned to the agent. */
|
|
29
|
+
export type ReadOnlyReason = "installed" | "outside-project" | "not-a-file";
|
|
30
|
+
|
|
31
|
+
export interface Writability {
|
|
32
|
+
writable: boolean;
|
|
33
|
+
reason?: ReadOnlyReason;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when `child` is `parent` or sits beneath it. Path-separator aware, so a sibling
|
|
37
|
+
* folder sharing a name prefix (`rx/shop` vs `rx/shop-admin`) is not a match. */
|
|
38
|
+
function within(parent: string, child: string): boolean {
|
|
39
|
+
const rel = relative(parent, child);
|
|
40
|
+
return rel === "" || (!rel.startsWith("..") && !resolve(child).includes(`${sep}..${sep}`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The folders a developer may write, absolute and existing.
|
|
45
|
+
*
|
|
46
|
+
* Built from the SAME loaders that resolve definitions, so a project Studio can show is a
|
|
47
|
+
* project Studio can write, with no second notion of what a project is.
|
|
48
|
+
*/
|
|
49
|
+
export function writableRoots(
|
|
50
|
+
projectRoot: string,
|
|
51
|
+
loaders: { listOrgs: () => string[]; projectDir: (name: string) => string },
|
|
52
|
+
): string[] {
|
|
53
|
+
const roots = [
|
|
54
|
+
...loaders.listOrgs().map((org) => loaders.projectDir(org)),
|
|
55
|
+
resolve(projectRoot, "prompts"),
|
|
56
|
+
resolve(projectRoot, "nodes"),
|
|
57
|
+
];
|
|
58
|
+
return roots.map((r) => resolve(r)).filter((r) => existsSync(r));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* May this exact path be written?
|
|
63
|
+
*
|
|
64
|
+
* `roots` comes from writableRoots(). A path inside one of them is the developer's own
|
|
65
|
+
* work. Everything else is read-only, and the reason distinguishes the design system a
|
|
66
|
+
* developer builds ON (normal, shown without a save button) from a path that has no
|
|
67
|
+
* business being addressed at all.
|
|
68
|
+
*/
|
|
69
|
+
export function writability(path: string, roots: string[], projectRoot: string): Writability {
|
|
70
|
+
const abs = resolve(path);
|
|
71
|
+
if (roots.some((r) => within(r, abs))) return { writable: true };
|
|
72
|
+
// Inside the project but not in a writable root: the marketplace on disk, rx/_schema,
|
|
73
|
+
// or the legacy orgs/ container. Reading is the point; writing is what is refused.
|
|
74
|
+
if (within(resolve(projectRoot), abs)) return { writable: false, reason: "installed" };
|
|
75
|
+
// node_modules, or anywhere else entirely. The design system resolves here for a
|
|
76
|
+
// consumer, which is the same answer for a different reason.
|
|
77
|
+
return { writable: false, reason: abs.includes(`${sep}node_modules${sep}`) ? "installed" : "outside-project" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SourceFile {
|
|
81
|
+
/** Absolute, because the route is addressed by path and the UI shows where it reads. */
|
|
82
|
+
path: string;
|
|
83
|
+
/** Relative to the definition's folder, for display: `manifest.json`, `layouts/idle.json`. */
|
|
84
|
+
rel: string;
|
|
85
|
+
writable: boolean;
|
|
86
|
+
reason?: ReadOnlyReason;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Definition source, plus the lifecycle handlers a component folder may hold.
|
|
91
|
+
*
|
|
92
|
+
* YAML AND JSON BOTH. The rx YAML migration is under way, so the design system is `.yaml`
|
|
93
|
+
* today while other trees are still `.json` (verified 2026-08-01: every marketplace
|
|
94
|
+
* component folder held only `<name>.yaml`, and a JSON-only filter listed nothing at all).
|
|
95
|
+
*/
|
|
96
|
+
const SOURCE_EXT = /\.(ya?ml|json|jsonc|md|mjs|js)$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every source file behind one definition.
|
|
100
|
+
*
|
|
101
|
+
* A component is a FOLDER (`<name>.json` or `manifest.json`, plus `layouts/`, `states/`,
|
|
102
|
+
* `components/`), and a simple one is a single flat file. Both shapes are returned as a
|
|
103
|
+
* list so the pane never has to know which it is looking at.
|
|
104
|
+
*/
|
|
105
|
+
export function sourceFiles(folderOrFile: string, roots: string[], projectRoot: string): SourceFile[] {
|
|
106
|
+
const abs = resolve(folderOrFile);
|
|
107
|
+
if (!existsSync(abs)) return [];
|
|
108
|
+
const out: SourceFile[] = [];
|
|
109
|
+
const base = statSync(abs).isDirectory() ? abs : resolve(abs, "..");
|
|
110
|
+
|
|
111
|
+
const add = (p: string) => {
|
|
112
|
+
const w = writability(p, roots, projectRoot);
|
|
113
|
+
out.push({ path: p, rel: relative(base, p) || p.split(sep).pop()!, writable: w.writable, reason: w.reason });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (statSync(abs).isFile()) {
|
|
117
|
+
add(abs);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const walk = (dir: string) => {
|
|
122
|
+
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
123
|
+
const p = resolve(dir, e.name);
|
|
124
|
+
if (e.isDirectory()) walk(p);
|
|
125
|
+
else if (SOURCE_EXT.test(e.name)) add(p);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
walk(abs);
|
|
129
|
+
|
|
130
|
+
// The envelope first: it is what a developer opens the view to read. Nested files
|
|
131
|
+
// (layouts/, states/, components/) last, and the manifest between them.
|
|
132
|
+
return out.sort((a, b) => {
|
|
133
|
+
const rank = (f: SourceFile) => (f.rel.includes(sep) ? 2 : /^manifest\.(ya?ml|json)$/.test(f.rel) ? 1 : 0);
|
|
134
|
+
return rank(a) - rank(b) || a.rel.localeCompare(b.rel);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SaveResult {
|
|
139
|
+
saved: boolean;
|
|
140
|
+
/** Why a refusal, in the caller's terms. Shown in the pane, returned to an agent. */
|
|
141
|
+
error?: string;
|
|
142
|
+
/** Lint findings for THIS file. Errors here mean the write was rolled back. */
|
|
143
|
+
findings?: unknown[];
|
|
144
|
+
/** Findings elsewhere in the project. Reported, never blocking. */
|
|
145
|
+
otherFindings?: unknown[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Save one file, gated on the linter.
|
|
150
|
+
*
|
|
151
|
+
* THE GATE IS SCOPED TO THIS FILE. `lintDefinitions` reads the whole rx tree, so a gate
|
|
152
|
+
* built naively on its verdict refuses a clean save whenever some unrelated file is
|
|
153
|
+
* broken, and can never say which finding the edit caused. Findings for the saved file
|
|
154
|
+
* block; the rest are reported and do not.
|
|
155
|
+
*
|
|
156
|
+
* WRITE, LINT, ROLL BACK. The linter reads from disk and takes no in-memory overlay, so
|
|
157
|
+
* the file has to be on disk to be judged. A refusal therefore restores the previous
|
|
158
|
+
* bytes, which is why the developer sees an atomic accept-or-reject even though the
|
|
159
|
+
* sequence underneath is not. A new file that fails is removed rather than restored.
|
|
160
|
+
*/
|
|
161
|
+
export async function saveSource(
|
|
162
|
+
path: string,
|
|
163
|
+
text: string,
|
|
164
|
+
roots: string[],
|
|
165
|
+
projectRoot: string,
|
|
166
|
+
lint: (rxHome: string) => Promise<{ problems: LintFinding[] }> | { problems: LintFinding[] },
|
|
167
|
+
): Promise<SaveResult> {
|
|
168
|
+
const abs = resolve(path);
|
|
169
|
+
const w = writability(abs, roots, projectRoot);
|
|
170
|
+
if (!w.writable) {
|
|
171
|
+
const why =
|
|
172
|
+
w.reason === "installed"
|
|
173
|
+
? "installed assets are read-only: edit your own project, not the design system"
|
|
174
|
+
: "outside your project";
|
|
175
|
+
return { saved: false, error: why };
|
|
176
|
+
}
|
|
177
|
+
if (!existsSync(resolve(abs, "..")))
|
|
178
|
+
return { saved: false, error: `no folder for ${abs}` };
|
|
179
|
+
|
|
180
|
+
const existed = existsSync(abs);
|
|
181
|
+
const previous = existed ? readFileSync(abs, "utf8") : null;
|
|
182
|
+
if (previous === text) return { saved: true, findings: [], otherFindings: [] };
|
|
183
|
+
|
|
184
|
+
writeFileSync(abs, text, "utf8");
|
|
185
|
+
|
|
186
|
+
const rxHome = resolve(projectRoot, "rx");
|
|
187
|
+
let problems: LintFinding[] = [];
|
|
188
|
+
try {
|
|
189
|
+
problems = (await lint(rxHome)).problems ?? [];
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// A linter that throws must not silently accept the write.
|
|
192
|
+
if (previous === null) rmSync(abs, { force: true });
|
|
193
|
+
else writeFileSync(abs, previous, "utf8");
|
|
194
|
+
return { saved: false, error: `lint failed to run: ${(e as Error).message}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The linter reports paths relative to the working directory, and `resolve` reads the
|
|
198
|
+
// same directory, so the two agree by construction rather than by string matching.
|
|
199
|
+
const mine = problems.filter((p) => p.file && resolve(p.file) === abs);
|
|
200
|
+
const errors = mine.filter((p) => p.level === "error");
|
|
201
|
+
if (errors.length) {
|
|
202
|
+
if (previous === null) rmSync(abs, { force: true });
|
|
203
|
+
else writeFileSync(abs, previous, "utf8");
|
|
204
|
+
return { saved: false, error: "lint errors", findings: mine, otherFindings: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
saved: true,
|
|
209
|
+
findings: mine,
|
|
210
|
+
otherFindings: problems.filter((p) => !p.file || resolve(p.file) !== abs),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface LintFinding {
|
|
215
|
+
level?: string;
|
|
216
|
+
file?: string;
|
|
217
|
+
msg?: string;
|
|
218
|
+
[k: string]: unknown;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One file's text, refused unless it is a file Studio would list. */
|
|
222
|
+
export function readSource(path: string, roots: string[], projectRoot: string): { text: string; writable: boolean; reason?: ReadOnlyReason } | null {
|
|
223
|
+
const abs = resolve(path);
|
|
224
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
|
|
225
|
+
const w = writability(abs, roots, projectRoot);
|
|
226
|
+
// A path outside the project entirely is not shown. The design system is, because
|
|
227
|
+
// reading what you build on is the normal case.
|
|
228
|
+
if (w.reason === "outside-project" && !abs.includes(`${sep}node_modules${sep}`)) return null;
|
|
229
|
+
return { text: readFileSync(abs, "utf8"), writable: w.writable, reason: w.reason };
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unoverse-platform/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
|
|
6
6
|
"scripts": {
|
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
"prepack": "node scripts/vendor-sdk.mjs"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
+
"@codemirror/autocomplete": "^6.18.6",
|
|
15
|
+
"@codemirror/commands": "^6.8.1",
|
|
16
|
+
"@codemirror/lang-javascript": "^6.2.4",
|
|
17
|
+
"@codemirror/lang-json": "^6.0.1",
|
|
18
|
+
"@codemirror/lang-markdown": "^6.3.0",
|
|
19
|
+
"@codemirror/lang-yaml": "^6.1.2",
|
|
20
|
+
"@codemirror/language": "^6.11.0",
|
|
21
|
+
"@codemirror/lint": "^6.8.0",
|
|
22
|
+
"@codemirror/search": "^6.5.0",
|
|
23
|
+
"@codemirror/state": "^6.5.2",
|
|
24
|
+
"@codemirror/theme-one-dark": "^6.1.2",
|
|
25
|
+
"@codemirror/view": "^6.37.1",
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
14
27
|
"@tailwindcss/vite": "^4.0.0",
|
|
15
28
|
"@unoverse-platform/base": "^0.3.0",
|
|
16
29
|
"@unoverse-platform/marketplace": "*",
|
|
@@ -21,7 +34,7 @@
|
|
|
21
34
|
"react-oidc-context": "^3.1.0",
|
|
22
35
|
"tailwindcss": "^4.0.0",
|
|
23
36
|
"vite": "^6.0.0",
|
|
24
|
-
"
|
|
37
|
+
"yaml": "^2.4.0"
|
|
25
38
|
},
|
|
26
39
|
"license": "MIT",
|
|
27
40
|
"bin": {
|