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 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 any installed skills current; to add the skills to your own agent by hand:
35
- `npx skills add unoverse-platform/skills`.
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 public
155
- // mirror, so `unoverse update` is the one command that brings everything current.
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
- process.stdout.write(` ${c.dim}checking…${c.off}`);
118
- const { problems, errors } = await lintForPublish(designRoot, project);
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}✗ ${errors.length} error(s) in ${project}. Nothing was sent.${c.off}\n`);
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
- const pending = (i) => (i.kind === "node" ? ` ${c.yellow}(lands PENDING review)${c.off}` : "");
151
- for (const i of plan.create) console.log(` ${c.green}+${c.off} ${label(i)} ${c.dim}(new)${c.off}${pending(i)}`);
152
- for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}${pending(i)}`);
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 public mirror github.com/unoverse-platform/skills is the one place installed
4
- // skills come from (publish syncs it from the platform repo's .claude/skills).
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. They used to be copied into each
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
- // INSTALLS, NOT JUST REFRESHES. This used to return early when the folder was absent,
16
- // because seeding was Studio's job. Nothing seeds them now, so an absent folder is the
17
- // FIRST install and creating it is the whole point.
18
- //
19
- // Runs on `unoverse update` (_postupdate), which is the one command that brings a
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 TARBALL = "https://codeload.github.com/unoverse-platform/skills/tar.gz/refs/heads/main";
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
- const dl = spawnSync("bash", ["-c", `curl -fsSL "${TARBALL}" | tar -xz -C "${tmp}" --strip-components=1`], {
33
- stdio: "pipe",
34
- });
35
- if (dl.status !== 0) {
36
- console.log(" Skills install skipped (github.com unreachable). They install on the next update.");
37
- return;
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 mirror carries is removed and
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 names) {
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 (${names.join(", ")})`);
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.195",
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
+ }