unoverse 0.1.195 → 0.1.196
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/unoverse.mjs +30 -4
- package/lib/lint.mjs +68 -0
- package/lib/publish.mjs +8 -13
- package/lib/skills.mjs +58 -35
- package/operator/lib/db-verify.sh +7 -1
- package/package.json +2 -1
- package/vendor/base/items/collect.js +45 -0
- package/vendor/base/items/publish.js +14 -0
- package/vendor/base/lint/context.mjs +56 -0
- package/vendor/base/lint/nodes/_schema/_defs.schema.json +102 -0
- package/vendor/base/lint/nodes/_schema/api.schema.json +971 -0
- package/vendor/base/lint/nodes/_schema/audio.schema.json +94 -0
- package/vendor/base/lint/nodes/_schema/config.schema.json +174 -0
- package/vendor/base/lint/nodes/_schema/credential.schema.json +89 -0
- package/vendor/base/lint/nodes/_schema/interface.schema.json +100 -0
- package/vendor/base/lint/nodes/_schema/node.schema.json +157 -0
- package/vendor/base/lint/nodes/_schema/package.schema.json +171 -0
- package/vendor/base/lint/nodes/_schema/test.schema.json +79 -0
- package/vendor/base/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/lint/nodes/index.mjs +82 -0
- package/vendor/base/lint/nodes/load.mjs +126 -0
- package/vendor/base/lint/nodes/node.mjs +764 -0
- package/vendor/base/lint/nodes/package.mjs +112 -0
- package/vendor/base/lint/nodes/schema.mjs +75 -0
- package/vendor/base/manifests/source.js +127 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Creating a universe registers the `canvas` MCP (`.mcp.json`): open the folder in
|
|
|
31
31
|
Claude Code and describe the workflow you want, built live on your Canvas. The
|
|
32
32
|
authoring skills for components, apps, themes, and custom nodes come with Studio
|
|
33
33
|
projects (`unoverse studio`). `unoverse update` keeps the CLI, a universe's images,
|
|
34
|
-
and
|
|
35
|
-
|
|
34
|
+
and the installed skills current. The skills are pages of the documentation, under
|
|
35
|
+
https://docs.unoverse.ai/skills, and the CLI installs them from there.
|
|
36
36
|
|
|
37
37
|
Documentation: https://docs.unoverse.ai
|
package/bin/unoverse.mjs
CHANGED
|
@@ -96,6 +96,7 @@ const HELP = [
|
|
|
96
96
|
"",
|
|
97
97
|
` ${bold("After that")}`,
|
|
98
98
|
row("studio", "Design components, nodes and agent skills"),
|
|
99
|
+
row("lint", "Check your components, nodes and skills, the same way deploy does"),
|
|
99
100
|
row("deploy", "Ship it"),
|
|
100
101
|
` ${dim("deploy studio your components, nodes and skills → your universe")}`,
|
|
101
102
|
` ${dim("deploy aws your universe → AWS")}`,
|
|
@@ -106,9 +107,14 @@ const HELP = [
|
|
|
106
107
|
].join("\n");
|
|
107
108
|
|
|
108
109
|
switch (cmd) {
|
|
109
|
-
case "create":
|
|
110
|
+
case "create": {
|
|
110
111
|
await create(args[0]);
|
|
112
|
+
// FIRST CONTACT INSTALLS THE SKILLS. They used to arrive only on `unoverse update`,
|
|
113
|
+
// so a developer's first `claude` in a fresh workspace knew nothing about unoverse.
|
|
114
|
+
const { installSkills } = await import("../lib/skills.mjs");
|
|
115
|
+
await installSkills();
|
|
111
116
|
break;
|
|
117
|
+
}
|
|
112
118
|
|
|
113
119
|
case "update": {
|
|
114
120
|
// ONE update: the CLI from npm, then everything the CLI is responsible for keeping
|
|
@@ -151,8 +157,8 @@ switch (cmd) {
|
|
|
151
157
|
|
|
152
158
|
case "_postupdate": {
|
|
153
159
|
// Runs AS the new version, in the developer's cwd. A universe refreshes its
|
|
154
|
-
// platform images; the authoring skills install to ~/.claude/skills from the
|
|
155
|
-
//
|
|
160
|
+
// platform images; the authoring skills install to ~/.claude/skills from the docs
|
|
161
|
+
// site, so `unoverse update` is the one command that brings everything current.
|
|
156
162
|
//
|
|
157
163
|
// THE SKILLS ARE NOT CWD-SCOPED. This used to refresh `<cwd>/.claude/skills` and only
|
|
158
164
|
// when that folder already existed, which meant it fired on whichever folder update
|
|
@@ -163,7 +169,7 @@ switch (cmd) {
|
|
|
163
169
|
operator(UNIVERSE, ["refresh-images"]);
|
|
164
170
|
}
|
|
165
171
|
const { installSkills } = await import("../lib/skills.mjs");
|
|
166
|
-
installSkills();
|
|
172
|
+
await installSkills();
|
|
167
173
|
process.exit(0);
|
|
168
174
|
}
|
|
169
175
|
|
|
@@ -191,6 +197,17 @@ switch (cmd) {
|
|
|
191
197
|
// quietly keeps talking to last week's build. If the listener IS a studio, it is
|
|
192
198
|
// ours to replace: kill it and wait for the port. Anything else on the port is
|
|
193
199
|
// NOT ours: name it and stop, never kill a stranger's process.
|
|
200
|
+
// A machine that has never installed the skills gets them here too, since studio is
|
|
201
|
+
// the other first-contact command. Present means theirs to keep; update refreshes.
|
|
202
|
+
{
|
|
203
|
+
const { existsSync: has } = await import("node:fs");
|
|
204
|
+
const { join: j } = await import("node:path");
|
|
205
|
+
const { homedir } = await import("node:os");
|
|
206
|
+
if (!has(j(homedir(), ".claude", "skills", "unoverse-create"))) {
|
|
207
|
+
const { installSkills } = await import("../lib/skills.mjs");
|
|
208
|
+
await installSkills();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
194
211
|
const STUDIO_PORT = 4108;
|
|
195
212
|
const lsof = spawnSync("lsof", ["-nP", `-iTCP:${STUDIO_PORT}`, "-sTCP:LISTEN", "-Fpc"], { encoding: "utf8" });
|
|
196
213
|
const holder = lsof.status === 0 ? (lsof.stdout.match(/^p(\d+)$/m) || [])[1] : undefined;
|
|
@@ -226,6 +243,15 @@ switch (cmd) {
|
|
|
226
243
|
process.exit(r.status ?? 0);
|
|
227
244
|
}
|
|
228
245
|
|
|
246
|
+
case "lint": {
|
|
247
|
+
// THE DEPLOY GATE, on its own. `deploy studio` runs exactly this before it sends, so a
|
|
248
|
+
// developer can ask the question without the answer costing them a deploy.
|
|
249
|
+
const { lint } = await import("../lib/lint.mjs");
|
|
250
|
+
const { importBase } = await import("../lib/publish.mjs");
|
|
251
|
+
await lint(args, importBase);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
229
255
|
case "login": {
|
|
230
256
|
// Standalone sign-in. publish calls the same flow lazily, so this exists for
|
|
231
257
|
// "set up my machine" moments and for re-authenticating after a permissions change.
|
package/lib/lint.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unoverse lint — check a workspace before you deploy it.
|
|
3
|
+
*
|
|
4
|
+
* THE SAME GATE `deploy studio` RUNS, and nothing else: one function in base
|
|
5
|
+
* (items/publish lintForPublish) lints the project's design/ and the workspace's nodes/,
|
|
6
|
+
* and this file only prints what it found. A developer who runs this and sees a tick will
|
|
7
|
+
* deploy without a surprise, because deploy asks the same function the same question.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the gate used to be reachable only by deploying (2026-09-05): the
|
|
10
|
+
* first place a developer met a lint error was halfway through shipping.
|
|
11
|
+
*/
|
|
12
|
+
import { findWorkspace, designHome } from "./workspace.mjs";
|
|
13
|
+
|
|
14
|
+
const c = { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", off: "\x1b[0m" };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lint one project and print the findings. Returns the findings so `deploy studio` can
|
|
18
|
+
* decide what to do with them; exits nobody.
|
|
19
|
+
*/
|
|
20
|
+
export async function lintProject({ lintForPublish, designRoot, project }) {
|
|
21
|
+
process.stdout.write(` ${c.dim}checking…${c.off}`);
|
|
22
|
+
const { problems, errors } = await lintForPublish(designRoot, project);
|
|
23
|
+
process.stdout.write("\r \r");
|
|
24
|
+
if (errors.length) {
|
|
25
|
+
console.error(` ${c.red}✗ ${errors.length} error(s) in ${project}.${c.off}\n`);
|
|
26
|
+
for (const p of errors.slice(0, 10)) console.error(` ${p.file}${p.line ? ":" + p.line : ""} ${p.msg}`);
|
|
27
|
+
if (errors.length > 10) console.error(` ${c.dim}…and ${errors.length - 10} more${c.off}`);
|
|
28
|
+
console.error("");
|
|
29
|
+
} else {
|
|
30
|
+
const warnings = problems.filter((p) => p.level === "warn").length;
|
|
31
|
+
console.log(` ${c.green}✓${c.off} checks passed${warnings ? ` ${c.dim}(${warnings} warning(s))${c.off}` : ""}`);
|
|
32
|
+
}
|
|
33
|
+
return { problems, errors };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `unoverse lint [project]`, from anywhere inside a workspace. Exit 1 on any error. */
|
|
37
|
+
export async function lint(args, importBase) {
|
|
38
|
+
const positional = args.find((a) => !a.startsWith("--"));
|
|
39
|
+
const ws = findWorkspace();
|
|
40
|
+
if (ws?.kind !== "assets") {
|
|
41
|
+
console.error(`\n ${c.red}✗${c.off} no design/ folder here or above. Run this inside your workspace.\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const designRoot = designHome(ws.root);
|
|
45
|
+
const { listProjects } = await importBase("items/collect.js");
|
|
46
|
+
const { lintForPublish } = await importBase("items/publish.js");
|
|
47
|
+
|
|
48
|
+
const projects = listProjects(designRoot);
|
|
49
|
+
if (!projects.length) {
|
|
50
|
+
console.error(`\n ${c.red}✗${c.off} ${designRoot} holds no projects\n`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
let project = positional;
|
|
54
|
+
if (!project) {
|
|
55
|
+
if (projects.length > 1) {
|
|
56
|
+
console.error(`\n several projects here. Say which:\n\n unoverse lint <project>\n\n Found: ${projects.join(", ")}\n`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
project = projects[0];
|
|
60
|
+
} else if (!projects.includes(project)) {
|
|
61
|
+
console.error(`\n ${c.red}✗${c.off} no project "${project}" in ${designRoot}. Found: ${projects.join(", ")}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`\n ${c.bold}lint ${project}${c.off}\n`);
|
|
66
|
+
const { errors } = await lintProject({ lintForPublish, designRoot, project });
|
|
67
|
+
process.exit(errors.length ? 1 : 0);
|
|
68
|
+
}
|
package/lib/publish.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
22
22
|
const IN_REPO_BASE = resolve(here, "../../base/dist");
|
|
23
23
|
const VENDORED_BASE = resolve(here, "../vendor/base");
|
|
24
24
|
|
|
25
|
-
async function importBase(subpath) {
|
|
25
|
+
export async function importBase(subpath) {
|
|
26
26
|
const root = existsSync(IN_REPO_BASE) ? IN_REPO_BASE : VENDORED_BASE;
|
|
27
27
|
const file = join(root, subpath);
|
|
28
28
|
if (!existsSync(file)) {
|
|
@@ -114,18 +114,12 @@ export async function publish(args) {
|
|
|
114
114
|
|
|
115
115
|
// ── 1. lint. Nothing is sent if this fails ──────────────────────────────────
|
|
116
116
|
console.log(`\n ${c.bold}deploy ${project}${c.off} ${c.dim}→ ${universe}${c.off}\n`);
|
|
117
|
-
|
|
118
|
-
const {
|
|
119
|
-
process.stdout.write("\r \r");
|
|
117
|
+
const { lintProject } = await import("./lint.mjs");
|
|
118
|
+
const { errors } = await lintProject({ lintForPublish, designRoot, project });
|
|
120
119
|
if (errors.length) {
|
|
121
|
-
console.error(` ${c.red}
|
|
122
|
-
for (const p of errors.slice(0, 10)) console.error(` ${p.file}${p.line ? ":" + p.line : ""} ${p.msg}`);
|
|
123
|
-
if (errors.length > 10) console.error(` ${c.dim}…and ${errors.length - 10} more${c.off}`);
|
|
124
|
-
console.error("");
|
|
120
|
+
console.error(` ${c.red}Nothing was sent.${c.off}\n`);
|
|
125
121
|
process.exit(1);
|
|
126
122
|
}
|
|
127
|
-
const warnings = problems.filter((p) => p.level === "warn").length;
|
|
128
|
-
console.log(` ${c.green}✓${c.off} checks passed${warnings ? ` ${c.dim}(${warnings} warning(s))${c.off}` : ""}`);
|
|
129
123
|
|
|
130
124
|
// ── 2. the credential, only now that there is something worth sending ───────
|
|
131
125
|
const items = collectProject(designRoot, project);
|
|
@@ -147,9 +141,10 @@ export async function publish(args) {
|
|
|
147
141
|
|
|
148
142
|
console.log("");
|
|
149
143
|
const label = (i) => `${i.kind}/${i.name}`;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (const i of plan.
|
|
144
|
+
// No "(lands PENDING review)" on a node (removed 2026-09-05): the review flow in
|
|
145
|
+
// DECLARATIVE_NODES.md §9.5 is not built, and the route registers a node row at once.
|
|
146
|
+
for (const i of plan.create) console.log(` ${c.green}+${c.off} ${label(i)} ${c.dim}(new)${c.off}`);
|
|
147
|
+
for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}`);
|
|
153
148
|
if (plan.unchanged.length) console.log(` ${c.dim}= ${plan.unchanged.length} unchanged${c.off}`);
|
|
154
149
|
for (const r of plan.refused) console.log(` ${c.red}✗ ${label(r)}${c.off} ${c.dim}${r.why}${c.off}`);
|
|
155
150
|
// Deploy is a SYNC: what left the workspace leaves the universe, said before it happens.
|
package/lib/skills.mjs
CHANGED
|
@@ -1,57 +1,80 @@
|
|
|
1
|
-
// Install the Claude Code authoring skills for this developer.
|
|
1
|
+
// Install the Claude Code authoring skills for this developer, FROM THE DOCS SITE.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
3
|
+
// The skills are pages of the documentation (packages/docs/skills/<skill>/...), published
|
|
4
|
+
// with every other page and listed in the site's llms.txt (owner ruling 2026-09-05: the
|
|
5
|
+
// docs site is the one public origin, and it already serves every page as raw markdown).
|
|
6
|
+
// The installer reads llms.txt, takes every page under /skills/, and writes each one to
|
|
7
|
+
// disk. There is no GitHub mirror and no tarball any more.
|
|
5
8
|
//
|
|
6
9
|
// PER DEVELOPER, NOT PER PROJECT (2026-08-21). These install to ~/.claude/skills, so
|
|
7
10
|
// Claude Code picks them up in every folder the developer opens and nothing lands
|
|
8
|
-
// inside a workspace where it could be committed.
|
|
9
|
-
// Studio workspace's own .claude/skills by Studio's scaffold, out of a snapshot
|
|
10
|
-
// vendored in the Studio npm package: that reproduced the platform repo's .claude
|
|
11
|
-
// folder on every remote developer's disk, pinned to whichever Studio version they
|
|
12
|
-
// installed. Studio no longer writes them (packages/studio/local/scaffold.mjs) and
|
|
13
|
-
// the mirror is the only channel.
|
|
11
|
+
// inside a workspace where it could be committed.
|
|
14
12
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// developer's tooling current.
|
|
21
|
-
import { mkdtempSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync } from "node:fs";
|
|
22
|
-
import { join } from "node:path";
|
|
13
|
+
// Runs on `unoverse create` and on a first `unoverse studio` (first contact), and on
|
|
14
|
+
// `unoverse update` (_postupdate), which is the one command that brings a developer's
|
|
15
|
+
// tooling current.
|
|
16
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, mkdtempSync, cpSync } from "node:fs";
|
|
17
|
+
import { join, dirname, basename } from "node:path";
|
|
23
18
|
import { tmpdir, homedir } from "node:os";
|
|
24
|
-
import { spawnSync } from "node:child_process";
|
|
25
19
|
|
|
26
|
-
const
|
|
20
|
+
const DOCS = "https://docs.unoverse.ai";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The skill files a site's llms.txt names: `{ skill, rel, url }` per page under /skills/,
|
|
24
|
+
* `rel` being the path inside the skill folder. A skill's front page is SKILL.md on disk
|
|
25
|
+
* whatever case the site serves it in, because Claude Code looks for exactly that name.
|
|
26
|
+
* Pure, so it can be checked without a network.
|
|
27
|
+
*/
|
|
28
|
+
export function skillFilesFromLlms(text, origin = DOCS) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const re = new RegExp(`\\((${origin.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}/skills/([^/\\s)]+)/([^\\s)]+\\.md))\\)`, "g");
|
|
31
|
+
for (const m of text.matchAll(re)) {
|
|
32
|
+
const [, url, skill, path] = m;
|
|
33
|
+
const rel = basename(path).toLowerCase() === "skill.md" ? join(dirname(path), "SKILL.md") : path;
|
|
34
|
+
out.push({ skill, rel, url });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
27
38
|
|
|
28
|
-
export function installSkills() {
|
|
39
|
+
export async function installSkills() {
|
|
29
40
|
const target = join(homedir(), ".claude", "skills");
|
|
41
|
+
let files;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`${DOCS}/llms.txt`);
|
|
44
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
45
|
+
files = skillFilesFromLlms(await res.text());
|
|
46
|
+
} catch {
|
|
47
|
+
console.log(` Skills install skipped (${DOCS} unreachable). They install on the next update.`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const skills = [...new Set(files.map((f) => f.skill))];
|
|
51
|
+
if (!skills.length) {
|
|
52
|
+
console.log(` Skills install skipped: ${DOCS}/llms.txt lists no skills yet.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Fetched whole into a scratch folder first, so a download that dies halfway leaves the
|
|
56
|
+
// installed copy untouched rather than half-replaced.
|
|
30
57
|
const tmp = mkdtempSync(join(tmpdir(), "unoverse-skills-"));
|
|
31
58
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
const res = await fetch(f.url);
|
|
61
|
+
if (!res.ok) throw new Error(`${f.url}: HTTP ${res.status}`);
|
|
62
|
+
const p = join(tmp, f.skill, f.rel);
|
|
63
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
64
|
+
writeFileSync(p, await res.text());
|
|
38
65
|
}
|
|
39
|
-
// A skill is a directory holding a SKILL.md. The mirror's README and dotfiles never
|
|
40
|
-
// install.
|
|
41
|
-
const names = readdirSync(tmp, { withFileTypes: true })
|
|
42
|
-
.filter((e) => e.isDirectory() && existsSync(join(tmp, e.name, "SKILL.md")))
|
|
43
|
-
.map((e) => e.name);
|
|
44
|
-
if (!names.length) return;
|
|
45
66
|
mkdirSync(target, { recursive: true });
|
|
46
|
-
// REPLACED WHOLE, ours only. Each skill folder the
|
|
67
|
+
// REPLACED WHOLE, ours only. Each skill folder the site carries is removed and
|
|
47
68
|
// re-copied, so a deleted file upstream really goes. Anything else in the
|
|
48
69
|
// developer's ~/.claude/skills is THEIRS and is never touched: this folder is
|
|
49
70
|
// shared with every skill they have installed from anywhere else.
|
|
50
|
-
for (const name of
|
|
71
|
+
for (const name of skills) {
|
|
51
72
|
rmSync(join(target, name), { recursive: true, force: true });
|
|
52
73
|
cpSync(join(tmp, name), join(target, name), { recursive: true });
|
|
53
74
|
}
|
|
54
|
-
console.log(` ✓ authoring skills installed to ~/.claude/skills (${
|
|
75
|
+
console.log(` ✓ authoring skills installed to ~/.claude/skills (${skills.join(", ")})`);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.log(` Skills install skipped (${e.message}). They install on the next update.`);
|
|
55
78
|
} finally {
|
|
56
79
|
rmSync(tmp, { recursive: true, force: true });
|
|
57
80
|
}
|
|
@@ -133,7 +133,13 @@ cmd_db_verify() {
|
|
|
133
133
|
goals: [
|
|
134
134
|
"goal_id", "user_id", "workflow_id", "status", "description",
|
|
135
135
|
"acceptance_criteria", "budget", "goal_state", "created_workflow_ids",
|
|
136
|
-
"created_at", "updated_at", "completed_at"
|
|
136
|
+
"created_at", "updated_at", "completed_at", "directive", "bar_locked_at"
|
|
137
|
+
],
|
|
138
|
+
goal_attempts: [
|
|
139
|
+
"id", "goal_id", "attempt", "passed", "results", "deliverables", "judged_at"
|
|
140
|
+
],
|
|
141
|
+
goal_scratch: [
|
|
142
|
+
"goal_id", "agent_id", "agent_name", "state", "updated_at"
|
|
137
143
|
],
|
|
138
144
|
knowledge_docs: [
|
|
139
145
|
"id", "workflow_id", "title", "doc_type", "sections", "version",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unoverse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.196",
|
|
4
4
|
"description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"type": "module",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"node": ">=18"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
21
22
|
"yaml": "^2.8.1"
|
|
22
23
|
},
|
|
23
24
|
"homepage": "https://github.com/unoverse-platform",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
21
21
|
import { join, relative, basename } from "node:path";
|
|
22
22
|
import { fingerprintOf } from "./fingerprint.js";
|
|
23
|
+
import { parse as parseYaml } from "yaml";
|
|
24
|
+
import { readDiskPackages } from "../manifests/source.js";
|
|
23
25
|
import { designSystemVersion } from "./baseVersion.js";
|
|
24
26
|
/** Folders under rx/ that are never a developer's own project. */
|
|
25
27
|
const NOT_A_PROJECT = new Set(["marketplace", "_schema", "orgs"]);
|
|
@@ -189,6 +191,49 @@ export function collectProject(designRoot, project) {
|
|
|
189
191
|
};
|
|
190
192
|
walk(blocksHome);
|
|
191
193
|
}
|
|
194
|
+
// nodes/ — THE WORKSPACE'S NODE PACKAGES, one row per node, shaped exactly as the
|
|
195
|
+
// marketplace stores a node (core/items/catalogue.ts) so the row source composes them
|
|
196
|
+
// on the same path as disk: the raw files, plus the package envelope their $refs and
|
|
197
|
+
// credential shapes resolve against. The name is the node's `type`, bare: a saved
|
|
198
|
+
// workflow stores that identity and nothing else.
|
|
199
|
+
//
|
|
200
|
+
// Until 2026-09-05 deploy never looked here, so `deploy studio` shipped a developer's
|
|
201
|
+
// components and skills and silently left every node behind.
|
|
202
|
+
//
|
|
203
|
+
// NOT FROM THE PLATFORM TREE. The monorepo's nodes ship inside the core image and are
|
|
204
|
+
// read from disk there; publishing them as rows under whichever org happened to be
|
|
205
|
+
// deployed would double every platform node. A design system on disk is the mark of
|
|
206
|
+
// the platform tree (the same test designSystemDir makes), and a developer's workspace
|
|
207
|
+
// never has one.
|
|
208
|
+
const nodesHome = join(designRoot, "..", "nodes");
|
|
209
|
+
if (existsSync(nodesHome) && !existsSync(join(designRoot, "marketplace"))) {
|
|
210
|
+
for (const pkg of collectNodePackages(nodesHome)) {
|
|
211
|
+
for (const raw of pkg.nodes) {
|
|
212
|
+
const type = nodeType(raw.files["node.yaml"]);
|
|
213
|
+
if (!type)
|
|
214
|
+
continue; // lint has already refused it; nothing to name a row by
|
|
215
|
+
add("node", type, {
|
|
216
|
+
package: { name: pkg.name, packageFile: pkg.packageFile ?? null, credentials: pkg.credentials, shared: pkg.shared },
|
|
217
|
+
dir: raw.dir,
|
|
218
|
+
files: raw.files,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
192
223
|
return items;
|
|
193
224
|
}
|
|
225
|
+
/** The workspace's node packages, read the way the platform reads its own. */
|
|
226
|
+
function collectNodePackages(nodesHome) {
|
|
227
|
+
return readDiskPackages(nodesHome).filter((pkg) => pkg.name !== "marketplace");
|
|
228
|
+
}
|
|
229
|
+
/** `type:` off a node.yaml: the one scalar the row is named by. Unparseable means none. */
|
|
230
|
+
function nodeType(nodeYaml) {
|
|
231
|
+
try {
|
|
232
|
+
const doc = parseYaml(nodeYaml ?? "");
|
|
233
|
+
return typeof doc?.type === "string" ? doc.type : "";
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
194
239
|
//# sourceMappingURL=collect.js.map
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* See docs/architecture/DECLARATIVE_NODES.md §9.
|
|
14
14
|
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
15
17
|
import { collectProject } from "./collect.js";
|
|
16
18
|
/**
|
|
17
19
|
* Lint a project, returning findings. Errors mean nothing is sent.
|
|
@@ -24,6 +26,18 @@ import { collectProject } from "./collect.js";
|
|
|
24
26
|
export async function lintForPublish(designRoot, project) {
|
|
25
27
|
const { lintDefinitions } = await import("../lint/design/index.mjs");
|
|
26
28
|
const result = lintDefinitions(designRoot);
|
|
29
|
+
/**
|
|
30
|
+
* NODES TOO. A workspace keeps its node packages beside design/ (the same `nodes/` the
|
|
31
|
+
* Studio Nodes screen reads), and until 2026-09-05 this gate never looked at them: a
|
|
32
|
+
* node with an events row out of order or a host missing from allowedHosts deployed as
|
|
33
|
+
* cleanly as a correct one. The node linter is the same library the platform's own CI
|
|
34
|
+
* runs. A workspace with no nodes/ folder has nothing to lint and is not an error.
|
|
35
|
+
*/
|
|
36
|
+
const nodesHome = join(designRoot, "..", "nodes");
|
|
37
|
+
if (existsSync(nodesHome)) {
|
|
38
|
+
const { lintNodes } = await import("../lint/nodes/index.mjs");
|
|
39
|
+
result.problems.push(...lintNodes(nodesHome).problems);
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
|
|
29
43
|
* check looked for "/design/" with a leading slash, which a relative path never
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state one lint run accumulates, and the only thing every rule module shares.
|
|
3
|
+
*
|
|
4
|
+
* It is MUTABLE and RESET per run, deliberately. The linter began as a CLI that ran once
|
|
5
|
+
* and exited, so module-level arrays were harmless; Studio and the publish route lint over
|
|
6
|
+
* and over in one process, and findings from the previous run would silently accumulate
|
|
7
|
+
* into the next. `reset()` is what makes a second call return the same answer as the first.
|
|
8
|
+
*/
|
|
9
|
+
import { relative } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Every finding, in discovery order. Sorted for presentation by the caller. */
|
|
12
|
+
export const problems = [];
|
|
13
|
+
|
|
14
|
+
/** Fragment path -> the set of nodes referencing it. Powers the shared/ pruning rules. */
|
|
15
|
+
export const refCounts = new Map();
|
|
16
|
+
|
|
17
|
+
/** Node -> the executor kind derived for it, shown as a summary rather than a finding. */
|
|
18
|
+
export const derivedKinds = new Map();
|
|
19
|
+
|
|
20
|
+
/** Credential name -> where it was declared, for cross-package collision rules. */
|
|
21
|
+
export const allCredentials = new Map();
|
|
22
|
+
|
|
23
|
+
/** Where this run is reading. Set by lintNodes(), read by rel() and the loaders. */
|
|
24
|
+
export const state = { nodesHome: "", schemaDir: "", schemas: {} };
|
|
25
|
+
|
|
26
|
+
export function reset(nodesHome, schemaDir) {
|
|
27
|
+
problems.length = 0;
|
|
28
|
+
seen.clear();
|
|
29
|
+
refCounts.clear();
|
|
30
|
+
derivedKinds.clear();
|
|
31
|
+
allCredentials.clear();
|
|
32
|
+
state.nodesHome = nodesHome;
|
|
33
|
+
state.schemaDir = schemaDir;
|
|
34
|
+
state.schemas = {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One finding. `error` fails a build; `warn` and `hint` inform.
|
|
39
|
+
*
|
|
40
|
+
* DEDUPED. The design linter walks a definition and its expansions, so the same rule can fire
|
|
41
|
+
* on the same line twice; a caller seeing it twice would think there were two problems.
|
|
42
|
+
* `line` is optional: the design linter knows where in the file, the node linter reports per file.
|
|
43
|
+
*/
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
export const report = (level, file, msg, line) => {
|
|
46
|
+
const key = `${level}|${file}|${line ?? ""}|${msg}`;
|
|
47
|
+
if (seen.has(key)) return;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
problems.push(line === undefined ? { level, file, msg } : { level, file, msg, line });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Shortest readable form: a walk out of the tree is worse than the absolute path. */
|
|
53
|
+
export const rel = (p) => {
|
|
54
|
+
const r = relative(process.cwd(), p);
|
|
55
|
+
return r.startsWith("..") ? p : r;
|
|
56
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://unoverse/nodes/_defs.schema.json",
|
|
4
|
+
"title": "Shared node definitions",
|
|
5
|
+
"description": "Fragments $ref'd by the sibling node schemas. Not authored directly. See docs/architecture/DECLARATIVE_NODES.md.",
|
|
6
|
+
"definitions": {
|
|
7
|
+
"nodeType": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"pattern": "^[A-Z][A-Za-z0-9]*$",
|
|
10
|
+
"description": "The node's stable identity, PascalCase. This is what a saved workflow stores and what the engine resolves. Renaming it orphans every graph that uses it. NOT the same as the `type` field in package.json's gravity.nodes[], which meant PromiseNode or CallbackNode."
|
|
11
|
+
},
|
|
12
|
+
"portType": {
|
|
13
|
+
"enum": [
|
|
14
|
+
"string",
|
|
15
|
+
"number",
|
|
16
|
+
"boolean",
|
|
17
|
+
"object",
|
|
18
|
+
"array",
|
|
19
|
+
"signal"
|
|
20
|
+
],
|
|
21
|
+
"description": "The shape carried on a wire. Mirrors NodeInputType in @unoverse-platform/plugin-base."
|
|
22
|
+
},
|
|
23
|
+
"signalType": {
|
|
24
|
+
"enum": [
|
|
25
|
+
"EXECUTE",
|
|
26
|
+
"CONTINUE",
|
|
27
|
+
"SPAWN",
|
|
28
|
+
"RESET"
|
|
29
|
+
],
|
|
30
|
+
"description": "Which signal fires this connector. EXECUTE is the default trigger; CONTINUE drives the next iteration of a streaming or looping node; SPAWN initialises an actor; RESET clears it. A node becomes ready when ANY ONE connector has all its required sources populated (signal-routing.md)."
|
|
31
|
+
},
|
|
32
|
+
"port": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": [
|
|
35
|
+
"name",
|
|
36
|
+
"type"
|
|
37
|
+
],
|
|
38
|
+
"properties": {
|
|
39
|
+
"name": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"pattern": "^[a-z][A-Za-z0-9_]*$",
|
|
42
|
+
"description": "camelCase by convention. Upstream nodes address it as signal.<sourceId>.<outputHandle>.\n\nsnake_case is ALLOWED because a migrated node must keep the connector names its code version had: a saved workflow references them by name, so renaming `linkedin_url` to `linkedinUrl` would silently stop resolving and the field would read as empty with no error anywhere. Fidelity to the node being replaced beats house style. Prefer camelCase for anything new."
|
|
43
|
+
},
|
|
44
|
+
"type": {
|
|
45
|
+
"$ref": "#/definitions/portType"
|
|
46
|
+
},
|
|
47
|
+
"required": {
|
|
48
|
+
"type": "boolean",
|
|
49
|
+
"description": "Inputs only. A connector is satisfied when all its REQUIRED sources are populated."
|
|
50
|
+
},
|
|
51
|
+
"signal": {
|
|
52
|
+
"$ref": "#/definitions/signalType",
|
|
53
|
+
"description": "Inputs only. Omit for the EXECUTE default."
|
|
54
|
+
},
|
|
55
|
+
"description": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "What travels on this port. Read by developers wiring the graph."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"additionalProperties": false
|
|
61
|
+
},
|
|
62
|
+
"nodeCategory": {
|
|
63
|
+
"enum": [
|
|
64
|
+
"AI",
|
|
65
|
+
"Voice",
|
|
66
|
+
"Go To Market",
|
|
67
|
+
"Search",
|
|
68
|
+
"Web Scraping",
|
|
69
|
+
"Media & Design",
|
|
70
|
+
"Documents",
|
|
71
|
+
"Knowledge & Vectors",
|
|
72
|
+
"Storage & Data",
|
|
73
|
+
"Communication",
|
|
74
|
+
"Flow",
|
|
75
|
+
"Output"
|
|
76
|
+
],
|
|
77
|
+
"description": "Descriptive taxonomy matching the node's JOB. This is the NODE vocabulary and it is deliberately different from the PACKAGE vocabulary in package.schema.json (ai, storage, ingest, ...). See docs-starter/nodes/CLAUDE.md."
|
|
78
|
+
},
|
|
79
|
+
"whenToUse": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"minLength": 40,
|
|
82
|
+
"description": "AI selection guidance, embedded and semantically ranked by getNodeCatalog, so it decides whether this node SURFACES AT ALL to the workflow-building agent. Rules (node-discoverability.md): 1-2 sentences; OUTCOME first, mechanism last; disqualify yourself by PROPERTY and never name a rival node; put wiring facts last. A node with weak meta is invisible no matter how well it works."
|
|
83
|
+
},
|
|
84
|
+
"expression": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"pattern": "^return ",
|
|
87
|
+
"description": "A sandboxed `return ...` data-shaping expression, evaluated by the platform's existing SafeExpression evaluator (engine/src/template/SafeExpression.ts). Same syntax developers already use in object template config fields, so this format introduces NO second expression language.\n\nSecurity is by ABSENCE: an acorn AST allowlist that never implements process, require, fetch, eval, Function, new, assignment, or constructor/__proto__, so there is nothing to escape to. Allowed: member access, indexing, object and array literals, spread, template strings, operators, ternaries, and safe array/string/JSON/Math methods including arrow callbacks. Anything else throws and is logged."
|
|
88
|
+
},
|
|
89
|
+
"templateOrExpression": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "EITHER a Handlebars template or a `return ...` expression, decided by the string itself: anything starting with `return ` is evaluated by SafeExpression, everything else is rendered as a template.\n\nONE RULE FOR EVERY STRING IN A CALL. url, headers, query and the auth fields all take this, exactly as the body already did. The gap used to be arbitrary and it cost a real node: a URL is often assembled from an earlier call's reply, and a vendor's shape is not always uniform (HubSpot's v3 associations return the related id as `toObjectId` on some object pairs and `id` on others). Handlebars has no `??`, so a manifest could not express the fallback that the retired TypeScript wrote as `toObjectId ?? id`, and the only escape was choosing an API version whose shape happened to be predictable. A node should not contort itself around a limitation of the executor.\n\nPrefer the template. Reach for the expression when a template genuinely cannot say it, because a template is what a reader can scan.\n\nNOTE for `url`: the linter checks allowedHosts hosts STATICALLY by blanking `{{ }}` and parsing what is left, and it cannot do that with an expression, so an expression URL is reported as not statically verifiable. It is still enforced at run time: `sendRequest` calls `assertAllowedHost` on the RESOLVED url, and every call in the runtime goes through it.",
|
|
92
|
+
"examples": [
|
|
93
|
+
"https://api.example.com/v1/things/{{ calls.search.results.0.id }}",
|
|
94
|
+
"return 'https://api.example.com/v1/things/' + (calls.search.results[0].toObjectId || calls.search.results[0].id)"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
"template": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "A Handlebars template resolved against the node's input context before the request is sent.\n\nFive roots:\n signal.<sourceId>.<outputHandle>.<field> upstream node output, e.g. {{signal.inputtrigger1.output.message}}\n config.<field> this node's own settings\n credentials.<name>.<field> this node's resolved credential\n services.<connector> RUNTIME service wiring, e.g. {{#unless services.mcpService.tools}}\n prompt.<blockName> a PROMPT BLOCK from the library (alias: blocks.<name>)\n\nThe services root exists because some request shaping depends on what is WIRED rather than configured, a fact only the executor knows at run time.\n\nThe prompt root is why a manifest must NEVER hardcode instruction text. Blocks live in prompts/blocks/**/*.md, are authored and toggled in Studio, and are camelCased from the filename (markdown-guidelines.md becomes {{prompt.markdownGuidelines}}). A copy of a block's words baked into a node is a fork that silently stops tracking the block. Text belongs in the library; the node references it. This is the prompt-side twin of the SDK owning no styles.\n\nEvery root works in ANY template field, including a user's own systemPrompt: that is what lets an author compose blocks and upstream values into a prompt without the node knowing anything about it.\n\nThere is NO {{input.*}} root: a wrong path resolves to EMPTY silently. Array elements and object keys are dot segments, never brackets: records.0.Name, not records[0].Name."
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|