@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/bin/studio.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* unoverse-studio — the authoring tool, run from a developer's own project.
|
|
4
|
+
*
|
|
5
|
+
* Studio is a TOOL, not a starter kit. A developer's repository holds only their assets
|
|
6
|
+
* (rx/, prompts/, nodes/); this is installed alongside and reads them. That is why there
|
|
7
|
+
* is no second repository to keep in step, and why their project contains no Docker, no
|
|
8
|
+
* ansible and no platform source.
|
|
9
|
+
*
|
|
10
|
+
* IT RUNS VITE, DELIBERATELY. Studio's file-reading host is a Vite plugin that loads the
|
|
11
|
+
* universe server's own disk loaders (local/plugin.ts, local/catalog.ts) rather than
|
|
12
|
+
* reimplementing them. Reproducing that in a bespoke server would fork those readers and
|
|
13
|
+
* they would drift apart within weeks, which is the exact problem local Studio was built
|
|
14
|
+
* to avoid. So the tool starts the same dev server `npm run dev` does.
|
|
15
|
+
*
|
|
16
|
+
* No database, no Redis, no engine, no Docker, and no account: those belong to running a
|
|
17
|
+
* universe, which is a different job. See docs/architecture/LOCAL_STUDIO.md.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from "vite";
|
|
20
|
+
import { dirname, resolve, join } from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
22
|
+
import { existsSync } from "fs";
|
|
23
|
+
import { createInterface } from "readline/promises";
|
|
24
|
+
import { createFirstProject, checkProjectName } from "../local/scaffold.mjs";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const appRoot = resolve(here, "..");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A project, if there is not one here yet.
|
|
31
|
+
*
|
|
32
|
+
* Studio USED to fail with "No rx/ folder found. Create rx/<your-project>/ to start one",
|
|
33
|
+
* which is a tool telling you to do the thing it could do. The first thirty seconds of a
|
|
34
|
+
* new install should end with something on screen, not with a mkdir.
|
|
35
|
+
*
|
|
36
|
+
* It asks rather than assumes: creating folders in whatever directory somebody happens to
|
|
37
|
+
* be in is not a thing to do quietly. Answer no and the original message stands.
|
|
38
|
+
*/
|
|
39
|
+
/** The same walk `catalog.ts` does: the nearest folder above us holding rx/. */
|
|
40
|
+
function findProjectRootFromCwd() {
|
|
41
|
+
for (let dir = process.cwd(); ; ) {
|
|
42
|
+
if (existsSync(join(dir, "rx"))) return dir;
|
|
43
|
+
if (existsSync(join(dir, "apps/unoverse/rx"))) return join(dir, "apps/unoverse");
|
|
44
|
+
const parent = resolve(dir, "..");
|
|
45
|
+
if (parent === dir) return process.cwd();
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function scaffoldIfEmpty() {
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
// The same walk findProjectRoot does. A project ABOVE us is a project; do not offer to
|
|
53
|
+
// make a second one inside it.
|
|
54
|
+
for (let dir = cwd; ; ) {
|
|
55
|
+
if (existsSync(join(dir, "rx")) || existsSync(join(dir, "apps/unoverse/rx"))) return;
|
|
56
|
+
const parent = resolve(dir, "..");
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`\n No project here yet.\n\n ${cwd}\n`);
|
|
62
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
63
|
+
|
|
64
|
+
// Ask until the name is usable. It becomes a folder AND an item's `org` column, so a
|
|
65
|
+
// rejection here is cheaper than a rename after something has been published.
|
|
66
|
+
let name;
|
|
67
|
+
for (;;) {
|
|
68
|
+
name = (await rl.question(" Name your project [my-project]: ")).trim() || "my-project";
|
|
69
|
+
const bad = checkProjectName(name);
|
|
70
|
+
if (!bad) break;
|
|
71
|
+
console.log(` ${bad}\n`);
|
|
72
|
+
}
|
|
73
|
+
rl.close();
|
|
74
|
+
|
|
75
|
+
// The same call the UI's New project makes, so the first project and the fifth are
|
|
76
|
+
// identical (local/scaffold.mjs).
|
|
77
|
+
createFirstProject(cwd, name);
|
|
78
|
+
console.log(`\n Created rx/${name}/, prompts/ and nodes/\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await scaffoldIfEmpty();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* POINT `base` AT THIS DEVELOPER'S PROJECT, before anything imports it.
|
|
85
|
+
*
|
|
86
|
+
* `paths.ts` snapshots its exports at import time (`export const RX_HOME = paths.rx`), so
|
|
87
|
+
* `setPaths()` called later updates an object nobody reads any more. The only hook that
|
|
88
|
+
* lands is the environment, read by `findHome()` on first import.
|
|
89
|
+
*
|
|
90
|
+
* Without this Studio fell back to walking up for `apps/unoverse`, so it worked perfectly
|
|
91
|
+
* in the monorepo it was written in and read the WRONG FOLDER everywhere else: a developer
|
|
92
|
+
* who had just created a project was shown the monorepo's. Silent, because both are valid
|
|
93
|
+
* directories and nothing errors.
|
|
94
|
+
*/
|
|
95
|
+
const projectRoot = findProjectRootFromCwd();
|
|
96
|
+
process.env.UNOVERSE_HOME = projectRoot;
|
|
97
|
+
console.log(`\n Studio reading: ${join(projectRoot, "rx")}\n`);
|
|
98
|
+
|
|
99
|
+
const server = await createServer({
|
|
100
|
+
// The APP is the vite root; the developer's project is found by walking up from the
|
|
101
|
+
// working directory (findProjectRoot), so Studio works from anywhere inside it.
|
|
102
|
+
root: appRoot,
|
|
103
|
+
configFile: resolve(appRoot, "vite.config.ts"),
|
|
104
|
+
// NO PORT OPTION. Studio is always 4108 (vite.config.ts, with strictPort), so the
|
|
105
|
+
// address in your browser's history is always the right one. A --port flag existed
|
|
106
|
+
// briefly and only produced sessions on 4117, 4118 and 4119 that nobody could find again.
|
|
107
|
+
server: { open: true },
|
|
108
|
+
// A tool should be quiet unless something is wrong.
|
|
109
|
+
logLevel: "warn",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await server.listen();
|
|
113
|
+
|
|
114
|
+
const url = server.resolvedUrls?.local?.[0] ?? `http://localhost:${server.config.server.port}`;
|
|
115
|
+
console.log(`\n unoverse studio ${url}\n`);
|
|
116
|
+
|
|
117
|
+
// Ctrl-C should stop the server rather than leave a listener holding the port, which is
|
|
118
|
+
// the thing that makes the next start fail with a confusing "port in use".
|
|
119
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
120
|
+
process.on(signal, async () => {
|
|
121
|
+
await server.close();
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>unoverse studio</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/local/catalog.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOCAL catalog: the same disk loaders the universe server uses, pointed at the
|
|
3
|
+
* developer's own project folder instead of a running platform.
|
|
4
|
+
*
|
|
5
|
+
* This is the whole reason local Studio needs no backend. `definitions.ts` and
|
|
6
|
+
* `theme.ts` are pure filesystem readers: no Postgres, no Redis, no engine. So local
|
|
7
|
+
* Studio does not reimplement them (a second loader would drift within weeks, which is
|
|
8
|
+
* the exact problem this migration exists to fix). It imports the SAME modules and sets
|
|
9
|
+
* RX_PATH to the developer's folder before they load.
|
|
10
|
+
*
|
|
11
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
12
|
+
*/
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
import { existsSync } from "fs";
|
|
15
|
+
|
|
16
|
+
/** The server's loader modules, loaded lazily so RX_PATH is set before they read it. */
|
|
17
|
+
type Loaders = {
|
|
18
|
+
listDefinitions: (kind: string, org?: string) => unknown[];
|
|
19
|
+
listStates: (kind: string, name: string) => unknown[];
|
|
20
|
+
listTemplatesForWorkbench: (org?: string) => unknown[];
|
|
21
|
+
loadDefinition: (ref: string, kind: string) => unknown;
|
|
22
|
+
listOrgs: () => string[];
|
|
23
|
+
inputPropKeys: (props: unknown) => string[];
|
|
24
|
+
themeOrgs: () => string[];
|
|
25
|
+
listThemeNames: (org: string) => string[];
|
|
26
|
+
resolveTheme: (name: string) => unknown;
|
|
27
|
+
DEFAULT_ORG: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the developer's project root: the folder holding `rx/`. Walks up from the
|
|
32
|
+
* working directory so `unoverse studio` works from anywhere inside the project, the
|
|
33
|
+
* same way git and npm behave. Throws rather than guessing, because silently reading
|
|
34
|
+
* the wrong folder is worse than not starting.
|
|
35
|
+
*/
|
|
36
|
+
export function findProjectRoot(from: string = process.cwd()): string {
|
|
37
|
+
let dir = resolve(from);
|
|
38
|
+
for (;;) {
|
|
39
|
+
// A developer's project (starter kit): rx/ sits at the root.
|
|
40
|
+
if (existsSync(resolve(dir, "rx"))) return dir;
|
|
41
|
+
// This monorepo, where Studio's author works: rx/ lives under apps/unoverse. Same
|
|
42
|
+
// shape underneath (rx/ + prompts/), just nested, so return that as the root.
|
|
43
|
+
if (existsSync(resolve(dir, "apps/unoverse/rx"))) return resolve(dir, "apps/unoverse");
|
|
44
|
+
const parent = resolve(dir, "..");
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(
|
|
49
|
+
`No rx/ folder found in ${from} or any parent. Run Studio from inside your project, ` +
|
|
50
|
+
`or create rx/<your-project>/ to start one.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load the server's disk loaders with RX_PATH pointed at this project.
|
|
56
|
+
*
|
|
57
|
+
* `baseSrc` is the path to @unoverse-platform/base's src, and `load` is Vite's
|
|
58
|
+
* ssrLoadModule (the dev server transpiles the TypeScript for us, so local Studio needs no
|
|
59
|
+
* build step of its own).
|
|
60
|
+
*
|
|
61
|
+
* THESE USED TO COME OUT OF apps/unoverse/server/src, which is what kept Studio from being
|
|
62
|
+
* publishable: nothing installed from npm can reach into another app's source. They live in
|
|
63
|
+
* `base` now, so Studio's entire platform dependency is one package.
|
|
64
|
+
*/
|
|
65
|
+
export async function loadCatalog(
|
|
66
|
+
baseSrc: string,
|
|
67
|
+
load: (id: string) => Promise<Record<string, unknown>>,
|
|
68
|
+
projectRoot: string,
|
|
69
|
+
): Promise<Loaders> {
|
|
70
|
+
/**
|
|
71
|
+
* POINT `base` AT THIS DEVELOPER'S PROJECT, before any loader is imported.
|
|
72
|
+
*
|
|
73
|
+
* This used to set `process.env.RX_PATH`, which paths.ts no longer reads: it was
|
|
74
|
+
* rewritten around UNOVERSE_HOME and `setPaths()`. The variable was simply ignored, and
|
|
75
|
+
* because paths.ts falls back to walking up for `apps/unoverse`, Studio kept working in
|
|
76
|
+
* THIS monorepo and read the wrong folder everywhere else. It served the monorepo's own
|
|
77
|
+
* projects to a developer who had just created one of their own.
|
|
78
|
+
*
|
|
79
|
+
* Silent because both answers are valid directories. Nothing errors; the wrong content
|
|
80
|
+
* simply appears, which is the failure mode this whole tool exists to avoid.
|
|
81
|
+
*
|
|
82
|
+
* ORDER MATTERS. paths.ts resolves its named exports ONCE at import ("a host calling
|
|
83
|
+
* setPaths() must do it before anything else imports this module"), so this load happens
|
|
84
|
+
* first and every later import sees the developer's home.
|
|
85
|
+
*/
|
|
86
|
+
const paths = await load(resolve(baseSrc, "paths.ts"));
|
|
87
|
+
(paths.setPaths as (n: { home: string }) => void)({ home: projectRoot });
|
|
88
|
+
|
|
89
|
+
const defs = await load(resolve(baseSrc, "definitions/definitions.ts"));
|
|
90
|
+
const theme = await load(resolve(baseSrc, "definitions/theme.ts"));
|
|
91
|
+
const inputs = await load(resolve(baseSrc, "definitions/inputs.ts"));
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
listDefinitions: defs.listDefinitions as Loaders["listDefinitions"],
|
|
95
|
+
listStates: defs.listStates as Loaders["listStates"],
|
|
96
|
+
listTemplatesForWorkbench: defs.listTemplatesForWorkbench as Loaders["listTemplatesForWorkbench"],
|
|
97
|
+
loadDefinition: defs.loadDefinition as Loaders["loadDefinition"],
|
|
98
|
+
listOrgs: defs.listOrgs as Loaders["listOrgs"],
|
|
99
|
+
inputPropKeys: inputs.inputPropKeys as Loaders["inputPropKeys"],
|
|
100
|
+
themeOrgs: theme.themeOrgs as Loaders["themeOrgs"],
|
|
101
|
+
listThemeNames: theme.listThemeNames as Loaders["listThemeNames"],
|
|
102
|
+
resolveTheme: theme.resolveTheme as Loaders["resolveTheme"],
|
|
103
|
+
DEFAULT_ORG: theme.DEFAULT_ORG as string,
|
|
104
|
+
};
|
|
105
|
+
}
|
package/local/keys.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys for local node testing: read from the project's own `.env`.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT a Studio-specific credential store. Developers already have a
|
|
5
|
+
* `.env`, it is already gitignored, and the platform already reads one. A second secret
|
|
6
|
+
* file would be a new concept to learn, a new thing to leak, and a new thing to explain.
|
|
7
|
+
*
|
|
8
|
+
* Studio therefore never WRITES a secret. It reads `.env` (and `.env.local`, which wins),
|
|
9
|
+
* tells the developer the exact variable each credential field wants, and reports whether
|
|
10
|
+
* it is set. Entering the value happens in the editor they are already in.
|
|
11
|
+
*
|
|
12
|
+
* Naming: the credential name loses its `Credential` suffix and the field is upper
|
|
13
|
+
* snake-cased, so `airtableCredential.personalAccessToken` reads from
|
|
14
|
+
* `AIRTABLE_PERSONAL_ACCESS_TOKEN`.
|
|
15
|
+
*
|
|
16
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
17
|
+
*/
|
|
18
|
+
import { resolve } from "path";
|
|
19
|
+
import { existsSync, readFileSync } from "fs";
|
|
20
|
+
|
|
21
|
+
/** camelCase or PascalCase to UPPER_SNAKE. */
|
|
22
|
+
function upperSnake(s: string): string {
|
|
23
|
+
return s
|
|
24
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
25
|
+
.replace(/[-\s]+/g, "_")
|
|
26
|
+
.toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The env variable a credential field reads from.
|
|
31
|
+
*
|
|
32
|
+
* The credential's own name is treated as ONE word, so `openAICredential` gives
|
|
33
|
+
* `OPENAI_API_KEY` rather than `OPEN_AI_API_KEY`. That matters: the provider names
|
|
34
|
+
* developers already have in their .env are the ones we should be reading.
|
|
35
|
+
* `envVarCandidates` keeps the split form working too, so neither spelling is a trap.
|
|
36
|
+
*/
|
|
37
|
+
export function envVarFor(credentialName: string, field: string): string {
|
|
38
|
+
const base = credentialName.replace(/Credentials?$/i, "");
|
|
39
|
+
return `${base.toUpperCase()}_${upperSnake(field)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Accepted spellings, most conventional first. */
|
|
43
|
+
export function envVarCandidates(credentialName: string, field: string): string[] {
|
|
44
|
+
const base = credentialName.replace(/Credentials?$/i, "");
|
|
45
|
+
const compact = `${base.toUpperCase()}_${upperSnake(field)}`;
|
|
46
|
+
const split = `${upperSnake(base)}_${upperSnake(field)}`;
|
|
47
|
+
return compact === split ? [compact] : [compact, split];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse the project's env files. `.env.local` overrides `.env`, matching the convention
|
|
52
|
+
* every other tool uses. Deliberately tolerant: a malformed line is skipped, never fatal.
|
|
53
|
+
*/
|
|
54
|
+
export function readEnv(projectRoot: string): Record<string, string> {
|
|
55
|
+
const out: Record<string, string> = {};
|
|
56
|
+
for (const name of [".env", ".env.local"]) {
|
|
57
|
+
// Look in the project AND its parent: in a monorepo the .env usually sits at the root.
|
|
58
|
+
for (const dir of [resolve(projectRoot, ".."), projectRoot]) {
|
|
59
|
+
const f = resolve(dir, name);
|
|
60
|
+
if (!existsSync(f)) continue;
|
|
61
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
62
|
+
const t = line.trim();
|
|
63
|
+
if (!t || t.startsWith("#")) continue;
|
|
64
|
+
const i = t.indexOf("=");
|
|
65
|
+
if (i <= 0) continue;
|
|
66
|
+
const key = t.slice(0, i).trim();
|
|
67
|
+
let value = t.slice(i + 1).trim();
|
|
68
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
69
|
+
value = value.slice(1, -1);
|
|
70
|
+
}
|
|
71
|
+
if (value) out[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the credential object a node executor expects, from env.
|
|
80
|
+
* Shape mirrors the platform's: { <credentialName>: { <field>: value } }.
|
|
81
|
+
*/
|
|
82
|
+
export function credentialsFromEnv(
|
|
83
|
+
projectRoot: string,
|
|
84
|
+
types: { name: string; properties?: { name: string }[] }[],
|
|
85
|
+
): Record<string, Record<string, string>> {
|
|
86
|
+
const env = readEnv(projectRoot);
|
|
87
|
+
const out: Record<string, Record<string, string>> = {};
|
|
88
|
+
for (const t of types) {
|
|
89
|
+
const fields: Record<string, string> = {};
|
|
90
|
+
for (const p of t.properties ?? []) {
|
|
91
|
+
const v = envVarCandidates(t.name, p.name).map((n) => env[n]).find(Boolean);
|
|
92
|
+
if (v) fields[p.name] = v;
|
|
93
|
+
}
|
|
94
|
+
if (Object.keys(fields).length) out[t.name] = fields;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which env variable each credential field wants, and whether it is currently set.
|
|
101
|
+
* Names only, never values: the browser has no reason to see a secret.
|
|
102
|
+
*/
|
|
103
|
+
export function keyPresence(
|
|
104
|
+
projectRoot: string,
|
|
105
|
+
types: { name: string; properties?: { name: string }[] }[],
|
|
106
|
+
): Record<string, { field: string; env: string; set: boolean }[]> {
|
|
107
|
+
const env = readEnv(projectRoot);
|
|
108
|
+
const out: Record<string, { field: string; env: string; set: boolean }[]> = {};
|
|
109
|
+
for (const t of types) {
|
|
110
|
+
out[t.name] = (t.properties ?? []).map((p) => {
|
|
111
|
+
const names = envVarCandidates(t.name, p.name);
|
|
112
|
+
// Report whichever spelling the developer actually used, else the conventional one.
|
|
113
|
+
const used = names.find((n) => env[n]);
|
|
114
|
+
return { field: p.name, env: used ?? names[0], set: Boolean(used) };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
package/local/nodes.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOCAL node catalog and runner.
|
|
3
|
+
*
|
|
4
|
+
* Scope (decided 2026-07-25): the developer's OWN nodes only, the ones in their
|
|
5
|
+
* project's `nodes/` folder. Local Studio never reads the installed plugins directory,
|
|
6
|
+
* `installed_plugins`, or the marketplace. Acquiring a node is a universe's job; this
|
|
7
|
+
* is developing the nodes you are writing.
|
|
8
|
+
*
|
|
9
|
+
* As with the definitions (catalog.ts), this reuses the server's REAL registration
|
|
10
|
+
* path rather than scraping source: each package is loaded through `loadPlugin`, which
|
|
11
|
+
* calls its `setup(api)` exactly as production does. So what Studio lists is what the
|
|
12
|
+
* platform would register, including config schemas and credential types.
|
|
13
|
+
*
|
|
14
|
+
* The bootstrap comment is explicit that this path touches neither Redis nor the
|
|
15
|
+
* workflow service, which is what makes it usable with no backend.
|
|
16
|
+
*
|
|
17
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
18
|
+
*/
|
|
19
|
+
import { resolve } from "path";
|
|
20
|
+
import { existsSync } from "fs";
|
|
21
|
+
|
|
22
|
+
export interface LocalNodes {
|
|
23
|
+
list: () => unknown[];
|
|
24
|
+
credentialTypes: () => unknown[];
|
|
25
|
+
/** Argument order mirrors the server's executeNode(nodeType, inputs, config, context). */
|
|
26
|
+
execute: (type: string, inputs: unknown, config: unknown, context: unknown) => Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load the developer's node packages into the in-memory registry.
|
|
31
|
+
*
|
|
32
|
+
* `projectRoot` is the folder holding `nodes/`. Returns null when there is no nodes/
|
|
33
|
+
* folder at all, so Studio can simply omit the section rather than showing an error:
|
|
34
|
+
* plenty of developers only author design assets.
|
|
35
|
+
*/
|
|
36
|
+
export async function loadLocalNodes(
|
|
37
|
+
baseSrc: string,
|
|
38
|
+
load: (id: string) => Promise<Record<string, unknown>>,
|
|
39
|
+
projectRoot: string,
|
|
40
|
+
): Promise<LocalNodes | null> {
|
|
41
|
+
const nodesDir = resolve(projectRoot, "nodes");
|
|
42
|
+
if (!existsSync(nodesDir)) return null;
|
|
43
|
+
|
|
44
|
+
// Set BEFORE the modules load: paths.ts reads process.env at module load time.
|
|
45
|
+
process.env.NODES_PATH = nodesDir;
|
|
46
|
+
|
|
47
|
+
const discovery = await load(resolve(baseSrc, "plugins/discovery.ts"));
|
|
48
|
+
const loader = await load(resolve(baseSrc, "plugins/loader.ts"));
|
|
49
|
+
const platform = await load(resolve(baseSrc, "platform/index.ts"));
|
|
50
|
+
const registry = await load(resolve(baseSrc, "registry.ts"));
|
|
51
|
+
const executor = await load(resolve(baseSrc, "executor.ts"));
|
|
52
|
+
|
|
53
|
+
const api = (platform.createNodeServiceAPI as () => unknown)();
|
|
54
|
+
const packages = (discovery.discoverNodesHome as () => string[])();
|
|
55
|
+
|
|
56
|
+
for (const name of packages) {
|
|
57
|
+
try {
|
|
58
|
+
await (loader.loadPlugin as (n: string, a: unknown) => Promise<string[]>)(name, api);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// One broken package must never stop the others loading. Same posture as the
|
|
61
|
+
// server's bootstrap, and the same reason: a bad asset cannot take the tool down.
|
|
62
|
+
console.warn(`[studio] could not load node package ${name}: ${(err as Error).message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
list: () => (registry.getAllNodes as () => unknown[])(),
|
|
68
|
+
credentialTypes: () => (registry.getAllCredentialTypes as () => unknown[])(),
|
|
69
|
+
execute: async (type, inputs, config, context) =>
|
|
70
|
+
(executor.executeNode as (t: string, i: unknown, c: unknown, ctx: unknown) => Promise<unknown>)(
|
|
71
|
+
type,
|
|
72
|
+
inputs,
|
|
73
|
+
config,
|
|
74
|
+
context,
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|