unoverse 0.1.213 → 0.1.215
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/unoverse.mjs +3 -3
- package/lib/create/index.mjs +18 -2
- package/lib/lint.mjs +11 -0
- package/lib/publish.mjs +28 -14
- package/lib/workspace.mjs +19 -3
- package/operator/docker-compose.yml +10 -9
- package/operator/lib/publish-assets.mjs +6 -0
- package/operator/lib/start.sh +2 -2
- package/package.json +1 -1
- package/vendor/base/items/baseVersion.js +1 -1
- package/vendor/base/items/collect.js +77 -5
- package/vendor/base/items/publish.js +21 -1
- package/vendor/base/lint/design/file.mjs +2 -2
- package/vendor/base/lint/design/index.mjs +74 -23
- package/vendor/base/lint/design/vocabulary.mjs +2 -2
- package/vendor/base/lint/nodes/_schema/_defs.schema.json +1 -1
- package/vendor/base/lint/nodes/blocks.mjs +3 -3
- package/vendor/base/lint/nodes/node.mjs +1 -1
- package/vendor/base/lint/objects/index.mjs +38 -11
- package/vendor/base/lint/prompts/index.mjs +15 -5
package/bin/unoverse.mjs
CHANGED
|
@@ -261,7 +261,7 @@ switch (cmd) {
|
|
|
261
261
|
// No URL typed: the workspace's committed target is the obvious answer.
|
|
262
262
|
const { findWorkspace, readConfig } = await import("../lib/workspace.mjs");
|
|
263
263
|
const ws = findWorkspace();
|
|
264
|
-
if (ws?.kind === "assets") target = (await readConfig(ws.root)).universe;
|
|
264
|
+
if (ws?.kind === "assets" || ws?.kind === "org") target = (await readConfig(ws.root)).universe;
|
|
265
265
|
}
|
|
266
266
|
if (!target) {
|
|
267
267
|
console.error(`\n Which universe? unoverse login <url>\n ${dim("(or run it from a workspace whose unoverse.yaml names one)")}\n`);
|
|
@@ -284,8 +284,8 @@ switch (cmd) {
|
|
|
284
284
|
if (args[0] === "studio") {
|
|
285
285
|
const { findWorkspace } = await import("../lib/workspace.mjs");
|
|
286
286
|
const ws = findWorkspace();
|
|
287
|
-
if (ws?.kind !== "assets") {
|
|
288
|
-
console.log(`\n deploy studio ships
|
|
287
|
+
if (ws?.kind !== "assets" && ws?.kind !== "org") {
|
|
288
|
+
console.log(`\n deploy studio ships an org repo or design workspace, and there is none above ${process.cwd()}.\n Start one with: unoverse create\n`);
|
|
289
289
|
process.exit(1);
|
|
290
290
|
}
|
|
291
291
|
const { publish } = await import("../lib/publish.mjs");
|
package/lib/create/index.mjs
CHANGED
|
@@ -25,9 +25,25 @@ export async function create(nameArg) {
|
|
|
25
25
|
try {
|
|
26
26
|
|
|
27
27
|
if (choice === "1") {
|
|
28
|
-
|
|
28
|
+
// ONE ORG, ONE REPO (UNO-88). This folder becomes the org's own repo: its folders at
|
|
29
|
+
// the root, its name declared in unoverse.yaml, Git with a first commit. Studio makes
|
|
30
|
+
// it on first launch from the name asked here, then opens it.
|
|
31
|
+
const here = process.cwd();
|
|
32
|
+
const suggested = (nameArg || here.split(/[\\/]/).pop() || "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+|-+$/g, "") || "my-org";
|
|
33
|
+
let org = "";
|
|
34
|
+
for (;;) {
|
|
35
|
+
org = (await ask(rl, `Org name ${dim(`[${suggested}]`)}: `)) || suggested;
|
|
36
|
+
if (/^[a-z][a-z0-9-]{0,63}$/.test(org)) break;
|
|
37
|
+
console.log(` ${dim("Lowercase letters, digits and hyphens, starting with a letter.")}`);
|
|
38
|
+
}
|
|
29
39
|
rl.close();
|
|
30
|
-
|
|
40
|
+
console.log(`\n ${dim(`Making ${here} the org "${org}", then opening Studio.`)}\n`);
|
|
41
|
+
// In the platform monorepo, this repo's own Studio (so unreleased work can be tried);
|
|
42
|
+
// everywhere else, the published one.
|
|
43
|
+
const local = resolve(dirname(fileURLToPath(import.meta.url)), "../../../studio/bin/studio.mjs");
|
|
44
|
+
const r = existsSync(local)
|
|
45
|
+
? spawnSync(process.execPath, [local, "--org", org], { stdio: "inherit" })
|
|
46
|
+
: spawnSync("npx", ["-y", "@unoverse-platform/studio@latest", "--org", org], { stdio: "inherit" });
|
|
31
47
|
process.exit(r.status ?? 0);
|
|
32
48
|
}
|
|
33
49
|
|
package/lib/lint.mjs
CHANGED
|
@@ -39,6 +39,17 @@ export async function lintProject({ lintForPublish, designRoot, project }) {
|
|
|
39
39
|
export async function lint(args, importBase) {
|
|
40
40
|
const positional = args.find((a) => !a.startsWith("--"));
|
|
41
41
|
const ws = findWorkspace();
|
|
42
|
+
// AN ORG REPO (UNO-88): one org, declared; lint it whole with its declared name.
|
|
43
|
+
if (ws?.kind === "org") {
|
|
44
|
+
if (positional && positional !== ws.org) {
|
|
45
|
+
console.error(`\n ${c.red}✗${c.off} this repo is the org "${ws.org}", not "${positional}"\n`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const { lintOrg } = await importBase("items/publish.js");
|
|
49
|
+
console.log(`\n ${c.bold}lint ${ws.org}${c.off}\n`);
|
|
50
|
+
const { errors } = await lintProject({ lintForPublish: () => lintOrg({ org: ws.org, dir: ws.root }), designRoot: ws.root, project: ws.org });
|
|
51
|
+
process.exit(errors.length ? 1 : 0);
|
|
52
|
+
}
|
|
42
53
|
if (ws?.kind !== "assets") {
|
|
43
54
|
console.error(`\n ${c.red}✗${c.off} no design/ folder here or above. Run this inside your workspace.\n`);
|
|
44
55
|
process.exit(1);
|
package/lib/publish.mjs
CHANGED
|
@@ -64,23 +64,37 @@ export async function publish(args) {
|
|
|
64
64
|
const positional = args.find((a) => !a.startsWith("--") && !optionValues.has(a));
|
|
65
65
|
|
|
66
66
|
const ws = findWorkspace();
|
|
67
|
-
if (ws?.kind !== "assets") die("no design/ folder here or above. Run this inside your workspace.");
|
|
68
|
-
const designRoot = designHome(ws.root);
|
|
67
|
+
if (ws?.kind !== "assets" && ws?.kind !== "org") die("no org repo or design/ folder here or above. Run this inside your workspace.");
|
|
69
68
|
|
|
70
|
-
const { collectProject, listProjects } = await importBase("items/collect.js");
|
|
71
|
-
const { lintForPublish, planPublish, sendPublish } = await importBase("items/publish.js");
|
|
69
|
+
const { collectProject, listProjects, collectOrg } = await importBase("items/collect.js");
|
|
70
|
+
const { lintForPublish, planPublish, sendPublish, lintOrg } = await importBase("items/publish.js");
|
|
72
71
|
|
|
73
72
|
// ── which project ───────────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// An org repo (UNO-88) IS one org, declared in its unoverse.yaml; the monorepo shape
|
|
74
|
+
// (design/<project>/) may hold several and asks which.
|
|
76
75
|
let project = positional;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
let designRoot;
|
|
77
|
+
let collect;
|
|
78
|
+
let lintFor = lintForPublish;
|
|
79
|
+
if (ws.kind === "org") {
|
|
80
|
+
if (project && project !== ws.org) die(`this repo is the org "${ws.org}", not "${project}"`);
|
|
81
|
+
project = ws.org;
|
|
82
|
+
designRoot = ws.root;
|
|
83
|
+
collect = () => collectOrg({ org: ws.org, dir: ws.root });
|
|
84
|
+
lintFor = () => lintOrg({ org: ws.org, dir: ws.root });
|
|
85
|
+
} else {
|
|
86
|
+
designRoot = designHome(ws.root);
|
|
87
|
+
const projects = listProjects(designRoot);
|
|
88
|
+
if (!projects.length) die(`nothing to publish: ${designRoot} holds no projects`);
|
|
89
|
+
if (!project) {
|
|
90
|
+
if (projects.length > 1) {
|
|
91
|
+
die(`several projects here. Say which:\n\n unoverse deploy studio <project>\n\n Found: ${projects.join(", ")}`);
|
|
92
|
+
}
|
|
93
|
+
project = projects[0];
|
|
94
|
+
} else if (!projects.includes(project)) {
|
|
95
|
+
die(`no project "${project}" in ${designRoot}. Found: ${projects.join(", ") || "none"}`);
|
|
80
96
|
}
|
|
81
|
-
|
|
82
|
-
} else if (!projects.includes(project)) {
|
|
83
|
-
die(`no project "${project}" in ${designRoot}. Found: ${projects.join(", ") || "none"}`);
|
|
97
|
+
collect = () => collectProject(designRoot, project);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
// ── which universe: --to overrides, --env names one from unoverse.yaml's
|
|
@@ -127,14 +141,14 @@ export async function publish(args) {
|
|
|
127
141
|
// ── 1. lint. Nothing is sent if this fails ──────────────────────────────────
|
|
128
142
|
console.log(`\n ${c.bold}deploy ${project}${c.off} ${c.dim}→ ${universe}${c.off}\n`);
|
|
129
143
|
const { lintProject } = await import("./lint.mjs");
|
|
130
|
-
const { errors } = await lintProject({ lintForPublish, designRoot, project });
|
|
144
|
+
const { errors } = await lintProject({ lintForPublish: lintFor, designRoot, project });
|
|
131
145
|
if (errors.length) {
|
|
132
146
|
console.error(` ${c.red}Nothing was sent.${c.off}\n`);
|
|
133
147
|
process.exit(1);
|
|
134
148
|
}
|
|
135
149
|
|
|
136
150
|
// ── 2. the credential, only now that there is something worth sending ───────
|
|
137
|
-
const items =
|
|
151
|
+
const items = collect();
|
|
138
152
|
if (!items.length) die(`nothing to publish in ${project}`);
|
|
139
153
|
let token;
|
|
140
154
|
try {
|
package/lib/workspace.mjs
CHANGED
|
@@ -34,16 +34,20 @@ export function designHome(root) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Walk up from `from` for the nearest marker.
|
|
37
|
+
* Walk up from `from` for the nearest marker. An org repo (`unoverse.yaml` with `org:`) wins
|
|
38
|
+
* at its own level, then a design home, then a universe. The NEAREST one decides what `publish`
|
|
38
39
|
* means (LOCAL_STUDIO.md §Publish is terminal-only: one verb, the folder decides):
|
|
39
40
|
* a design home → this is an asset workspace, publish means item rows; a
|
|
40
41
|
* docker-compose.yml first → this is a universe, publish stays the operator's.
|
|
41
42
|
* Checked in that order at each level, so a universe folder that also holds a design
|
|
42
|
-
* tree
|
|
43
|
-
*
|
|
43
|
+
* tree is an asset workspace when you stand in it, and a universe when you stand at its
|
|
44
|
+
* compose root. The platform monorepo holds no design tree (retired 2026-10-04), so
|
|
45
|
+
* anywhere inside it is the universe its compose root describes.
|
|
44
46
|
*/
|
|
45
47
|
export function findWorkspace(from = process.cwd()) {
|
|
46
48
|
for (let dir = resolve(from); ; ) {
|
|
49
|
+
const org = readOrgName(dir);
|
|
50
|
+
if (org) return { kind: "org", root: dir, org };
|
|
47
51
|
if (hasDesignHome(dir)) return { kind: "assets", root: dir };
|
|
48
52
|
if (existsSync(join(dir, "docker-compose.yml"))) return { kind: "universe", root: dir };
|
|
49
53
|
const parent = resolve(dir, "..");
|
|
@@ -54,6 +58,18 @@ export function findWorkspace(from = process.cwd()) {
|
|
|
54
58
|
|
|
55
59
|
const CONFIG_FILE = "unoverse.yaml";
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* AN ORG REPO (UNO-88): its `unoverse.yaml` declares `org: <slug>`, and the org's folders
|
|
63
|
+
* sit at the root with no design/. The name is declared, never read off the folder. Mirrors
|
|
64
|
+
* base items/collect.ts readOrgName; sync, because the marker walk above is.
|
|
65
|
+
*/
|
|
66
|
+
export function readOrgName(root) {
|
|
67
|
+
const path = join(root, CONFIG_FILE);
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
const m = /^org:\s*["']?([a-z][a-z0-9-]{0,63})["']?\s*(?:#.*)?$/m.exec(readFileSync(path, "utf8"));
|
|
70
|
+
return m ? m[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
/** Read unoverse.yaml. Absent file is `{}`, not an error — first publish writes it. */
|
|
58
74
|
export async function readConfig(root) {
|
|
59
75
|
const path = join(root, CONFIG_FILE);
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
# - unoverse (the platform runtime — engine in-process + node plane + native MCP),
|
|
7
7
|
# canvas, umap, memory. Code protected, pulled as images.
|
|
8
8
|
#
|
|
9
|
-
# LAYER 2: PLATFORM-DEV
|
|
10
|
-
# -
|
|
11
|
-
#
|
|
12
|
-
# In a STARTER/customer universe
|
|
9
|
+
# LAYER 2: PLATFORM-DEV MOUNT (monorepo only; sync-starter strips it)
|
|
10
|
+
# - packages/marketplace/definitions the marketplace tier (components, templates,
|
|
11
|
+
# styles, skills, blocks, nodes, objects, pipelines)
|
|
12
|
+
# In a STARTER/customer universe this folder does not exist: authoring is
|
|
13
13
|
# Studio's (publish over the API) and content is DB-driven (marketplace).
|
|
14
14
|
# Everything else under apps/unoverse (engine, server, web, tools) is image-only.
|
|
15
15
|
#
|
|
@@ -296,7 +296,7 @@ services:
|
|
|
296
296
|
- PLUGINS_DIR=/app/plugins
|
|
297
297
|
# Dev-editable folders are mounted over the image below; NODE_PATH resolves
|
|
298
298
|
# their hoisted deps from the local workspace node_modules. The plugins dir is
|
|
299
|
-
# also on NODE_PATH so locally-built component nodes (
|
|
299
|
+
# also on NODE_PATH so locally-built component nodes (packages/marketplace/definitions/nodes/*, which
|
|
300
300
|
# live OUTSIDE /app/plugins) can resolve @unoverse-platform/plugin-base — the only
|
|
301
301
|
# copy carrying a built dist. Without this the runtime hits MODULE_NOT_FOUND and
|
|
302
302
|
# the nodes show "Package not installed".
|
|
@@ -307,10 +307,11 @@ services:
|
|
|
307
307
|
# a `unoverse_base:/app/base` volume here between 2026-08-30 and 2026-09-03, holding a
|
|
308
308
|
# base the entrypoint installed from npm at startup. Both are gone: base is compiled in
|
|
309
309
|
# from the workspace, so the image and its runtime cannot disagree.
|
|
310
|
-
# ── Platform-dev
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
|
|
310
|
+
# ── Platform-dev mount (monorepo only; sync-starter strips this line from
|
|
311
|
+
# the starter's copy, so a customer universe serves what the DB says). Local
|
|
312
|
+
# edits are picked up on restart; definitions are data, no build. The design
|
|
313
|
+
# tree mount went with the design tree (2026-10-04).
|
|
314
|
+
- ./packages/marketplace/definitions:/app/packages/marketplace/definitions # the marketplace tier: every authored kind, read live, and also how base finds the content home (paths.ts findHome)
|
|
314
315
|
- ./node_modules:/app/host_node_modules # Hoisted deps for locally-built nodes
|
|
315
316
|
healthcheck:
|
|
316
317
|
# Ready = internal runtime port up (nodes are loaded into memory BEFORE it binds).
|
|
@@ -40,6 +40,12 @@ const die = (msg) => {
|
|
|
40
40
|
process.exit(1);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// The platform repo's design/ was retired 2026-10-04: no org is authored here any more. An
|
|
44
|
+
// absent folder is no org tier, said plainly, rather than a lint error about a missing folder.
|
|
45
|
+
if (!existsSync(designRoot)) {
|
|
46
|
+
die(`no design/ folder at ${designRoot}. An org lives in its own repo and publishes from there; pass --design <folder> for a design tree kept elsewhere.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
if (!project) {
|
|
44
50
|
const projects = listProjects(designRoot);
|
|
45
51
|
die(`which project?\n\n unoverse publish <project> --to <universe>\n\n Found: ${projects.join(", ") || "none"}`);
|
package/operator/lib/start.sh
CHANGED
|
@@ -286,8 +286,8 @@ build_component_nodes() {
|
|
|
286
286
|
|
|
287
287
|
banner "Component Nodes (definition-backed)"
|
|
288
288
|
|
|
289
|
-
echo " Building the marketplace package locally (
|
|
290
|
-
if (cd "$ROOT/packages/marketplace" &&
|
|
289
|
+
echo " Building the marketplace package locally (tsc → dist)..."
|
|
290
|
+
if (cd "$ROOT/packages/marketplace" && npx tsc 2>/dev/null); then
|
|
291
291
|
ok "Marketplace package built → packages/marketplace/dist"
|
|
292
292
|
elif [ -d "$ROOT/packages/marketplace/dist" ]; then
|
|
293
293
|
# Starters can't build it (no workspace plugin-base) — the dist SHIPS with the
|
package/package.json
CHANGED
|
@@ -69,7 +69,7 @@ function packageVersion(designSystemDir) {
|
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* Compute the version of the design system at `designSystemDir` (the resolved marketplace
|
|
72
|
-
* directory: on
|
|
72
|
+
* directory: the tier on disk, or the installed bundle).
|
|
73
73
|
*
|
|
74
74
|
* Hashes components, atoms and styles together, because they only mean anything as a set:
|
|
75
75
|
* a universe holding half of them renders against a contract with holes.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* performs correctly, and a half-published project is worse than either extreme.
|
|
19
19
|
*/
|
|
20
20
|
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
21
|
-
import { join, relative, basename } from "node:path";
|
|
21
|
+
import { join, relative, basename, dirname, extname } from "node:path";
|
|
22
22
|
import { fingerprintOf } from "./fingerprint.js";
|
|
23
23
|
import { parse as parseYaml } from "yaml";
|
|
24
24
|
import { readDiskPackages } from "../manifests/source.js";
|
|
@@ -40,6 +40,34 @@ function designSystemDir(designRoot) {
|
|
|
40
40
|
return bundled;
|
|
41
41
|
return onDisk; // nothing found: the version will hash to an empty set, which is honest
|
|
42
42
|
}
|
|
43
|
+
const ORG_SLUG = /^[a-z][a-z0-9-]{0,63}$/;
|
|
44
|
+
/** The org a folder declares in its `unoverse.yaml`, or null when it is not an org repo. */
|
|
45
|
+
export function readOrgName(dir) {
|
|
46
|
+
const file = join(dir, "unoverse.yaml");
|
|
47
|
+
if (!existsSync(file))
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const doc = parseYaml(readFileSync(file, "utf8"));
|
|
51
|
+
const org = typeof doc?.org === "string" ? doc.org.trim() : "";
|
|
52
|
+
return ORG_SLUG.test(org) ? org : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** This folder as an org repo, or null. */
|
|
59
|
+
export function orgRepo(dir) {
|
|
60
|
+
const org = readOrgName(dir);
|
|
61
|
+
return org ? { org, dir } : null;
|
|
62
|
+
}
|
|
63
|
+
/** An org repo's design system: only ever the bundle installed inside that repo. */
|
|
64
|
+
function orgDesignSystemDir(dir) {
|
|
65
|
+
return join(dir, "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions");
|
|
66
|
+
}
|
|
67
|
+
/** Collect an org repo into items: the same walk as a monorepo project, rooted at the repo. */
|
|
68
|
+
export function collectOrg(home) {
|
|
69
|
+
return collectFrom(home.dir, home.org, orgDesignSystemDir(home.dir));
|
|
70
|
+
}
|
|
43
71
|
/** Every project a developer could publish, by name. */
|
|
44
72
|
export function listProjects(designRoot) {
|
|
45
73
|
if (!existsSync(designRoot))
|
|
@@ -49,6 +77,46 @@ export function listProjects(designRoot) {
|
|
|
49
77
|
.filter((e) => statSync(join(designRoot, e)).isDirectory())
|
|
50
78
|
.sort();
|
|
51
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* AN ORG'S IMAGES TRAVEL INSIDE ITS DEFINITIONS. On disk the loader turns `src: assets/x.png`
|
|
82
|
+
* into the file's data URI (definitions/load/assets.ts); a universe holds the definition and
|
|
83
|
+
* never the org's folder, so a deploy that sent the bare path shipped a broken image. The same
|
|
84
|
+
* lookup, done here before sending: the file's folder upward to its org (the one holding
|
|
85
|
+
* `styles/`), then the shared design system's. Same types, same 256 KB cap. Self-contained on
|
|
86
|
+
* purpose: this file is vendored into the published CLI with only its own imports.
|
|
87
|
+
*/
|
|
88
|
+
const ASSET_TYPES = {
|
|
89
|
+
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
|
90
|
+
};
|
|
91
|
+
function assetUri(fromDir, src) {
|
|
92
|
+
const rel = src.slice("assets/".length);
|
|
93
|
+
const type = ASSET_TYPES[extname(rel).toLowerCase()];
|
|
94
|
+
if (!type)
|
|
95
|
+
return null;
|
|
96
|
+
let file = null;
|
|
97
|
+
for (let d = fromDir; dirname(d) !== d; d = dirname(d)) {
|
|
98
|
+
if (existsSync(join(d, "assets", rel))) {
|
|
99
|
+
file = join(d, "assets", rel);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (existsSync(join(d, "styles"))) {
|
|
103
|
+
const shared = join(dirname(d), "marketplace", "assets", rel);
|
|
104
|
+
if (existsSync(shared))
|
|
105
|
+
file = shared;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return file && statSync(file).size <= 256 * 1024 ? `data:${type};base64,${readFileSync(file).toString("base64")}` : null;
|
|
110
|
+
}
|
|
111
|
+
const readDef = (p) => {
|
|
112
|
+
const text = readFileSync(p, "utf8");
|
|
113
|
+
if (!/\.ya?ml$/.test(p))
|
|
114
|
+
return text;
|
|
115
|
+
return text.replace(/^(\s*(?:-\s+)?[\w-]+:\s*)(["']?)(assets\/[^\s"'#]+)\2([ \t]*(?:#.*)?)$/gm, (whole, key, _q, src, rest) => {
|
|
116
|
+
const uri = assetUri(dirname(p), src);
|
|
117
|
+
return uri ? `${key}"${uri}"${rest}` : whole;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
52
120
|
/** Every definition file under a folder, relative-pathed, as one object. */
|
|
53
121
|
function filesUnder(dir) {
|
|
54
122
|
const out = {};
|
|
@@ -62,7 +130,7 @@ function filesUnder(dir) {
|
|
|
62
130
|
if (e.isDirectory())
|
|
63
131
|
walk(p);
|
|
64
132
|
else if (isDef(e.name) || e.name.endsWith(".md"))
|
|
65
|
-
out[relative(dir, p)] =
|
|
133
|
+
out[relative(dir, p)] = readDef(p);
|
|
66
134
|
}
|
|
67
135
|
};
|
|
68
136
|
walk(dir);
|
|
@@ -80,10 +148,14 @@ export function collectProject(designRoot, project) {
|
|
|
80
148
|
const root = join(designRoot, project);
|
|
81
149
|
if (!existsSync(root))
|
|
82
150
|
throw new Error(`No project "${project}" in ${designRoot}`);
|
|
151
|
+
return collectFrom(root, project, designSystemDir(designRoot));
|
|
152
|
+
}
|
|
153
|
+
/** The walk itself: one org's folder, its name, and the design system it was built on. */
|
|
154
|
+
function collectFrom(root, project, designSystem) {
|
|
83
155
|
// WHAT THIS WAS BUILT ON, stamped on every item. Computed once per collection: it is the
|
|
84
156
|
// same design system for the whole project, and hashing it per item would be wasteful and
|
|
85
157
|
// could disagree with itself mid-run if a file changed.
|
|
86
|
-
const base_version = designSystemVersion(
|
|
158
|
+
const base_version = designSystemVersion(designSystem);
|
|
87
159
|
const items = [];
|
|
88
160
|
// A COMPONENT (and, since 2026-08-24, a SKILL) row's name is the qualified ref
|
|
89
161
|
// (`<org>/<name>`): the items table's identity is (kind, name) with no org in the
|
|
@@ -146,7 +218,7 @@ export function collectProject(designRoot, project) {
|
|
|
146
218
|
else if (isDef(e.name)) {
|
|
147
219
|
// The name is the definition's, without the extension: `card.yaml` is `card`,
|
|
148
220
|
// addressed the same way whether it later grows into a folder.
|
|
149
|
-
add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]:
|
|
221
|
+
add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]: readDef(join(home, e.name)) } });
|
|
150
222
|
}
|
|
151
223
|
}
|
|
152
224
|
}
|
|
@@ -212,7 +284,7 @@ export function collectProject(designRoot, project) {
|
|
|
212
284
|
// 2026-09-08 it looked at a `nodes/` folder beside design/ while every org already
|
|
213
285
|
// kept its nodes INSIDE its project, so the same nodes were left behind again. Org
|
|
214
286
|
// nodes are org-scoped like skills; the marketplace tier's own nodes sit under
|
|
215
|
-
//
|
|
287
|
+
// the marketplace tier, which is never collected.
|
|
216
288
|
const nodesHome = join(root, "nodes");
|
|
217
289
|
if (existsSync(nodesHome)) {
|
|
218
290
|
for (const pkg of collectNodePackages(nodesHome)) {
|
|
@@ -75,6 +75,25 @@ export async function lintForPublish(designRoot, project) {
|
|
|
75
75
|
const mine = result.problems.filter((p) => !p.file || !inDesign(p.file) || inProject(p.file));
|
|
76
76
|
return { problems: mine, errors: mine.filter((p) => p.level === "error") };
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Lint an org repo (UNO-88): the same four linters, told which folder is the org and what it
|
|
80
|
+
* is called. The repo holds one org, so every finding is that org's; nothing to filter.
|
|
81
|
+
*/
|
|
82
|
+
export async function lintOrg(home) {
|
|
83
|
+
const org = { name: home.org, dir: home.dir };
|
|
84
|
+
const { lintDefinitions } = await import("../lint/design/index.mjs");
|
|
85
|
+
const result = lintDefinitions(home.dir, { org });
|
|
86
|
+
const nodesHome = join(home.dir, "nodes");
|
|
87
|
+
if (existsSync(nodesHome)) {
|
|
88
|
+
const { lintNodes } = await import("../lint/nodes/index.mjs");
|
|
89
|
+
result.problems.push(...lintNodes(nodesHome).problems);
|
|
90
|
+
}
|
|
91
|
+
const { lintObjects } = await import("../lint/objects/index.mjs");
|
|
92
|
+
result.problems.push(...lintObjects(home.dir, { org }).problems);
|
|
93
|
+
const { lintPrompts } = await import("../lint/prompts/index.mjs");
|
|
94
|
+
result.problems.push(...lintPrompts(home.dir, { org }).problems);
|
|
95
|
+
return { problems: result.problems, errors: result.problems.filter((p) => p.level === "error") };
|
|
96
|
+
}
|
|
78
97
|
/**
|
|
79
98
|
* What publishing WOULD do, without doing it.
|
|
80
99
|
*
|
|
@@ -196,7 +215,8 @@ export async function sendPublish(plan, universe, token, fetchImpl = fetch) {
|
|
|
196
215
|
const res = await fetchImpl(`${universe}/publish`, {
|
|
197
216
|
method: "POST",
|
|
198
217
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
199
|
-
|
|
218
|
+
// The org is part of an item's identity (UNO-89): remove THIS org's row, never another's.
|
|
219
|
+
body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name, org: r.org }),
|
|
200
220
|
});
|
|
201
221
|
const body = (await res.json().catch(() => ({})));
|
|
202
222
|
if (res.ok)
|
|
@@ -175,7 +175,7 @@ function checkAppTree(json, root, file) {
|
|
|
175
175
|
if (ref.includes("/"))
|
|
176
176
|
report("error", file, `state "${where}" preview names "${ref}" with an org prefix. Studio seeds a preview by the component's bare name ("${ref.split("/").pop()}"); a prefixed name resolves to nothing and the state's tab shows nothing (docs.unoverse.ai/design/apps)`);
|
|
177
177
|
else if (comps && !comps.has(ref.toLowerCase()))
|
|
178
|
-
report("error", file, `state "${where}" preview names "${ref}", which is no component or template this org can see (
|
|
178
|
+
report("error", file, `state "${where}" preview names "${ref}", which is no component or template this org can see (the marketplace's, this org's components/ and templates/). Studio would seed nothing and the state's tab would show nothing (docs.unoverse.ai/design/apps)`);
|
|
179
179
|
}
|
|
180
180
|
} else if (entry.preview !== undefined && (typeof entry.preview !== "object" || entry.preview === null))
|
|
181
181
|
report("error", file, `state "${where}" preview must be a list of component names, or an object of app-state mock data (docs.unoverse.ai/design/apps)`);
|
|
@@ -578,7 +578,7 @@ function lintFile(file) {
|
|
|
578
578
|
// 2026-09-11); the lookup is the name before the hash.
|
|
579
579
|
for (const c of list)
|
|
580
580
|
if (comps && !comps.has(String(c).split("#")[0].toLowerCase()))
|
|
581
|
-
report("error", file, `preview."${state}" names unknown component "${c}". No match in
|
|
581
|
+
report("error", file, `preview."${state}" names unknown component "${c}". No match in the marketplace's components/ or this org's components/ (org-privacy: another org's components are out of reach; lookup is case-insensitive)`);
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
584
|
}
|
|
@@ -42,10 +42,13 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
42
42
|
const hit = overlay[resolve(f)];
|
|
43
43
|
return hit === undefined ? readFileSync(f, "utf8") : hit;
|
|
44
44
|
};
|
|
45
|
-
|
|
45
|
+
// With no root given: the platform repo's marketplace tier (linted as the root itself, as
|
|
46
|
+
// CI and the release do), else a developer's design/.
|
|
47
|
+
const candidates = designRoot ? [resolve(designRoot)] : [resolve("packages/marketplace/definitions"), resolve("design")];
|
|
46
48
|
|
|
47
|
-
/**
|
|
48
|
-
* with `components/` and `atoms/` loose
|
|
49
|
+
/** A design root holding the design system at `<root>/marketplace/`, or the design system
|
|
50
|
+
* itself as the root, with `components/` and `atoms/` loose in it (how the platform repo
|
|
51
|
+
* lints its tier, `packages/marketplace/definitions`). */
|
|
49
52
|
const holdsDesignSystem = (p) =>
|
|
50
53
|
existsSync(join(p, "marketplace")) || existsSync(join(p, "components")) || existsSync(join(p, "atoms"));
|
|
51
54
|
|
|
@@ -53,7 +56,7 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
53
56
|
* A DEVELOPER'S design/ HOLDS ONLY ORG FOLDERS, and looking for the monorepo's shapes there
|
|
54
57
|
* finds nothing. `design/<org>/{components,styles,apps}` is what Studio scaffolds and
|
|
55
58
|
* the only layout a developer ever has, because the design system is INSTALLED rather
|
|
56
|
-
* than authored (sync-starter.sh keeps
|
|
59
|
+
* than authored (sync-starter.sh keeps the marketplace tier out of a project on purpose).
|
|
57
60
|
*
|
|
58
61
|
* So a project could not be linted, and since publishing lints first, it could not be
|
|
59
62
|
* published either: "no design/ folder here", naming the folder it was standing in.
|
|
@@ -75,7 +78,34 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
75
78
|
});
|
|
76
79
|
};
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* AN ORG'S OWN REPO (RELEASE_LIFECYCLE.md §3, UNO-88). The repo IS the org: its folders sit
|
|
83
|
+
* at the root and its name is declared (`unoverse.yaml` org:), never read off a folder.
|
|
84
|
+
* The caller says so with `options.org = { name, dir }`, and then nothing here guesses:
|
|
85
|
+
* the org list is that one folder, and its name is the one given. Without it a flat repo
|
|
86
|
+
* would read as the legacy design-system layout (components/ at the root) and lint as
|
|
87
|
+
* nothing at all.
|
|
88
|
+
*/
|
|
89
|
+
const ORG_REPO = options.org?.dir ? resolve(options.org.dir) : null;
|
|
90
|
+
const ORG_NAME = options.org?.name ?? null;
|
|
91
|
+
/**
|
|
92
|
+
* THE MARKETPLACE TIER BY NAME, when it is not `<root>/marketplace`. The platform repo
|
|
93
|
+
* authors it at packages/marketplace/definitions (base paths.ts `marketplace`), outside any
|
|
94
|
+
* design root, and a caller that knows that says so. The tier then reads exactly as
|
|
95
|
+
* `<root>/marketplace` would: the design system, and a home named `marketplace`.
|
|
96
|
+
*/
|
|
97
|
+
const MARKETPLACE = !ORG_REPO && options.marketplace ? resolve(options.marketplace) : null;
|
|
98
|
+
const DESIGN_ROOT = ORG_REPO
|
|
99
|
+
? dirname(ORG_REPO)
|
|
100
|
+
: candidates.find((p) => holdsDesignSystem(p) || holdsOrgs(p)) ??
|
|
101
|
+
(MARKETPLACE && (existsSync(candidates[0]) || existsSync(MARKETPLACE)) ? candidates[0] : undefined);
|
|
102
|
+
/**
|
|
103
|
+
* NO ORG TIER HERE. The platform repo authors no orgs (its design/ was retired 2026-10-04),
|
|
104
|
+
* so a caller naming the marketplace tier may hand a design root that does not exist. That
|
|
105
|
+
* means "lint the tier alone", never "nothing to lint": an error here would fail every save
|
|
106
|
+
* in the platform repo's Studio on a folder nobody is meant to have.
|
|
107
|
+
*/
|
|
108
|
+
const NO_ORG_TIER = !ORG_REPO && !existsSync(DESIGN_ROOT ?? "");
|
|
79
109
|
if (!DESIGN_ROOT)
|
|
80
110
|
return {
|
|
81
111
|
problems: [{ level: "error", file: candidates[0], msg: `no design/ folder here (looked in: ${candidates.join(", ")})` }],
|
|
@@ -83,17 +113,25 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
83
113
|
};
|
|
84
114
|
|
|
85
115
|
// ── tree layout ──
|
|
86
|
-
// New layout:
|
|
116
|
+
// New layout: <root>/marketplace/{atoms,components,styles} (or the tier named by
|
|
117
|
+
// options.marketplace) + one top-level folder per
|
|
87
118
|
// org (design/<org>/). Legacy layout: components/ + atoms/ at the root, orgs under design/orgs/.
|
|
88
119
|
// The DESIGN SYSTEM is the primary lint target; org folders get the SAME generic
|
|
89
120
|
// checks — nothing here may key on a specific org's name.
|
|
90
|
-
// WHERE THE DESIGN SYSTEM ACTUALLY IS.
|
|
91
|
-
// deliberately absent from a developer's project (sync-starter.sh): the platform installs
|
|
92
|
-
// @unoverse-platform/marketplace, whose `definitions/`
|
|
93
|
-
// AND styles (
|
|
121
|
+
// WHERE THE DESIGN SYSTEM ACTUALLY IS. `<root>/marketplace/` when a root carries one; it
|
|
122
|
+
// is deliberately absent from a developer's project (sync-starter.sh): the platform installs
|
|
123
|
+
// @unoverse-platform/marketplace, whose `definitions/` folder carries components, atoms
|
|
124
|
+
// AND styles (authored there, packages/marketplace/definitions in the platform repo). All three matter — the space-scale check reads
|
|
94
125
|
// styles/base/spacing, so a fallback finding only components would build a PARTIAL scale
|
|
95
126
|
// and reject valid steps, reporting false errors on correct work. Mirrors definitions.ts.
|
|
96
127
|
const DS = (() => {
|
|
128
|
+
// In an org repo the design system is only ever the installed bundle inside that repo:
|
|
129
|
+
// never a sibling folder, which would be another org's repo.
|
|
130
|
+
if (ORG_REPO) {
|
|
131
|
+
const bundled = join(ORG_REPO, "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions");
|
|
132
|
+
return existsSync(bundled) ? bundled : join(ORG_REPO, ".unoverse", "no-design-system");
|
|
133
|
+
}
|
|
134
|
+
if (MARKETPLACE) return MARKETPLACE;
|
|
97
135
|
const onDisk = join(DESIGN_ROOT, "marketplace");
|
|
98
136
|
if (existsSync(onDisk)) return onDisk;
|
|
99
137
|
for (const c of [
|
|
@@ -102,8 +140,20 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
102
140
|
if (existsSync(c)) return c;
|
|
103
141
|
return DESIGN_ROOT; // none anywhere: shared refs will not resolve, and the findings will say so
|
|
104
142
|
})();
|
|
143
|
+
/** Is a design system on disk at all? When not, checks that need it are skipped, not failed. */
|
|
144
|
+
const HAS_DS = DS !== DESIGN_ROOT && existsSync(DS);
|
|
145
|
+
/** The org a home folder belongs to: the declared name in an org repo, else the folder's name. */
|
|
146
|
+
const orgName = (dir) => (ORG_REPO && dir === ORG_REPO ? ORG_NAME : basename(dir));
|
|
147
|
+
/** Every home that can hold node packages, credentials or places: the design system and the orgs. */
|
|
148
|
+
const tierHomes = () =>
|
|
149
|
+
ORG_REPO ? [DS, ...orgDirs] : [...(MARKETPLACE ? [DS] : []), ...(NO_ORG_TIER ? [] : readdirSync(DESIGN_ROOT).map((t) => join(DESIGN_ROOT, t)))];
|
|
150
|
+
/** A file's path below the design root, the named marketplace tier reading as `marketplace/…`. */
|
|
151
|
+
const underRoot = (f) =>
|
|
152
|
+
MARKETPLACE && (f === DS || f.startsWith(DS + sep)) ? ["marketplace", ...relative(DS, f).split(sep)] : relative(DESIGN_ROOT, f).split(sep);
|
|
105
153
|
const legacyOrgsDir = join(DESIGN_ROOT, "orgs");
|
|
106
154
|
const orgDirs = (() => {
|
|
155
|
+
if (ORG_REPO) return [ORG_REPO];
|
|
156
|
+
if (NO_ORG_TIER) return [];
|
|
107
157
|
if (existsSync(legacyOrgsDir))
|
|
108
158
|
return readdirSync(legacyOrgsDir)
|
|
109
159
|
.filter((e) => !e.startsWith("."))
|
|
@@ -176,11 +226,11 @@ const isPropsExtract = (f) => {
|
|
|
176
226
|
const isManifest = (f) => /^manifest\.(json|yaml)$/.test(basename(f));
|
|
177
227
|
// WHICH HOME A FILE IS IN — asked of the path BELOW the design root, never the absolute
|
|
178
228
|
// one. Testing the absolute path for an `apps` segment was true of every file in this
|
|
179
|
-
// monorepo, because the design tree itself
|
|
229
|
+
// monorepo, because the design tree itself lived at `apps/unoverse/design`: every
|
|
180
230
|
// component manifest in the repo was linted as an APP manifest (two bogus warnings each,
|
|
181
231
|
// while the real component-manifest rules never ran once). A rule that cannot tell the
|
|
182
232
|
// homes apart is worse than no rule — it trains people to ignore its output.
|
|
183
|
-
const homeSegments = (f) =>
|
|
233
|
+
const homeSegments = (f) => underRoot(f);
|
|
184
234
|
const isAppPath = (f) => homeSegments(f).includes("apps");
|
|
185
235
|
// THE TEMPLATE KIND (UNOVERSE_TEMPLATE_MODEL.md §9b). Its folders answer to different
|
|
186
236
|
// rules than an app's or a component's, so the rules that would misfire have to be able
|
|
@@ -448,7 +498,7 @@ for (const orgDir of orgDirs) {
|
|
|
448
498
|
const name = statSync(join(home, e)).isDirectory() ? e : isDefFile(e) ? defName(e) : null;
|
|
449
499
|
if (name) set.add(name.toLowerCase());
|
|
450
500
|
}
|
|
451
|
-
orgComponentNames.set(
|
|
501
|
+
orgComponentNames.set(orgName(orgDir), set);
|
|
452
502
|
}
|
|
453
503
|
// TEMPLATE-LOCAL PARTS are addressed `<template>/<part>` (§9b rule 7) — the same
|
|
454
504
|
// qualified shape as `<org>/<name>`, resolved against the template homes. The runtime
|
|
@@ -461,7 +511,7 @@ const orgTemplateNames = new Map(); // org (or DS basename) -> Set(lower block n
|
|
|
461
511
|
for (const root of [DS, ...orgDirs]) {
|
|
462
512
|
const home = join(root, "templates");
|
|
463
513
|
if (!existsSync(home)) continue;
|
|
464
|
-
const blocks = orgTemplateNames.get(
|
|
514
|
+
const blocks = orgTemplateNames.get(orgName(root)) ?? new Set();
|
|
465
515
|
for (const t of readdirSync(home)) {
|
|
466
516
|
if (isDefFile(t)) {
|
|
467
517
|
blocks.add(defName(t).toLowerCase());
|
|
@@ -479,11 +529,11 @@ for (const root of [DS, ...orgDirs]) {
|
|
|
479
529
|
}
|
|
480
530
|
templatePartNames.set(t.toLowerCase(), set);
|
|
481
531
|
}
|
|
482
|
-
orgTemplateNames.set(
|
|
532
|
+
orgTemplateNames.set(orgName(root), blocks);
|
|
483
533
|
}
|
|
484
534
|
const orgOfFile = (file) => {
|
|
485
535
|
const home = orgDirs.find((d) => file.startsWith(d + sep));
|
|
486
|
-
return home ?
|
|
536
|
+
return home ? orgName(home) : null;
|
|
487
537
|
};
|
|
488
538
|
// A valid Ref target if there's ANY resolvable home the FILE may reach: atoms, DS
|
|
489
539
|
// components, or (from inside an org tree) that org's own components — bare or
|
|
@@ -747,7 +797,7 @@ const credentialDefs = new Set();
|
|
|
747
797
|
// Every tier's node packages: the marketplace's and each org's, all at <tier>/nodes/.
|
|
748
798
|
const nodesHomes = [];
|
|
749
799
|
try {
|
|
750
|
-
for (const
|
|
800
|
+
for (const home of tierHomes()) nodesHomes.push(join(home, "nodes"));
|
|
751
801
|
} catch {
|
|
752
802
|
/* no design root: nothing declares a credential */
|
|
753
803
|
}
|
|
@@ -908,7 +958,7 @@ function appSizesForFile(file) {
|
|
|
908
958
|
return appSizesCache.get(home);
|
|
909
959
|
}
|
|
910
960
|
|
|
911
|
-
// The universal component names (
|
|
961
|
+
// The universal component names (the tier's components/*, case-insensitive) — for
|
|
912
962
|
// validating an app manifest's `preview` map. null = the file is not under a design tree.
|
|
913
963
|
const componentNamesCache = new Map();
|
|
914
964
|
// Components an ORG's template may reference: the marketplace tier + that org's OWN
|
|
@@ -918,9 +968,10 @@ function componentNamesForFile(file) {
|
|
|
918
968
|
// `${sep}orgs${sep}` and returned null for anything else, so on the flat layout — which
|
|
919
969
|
// is every project now (`design/<org>/…`, "flat wins") — it answered "no list" and
|
|
920
970
|
// every rule that asks it, preview names included, silently stopped checking.
|
|
921
|
-
const segs =
|
|
971
|
+
const segs = underRoot(file);
|
|
922
972
|
if (segs.length < 2 || segs[0] === "..") return null;
|
|
923
973
|
const projectSegs = segs[0] === "orgs" ? segs.slice(0, 2) : segs.slice(0, 1);
|
|
974
|
+
const projectHome = MARKETPLACE && projectSegs[0] === "marketplace" ? DS : join(DESIGN_ROOT, ...projectSegs);
|
|
924
975
|
const org = projectSegs.join("/");
|
|
925
976
|
const cached = componentNamesCache.get(org);
|
|
926
977
|
if (cached !== undefined) return cached;
|
|
@@ -929,17 +980,17 @@ function componentNamesForFile(file) {
|
|
|
929
980
|
// own names would be left: every marketplace name a preview uses (`card`) then reads as
|
|
930
981
|
// unknown, and a fresh Studio workspace failed lint on its own sample. Unverifiable here,
|
|
931
982
|
// so not asserted.
|
|
932
|
-
if (
|
|
983
|
+
if (!HAS_DS) {
|
|
933
984
|
componentNamesCache.set(org, null);
|
|
934
985
|
return null;
|
|
935
986
|
}
|
|
936
987
|
const dirs = [
|
|
937
988
|
join(DS, "components"),
|
|
938
|
-
join(
|
|
989
|
+
join(projectHome, "components"),
|
|
939
990
|
// TEMPLATES ARE PEERS (§9c): a state that holds one seeds it by name like any
|
|
940
991
|
// component, so the name set a preview is checked against includes them.
|
|
941
992
|
join(DS, "templates"),
|
|
942
|
-
join(
|
|
993
|
+
join(projectHome, "templates"),
|
|
943
994
|
];
|
|
944
995
|
let names = null;
|
|
945
996
|
for (const dir of dirs) {
|
|
@@ -964,7 +1015,7 @@ function placeNames() {
|
|
|
964
1015
|
if (n.type === "Place" && typeof n.name === "string") names.add(n.name);
|
|
965
1016
|
for (const v of Object.values(n)) walk(v);
|
|
966
1017
|
};
|
|
967
|
-
const roots = [DS, ...
|
|
1018
|
+
const roots = [DS, ...tierHomes().filter((d) => !basename(d).startsWith("."))];
|
|
968
1019
|
for (const root of new Set(roots))
|
|
969
1020
|
for (const f of jsonFiles(join(root, "apps"))) {
|
|
970
1021
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The CLOSED SETS. This file is the linter's ground truth.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors
|
|
4
|
+
* Mirrors packages/docs/schemas/design/unoverse.schema.json and the server guards, and UNOVERSE_CONFORMANCE
|
|
5
5
|
* §5 makes keeping them equal a maintenance rule: "One source for each closed set... Keep
|
|
6
6
|
* them equal or they disagree silently."
|
|
7
7
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* without extending the schema and the SDK in the same change.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
// ── ground truth (mirrors design/
|
|
13
|
+
// ── ground truth (mirrors packages/docs/schemas/design/unoverse.schema.json + server guards) ──
|
|
14
14
|
export const PRIMITIVES = new Set([
|
|
15
15
|
"Box", "Stack", "Row", "Column", "Each", "Switch", "ComponentSlot", "Timeline",
|
|
16
16
|
"Text", "Image", "Button", "Input", "Select", "Markdown", "Skeleton", "Icon", "Ref", "Orb", "Template", "Place",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
"template": {
|
|
98
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
|
|
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 packages/marketplace/definitions/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
100
|
}
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -23,7 +23,7 @@ export const HANDLEBARS_HELPERS = new Set([
|
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* The prompt-block library:
|
|
26
|
+
* The prompt-block library: the marketplace tier's blocks/**\/*.md, keyed the way the resolver keys them
|
|
27
27
|
* (engine/src/template/promptBlocks.ts) — the filename camelCased, so markdown-guidelines.md is
|
|
28
28
|
* referenced as {{prompt.markdownGuidelines}}.
|
|
29
29
|
*
|
|
@@ -42,8 +42,8 @@ const CACHE = new Map();
|
|
|
42
42
|
|
|
43
43
|
export function promptBlocks() {
|
|
44
44
|
// The block library is the marketplace tier's. Beside the nodes when they ARE that
|
|
45
|
-
// tier (
|
|
46
|
-
// (design/<org>/nodes → design/marketplace/blocks).
|
|
45
|
+
// tier (packages/marketplace/definitions/{nodes,blocks}); one tier over for an org's own
|
|
46
|
+
// nodes (design/<org>/nodes → design/marketplace/blocks, a design root carrying the tier).
|
|
47
47
|
const home = [resolve(state.nodesHome, "../blocks"), resolve(state.nodesHome, "../../marketplace/blocks")]
|
|
48
48
|
.find((d) => existsSync(d)) ?? resolve(state.nodesHome, "../../marketplace/blocks");
|
|
49
49
|
const cached = CACHE.get(home);
|
|
@@ -672,7 +672,7 @@ export function lintNode(dir, pkg) {
|
|
|
672
672
|
for (const [, b] of body.matchAll(/\b(?:prompt|blocks)\.([A-Za-z0-9_]+)/g))
|
|
673
673
|
if (!promptBlocks().has(b) && !seen.has(b)) {
|
|
674
674
|
seen.add(b);
|
|
675
|
-
report("error", F.api, `references prompt block "{{prompt.${b}}}", which does not exist in
|
|
675
|
+
report("error", F.api, `references prompt block "{{prompt.${b}}}", which does not exist in the marketplace's blocks/. It resolves to empty and the instruction silently disappears`);
|
|
676
676
|
}
|
|
677
677
|
}
|
|
678
678
|
}
|
|
@@ -106,7 +106,13 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
106
106
|
}
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
// The marketplace tier: `<root>/marketplace`, unless the caller names it (options.marketplace:
|
|
110
|
+
// the platform repo authors it outside any design root, base paths.ts `marketplace`).
|
|
111
|
+
const marketplace = options.marketplace ? resolve(options.marketplace) : join(ROOT, "marketplace");
|
|
112
|
+
// NO ORG TIER HERE: a named marketplace tier with no design root beside it (the platform
|
|
113
|
+
// repo, whose design/ was retired 2026-10-04) lints the tier alone. Only a root with neither
|
|
114
|
+
// is "nothing to lint".
|
|
115
|
+
if (!isDir(ROOT) && !(options.marketplace && isDir(marketplace))) {
|
|
110
116
|
report("error", ROOT, "no design/ folder here");
|
|
111
117
|
return { problems: [...problems], objectCount: 0, pipelineCount: 0, designRoot: ROOT };
|
|
112
118
|
}
|
|
@@ -117,13 +123,18 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
117
123
|
loadAllSchemas();
|
|
118
124
|
|
|
119
125
|
// ── the tiers: the marketplace, then every org folder ──
|
|
120
|
-
|
|
126
|
+
// An org's own repo is ONE tier with a DECLARED name (`options.org`, UNO-88): its folder may
|
|
127
|
+
// be called anything, and the org name is also the dataset table's (`dataset_<org>_<name>`).
|
|
121
128
|
const tiers = [];
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
if (options.org?.dir) {
|
|
130
|
+
tiers.push({ org: options.org.name, dir: resolve(options.org.dir) });
|
|
131
|
+
} else {
|
|
132
|
+
if (isDir(marketplace)) tiers.push({ org: undefined, dir: marketplace });
|
|
133
|
+
for (const e of isDir(ROOT) ? readdirSync(ROOT) : []) {
|
|
134
|
+
if (e.startsWith(".") || e === "marketplace" || e === "_schema") continue;
|
|
135
|
+
const d = join(ROOT, e);
|
|
136
|
+
if (isDir(d)) tiers.push({ org: e, dir: d });
|
|
137
|
+
}
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
/**
|
|
@@ -233,7 +244,7 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
233
244
|
let objectCount = 0;
|
|
234
245
|
for (const [, f] of objectIndex) {
|
|
235
246
|
objectCount++;
|
|
236
|
-
const tier = { org: f.org, dir:
|
|
247
|
+
const tier = { org: f.org, dir: tiers.find((t) => t.org === f.org)?.dir ?? marketplace };
|
|
237
248
|
const at = (file) => rel(join(f.dir, file));
|
|
238
249
|
if (f.envelope === null) continue;
|
|
239
250
|
validateAgainst(SCHEMA_ID.object, f.envelope, rel(f.envFile), `object ${f.name}: the envelope`);
|
|
@@ -259,22 +270,38 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
259
270
|
|
|
260
271
|
// extends: resolves, no cycle, across the tiers the org can see.
|
|
261
272
|
let parentProps = null;
|
|
273
|
+
// A BASE THAT CANNOT BE SEEN is not a missing base. An org repo carries no marketplace on
|
|
274
|
+
// disk (UNO-88), so `extends: need` names an object installed in every universe and present
|
|
275
|
+
// in none of the repo's folders. The same rule as the nodes above: step back to a hint, read
|
|
276
|
+
// a description-only prop as the re-brief of an inherited field (the only thing it can be),
|
|
277
|
+
// and leave the row keys, which need the base's fields, to the universe that has them.
|
|
278
|
+
let baseUnseen = false;
|
|
262
279
|
const base = isRecord(f.envelope) ? f.envelope.extends : undefined;
|
|
263
280
|
if (typeof base === "string") {
|
|
264
281
|
const parent = findObject(base, f.org);
|
|
265
|
-
if (!parent
|
|
282
|
+
if (!parent && options.org?.dir && !marketplaceSeen) {
|
|
283
|
+
baseUnseen = true;
|
|
284
|
+
report("hint", rel(f.envFile), `object ${f.name}: extends "${base}", which is not on disk here (the marketplace is installed, not in the repo), so what it inherits was not checked`);
|
|
285
|
+
parentProps = Object.fromEntries(
|
|
286
|
+
Object.entries(isRecord(f.props) ? f.props : {}).filter(([, p]) => isRecord(p) && Object.keys(p).every((k) => k === "description")),
|
|
287
|
+
);
|
|
288
|
+
} else if (!parent) report("error", rel(f.envFile), `object ${f.name}: extends "${base}", which no tier holds`);
|
|
266
289
|
else {
|
|
267
290
|
parentProps = composedProps(parent, [f.name]);
|
|
268
291
|
if (parentProps === null) report("error", rel(f.envFile), `object ${f.name}: extends cycle through ${base}`);
|
|
269
292
|
}
|
|
270
293
|
}
|
|
271
294
|
say(propsFindings(f.name, f.props, parentProps, base, basename(f.propsFile)));
|
|
272
|
-
|
|
295
|
+
// With the base unseen, a function the object does not declare itself may be the base's
|
|
296
|
+
// (README: a prop may be written from an inherited function), so only its own are checked.
|
|
297
|
+
const fns = composedService(f);
|
|
298
|
+
const ownFn = (from) => typeof from === "string" && from.split(".")[0] in fns;
|
|
299
|
+
if (isRecord(f.props)) for (const [k, p] of Object.entries(f.props)) if (isRecord(p) && p.from != null && (!baseUnseen || ownFn(p.from))) say(fromFindings(f.name, k, p.from, fns, basename(f.propsFile)));
|
|
273
300
|
say(serviceFindings(f.name, f.service, visibleNodes(tier)));
|
|
274
301
|
say(makeFindings(f.name, f.make, f.service, visibleNodes(tier)));
|
|
275
302
|
if (!marketplaceSeen && isRecord(f.service)) report("hint", rel(f.servicePath), `object ${f.name}: the marketplace is not on disk here, so the nodes its calls name were not checked`);
|
|
276
303
|
const all = composedProps(f) ?? (isRecord(f.props) ? f.props : {});
|
|
277
|
-
say(rowKeyFindings(f.name, f.envelope, all, basename(f.envFile)));
|
|
304
|
+
if (!baseUnseen) say(rowKeyFindings(f.name, f.envelope, all, basename(f.envFile)));
|
|
278
305
|
say(objectTestFindings(f.name, f.test, ownFunctions(f)));
|
|
279
306
|
// An org may never take a marketplace name: a bare object_type must stay unambiguous.
|
|
280
307
|
if (f.org && marketplaceObjects.has(f.name)) report("error", rel(f.envFile), `object ${f.name}: an org's object may not take a marketplace object's name; the row's object_type would be ambiguous. Rename it, or extend ${f.name} under a name of its own`);
|
|
@@ -135,18 +135,28 @@ export function lintPrompts(designRoot, options = {}) {
|
|
|
135
135
|
const present = (f) => existsSync(f) || overlay[resolve(f)] !== undefined;
|
|
136
136
|
const rel = (f) => (relative(process.cwd(), f).startsWith("..") ? f : relative(process.cwd(), f));
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// The marketplace tier: `<root>/marketplace`, unless the caller names it (options.marketplace:
|
|
139
|
+
// the platform repo authors it outside any design root, base paths.ts `marketplace`).
|
|
140
|
+
const marketplace = options.marketplace ? resolve(options.marketplace) : join(ROOT, "marketplace");
|
|
141
|
+
// NO ORG TIER HERE: a named marketplace tier with no design root beside it (the platform
|
|
142
|
+
// repo, whose design/ was retired 2026-10-04) lints the tier alone. Only a root with neither
|
|
143
|
+
// is "nothing to lint".
|
|
144
|
+
if (!isDir(ROOT) && !(options.marketplace && isDir(marketplace))) {
|
|
139
145
|
report("error", ROOT, "no design/ folder here");
|
|
140
146
|
return { problems: [...problems], skillCount: 0, blockCount: 0, designRoot: ROOT };
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
// ── the tiers: the marketplace, every flat org folder, and the legacy orgs/ container ──
|
|
144
150
|
const tiers = [];
|
|
145
|
-
const marketplace = join(ROOT, "marketplace");
|
|
146
|
-
if (isDir(marketplace)) tiers.push({ org: undefined, dir: marketplace });
|
|
147
151
|
const orgDirs = (base) => readdirSync(base).filter((e) => !e.startsWith(".") && !e.startsWith("_") && e !== "marketplace" && e !== "orgs" && isDir(join(base, e)));
|
|
148
|
-
|
|
149
|
-
|
|
152
|
+
if (options.org?.dir) {
|
|
153
|
+
// An org's own repo: one tier, its name declared, never read off the folder (UNO-88).
|
|
154
|
+
tiers.push({ org: options.org.name, dir: resolve(options.org.dir) });
|
|
155
|
+
} else {
|
|
156
|
+
if (isDir(marketplace)) tiers.push({ org: undefined, dir: marketplace });
|
|
157
|
+
if (isDir(ROOT)) for (const e of orgDirs(ROOT)) tiers.push({ org: e, dir: join(ROOT, e) });
|
|
158
|
+
if (isDir(join(ROOT, "orgs"))) for (const e of orgDirs(join(ROOT, "orgs"))) tiers.push({ org: e, dir: join(ROOT, "orgs", e) });
|
|
159
|
+
}
|
|
150
160
|
|
|
151
161
|
let skillCount = 0;
|
|
152
162
|
let blockCount = 0;
|