unoverse 0.1.194 → 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
  }
@@ -76,17 +76,14 @@
76
76
  debug:
77
77
  msg: "{{ pull_result.stdout_lines[-8:] | default(['done']) }}"
78
78
 
79
- # A DEPLOY TAKES THE NEWEST NODE RUNTIME. The entrypoint installs
80
- # @unoverse-platform/base within the range the image declares, but it only goes to the
81
- # registry when the volume is cold or the range moved otherwise a restart would be a
82
- # network call, and a restart must be instant and offline. A DEPLOY is the moment the
83
- # operator asked for "make this current" (MARKETPLACE.md §5a), so it says so explicitly.
84
- # Scoped to this one `up`, never written into .env: a later `docker restart` must not
85
- # inherit it.
86
- - name: "[3/4] Restart on the new images (and take the newest base in range)"
79
+ # THE NODE RUNTIME CAME WITH THE IMAGE. base is baked in, not installed
80
+ # (MARKETPLACE.md §5a, reversed 2026-09-03), so the pull above already moved it and
81
+ # this step has nothing extra to ask for. It used to run with `UNOVERSE_BASE_REFRESH=1`
82
+ # to mean "take the newest base in range"; there is no range and no install any more.
83
+ - name: "[3/4] Restart on the new images"
87
84
  shell: |
88
85
  cd {{ gravity_dir }}
89
- UNOVERSE_BASE_REFRESH=1 docker compose up -d 2>&1 | tail -6
86
+ docker compose up -d 2>&1 | tail -6
90
87
  register: up_result
91
88
 
92
89
  # `up -d` RECREATES ON CONFIG CHANGE, NOT ON IMAGE CHANGE — not reliably, and not on
@@ -275,20 +275,12 @@ services:
275
275
  # copy carrying a built dist. Without this the runtime hits MODULE_NOT_FOUND and
276
276
  # the nodes show "Package not installed".
277
277
  - NODE_PATH=/app/host_node_modules:/app/plugins/node_modules
278
- # PASSTHROUGH, not a setting. `unoverse deploy` runs `UNOVERSE_BASE_REFRESH=1 docker
279
- # compose up -d` to mean "take the newest node runtime in range"; without this line
280
- # that variable would reach the compose CLI and stop there, never the container.
281
- # Defaults to 0, so an ordinary restart stays offline and instant.
282
- - UNOVERSE_BASE_REFRESH=${UNOVERSE_BASE_REFRESH:-0}
283
278
  volumes:
284
279
  - unoverse_plugins:/app/plugins
285
- # THE NODE RUNTIME, which this image does not carry (MARKETPLACE.md §5a). The
286
- # entrypoint installs @unoverse-platform/base here at startup, within the range
287
- # apps/unoverse/package.json declares, and symlinks it into
288
- # /app/node_modules/@unoverse-platform/base. A volume rather than a layer so a plain
289
- # restart is offline and instant: the fetch happens on a cold volume, or when the
290
- # declared range changes, or when a deploy sets UNOVERSE_BASE_REFRESH=1.
291
- - unoverse_base:/app/base
280
+ # THE NODE RUNTIME IS IN THE IMAGE (MARKETPLACE.md §5a, reversed 2026-09-03). There was
281
+ # a `unoverse_base:/app/base` volume here between 2026-08-30 and 2026-09-03, holding a
282
+ # base the entrypoint installed from npm at startup. Both are gone: base is compiled in
283
+ # from the workspace, so the image and its runtime cannot disagree.
292
284
  # ── Platform-dev mounts (monorepo only; sync-starter strips these three
293
285
  # lines from the starter's copy — a customer universe serves what the DB
294
286
  # says). Local edits are picked up on restart; design/prompts are data, no build.
@@ -407,4 +399,3 @@ services:
407
399
  volumes:
408
400
  umap_models:
409
401
  unoverse_plugins: # Marketplace-installed node packages (persisted across restarts)
410
- unoverse_base: # @unoverse-platform/base, installed at startup (MARKETPLACE.md §5a)
@@ -79,27 +79,18 @@ cmd_check() {
79
79
 
80
80
  # 1b. THE NODE RUNTIME THIS UNIVERSE IS ACTUALLY RUNNING.
81
81
  #
82
- # base is installed at startup, not baked into the image (MARKETPLACE.md §5a), so a
83
- # universe's platform version and its runtime version are two different numbers that move
84
- # independently. Nobody updates what they cannot see: this is the line that turns "which
85
- # base am I on?" into something answerable, and shows the gap when the registry has newer.
82
+ # base ships INSIDE the image (MARKETPLACE.md §5a, reversed 2026-09-03), so this number
83
+ # cannot disagree with the platform version above and printing it is how you can tell.
84
+ # It used to be installed from npm at startup and compared against the registry here,
85
+ # because the two moved independently; they do not any more, and a line that still
86
+ # offered "npm has newer" would be advertising a lane that no longer exists.
86
87
  # Read out of the RUNNING container rather than off this machine's disk, because the
87
88
  # container is the only thing whose answer counts.
88
- local base_installed base_range base_latest
89
+ local base_installed
89
90
  base_installed=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
90
91
  node -p "require('/app/node_modules/@unoverse-platform/base/package.json').version" 2>/dev/null | tr -d '\r')
91
92
  if [ -n "$base_installed" ]; then
92
- base_range=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
93
- node -p "require('/app/apps/unoverse/package.json').dependencies['@unoverse-platform/base']" 2>/dev/null | tr -d '\r')
94
- base_latest=$(curl -s --max-time 5 "https://registry.npmjs.org/-/package/@unoverse-platform%2Fbase/dist-tags" 2>/dev/null \
95
- | node -pe 'try{JSON.parse(require("fs").readFileSync(0,"utf8")).latest ?? ""}catch(e){""}' 2>/dev/null)
96
- if [ -n "$base_latest" ] && [ "$base_latest" != "$base_installed" ]; then
97
- # NOT a failure. A universe is entitled to sit on an older runtime; it is not entitled
98
- # to be unable to find out. `deploy` and `update` are what take the newer one.
99
- warn "node runtime ${BOLD}$base_installed${NC} ${DIM}(range $base_range · npm has $base_latest — 'unoverse deploy' takes it)${NC}"
100
- else
101
- ok "node runtime ${BOLD}$base_installed${NC} ${DIM}(range ${base_range:-unknown})${NC}"
102
- fi
93
+ ok "node runtime ${BOLD}$base_installed${NC} ${DIM}(in the image)${NC}"
103
94
  echo ""
104
95
  fi
105
96
 
@@ -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",
@@ -155,12 +155,10 @@ case "${1:-}" in
155
155
  exit 1
156
156
  fi
157
157
  if [ -n "$(docker compose -f "$ROOT/docker-compose.yml" ps -q 2>/dev/null)" ]; then
158
- # THE NODE RUNTIME UPDATES LIKE THE IMAGES DO. base is installed at startup, not
159
- # baked (MARKETPLACE.md §5a), and the entrypoint only goes to the registry on a cold
160
- # volume or a changed range otherwise every restart would be a network call. An
161
- # UPDATE is exactly the moment the operator asked for the newest of everything, so
162
- # it says so. Scoped to this command; nothing is written to .env.
163
- UNOVERSE_BASE_REFRESH=1 \
158
+ # THE NODE RUNTIME ARRIVES WITH THE IMAGE. base is baked in, not installed
159
+ # (MARKETPLACE.md §5a, reversed 2026-09-03), so pulling the image IS updating the
160
+ # runtime and there is nothing extra to ask for. `UNOVERSE_BASE_REFRESH=1` used to
161
+ # ride this line to mean "take the newest base in range"; the range is gone with it.
164
162
  docker compose -f "$ROOT/docker-compose.yml" --env-file "$ROOT/.env" up -d --remove-orphans >/dev/null 2>&1 \
165
163
  && ok "Running services moved to the new images"
166
164
  # MIGRATIONS RUN ON UPDATE, same as deploy and start. An update is exactly when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.194",
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
+ };