@unoverse-platform/studio 0.1.105 → 0.1.107

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.
@@ -14,8 +14,9 @@
14
14
  * See docs/architecture/authoring/LOCAL_STUDIO.md.
15
15
  */
16
16
  import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync, readFileSync } from "node:fs";
17
+ import { execFileSync } from "node:child_process";
17
18
  import { designHome } from "./design-home.mjs";
18
- import { join, dirname, resolve } from "node:path";
19
+ import { join, dirname, resolve, basename } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
 
21
22
  /**
@@ -73,12 +74,25 @@ export function createProject(home, name) {
73
74
  const root = join(designHome(home), name);
74
75
  if (existsSync(root)) throw new Error(`${name} already exists in this workspace`);
75
76
 
77
+ return fillOrgFolder(root);
78
+ }
79
+
80
+ /**
81
+ * One org's folder, filled: the welcome component, the sample styles, identity, app and
82
+ * chat component. Shared by the monorepo shape (design/<org>/) and an org repo, whose root
83
+ * IS the org's folder (UNO-88), so the two can never drift.
84
+ */
85
+ function fillOrgFolder(root, { baselineOnly = false } = {}) {
86
+ // THE BASELINE (decided 2026-10-03): what an org needs to work, its starting tokens and its
87
+ // identity. Everything else here is a sample; an org repo gets none (samples live in their
88
+ // own repo), the monorepo shape still does.
76
89
  // EVERY AUTHORED KIND LIVES UNDER THE ORG (2026-08-24). skills/ and blocks/ moved in
77
90
  // beside components/apps/styles so a skill is org-scoped exactly like a component —
78
91
  // two orgs may each ship an `hr-education-coach` without colliding on one flat name
79
92
  // (items/collect.ts qualifies a skill's row `<org>/<name>`).
80
- for (const d of ["components/welcome", "apps", "styles", "skills", "blocks", "identity"]) mkdirSync(join(root, d), { recursive: true });
81
- writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
93
+ const dirs = baselineOnly ? ["styles", "identity"] : ["components/welcome", "apps", "styles", "skills", "blocks", "identity"];
94
+ for (const d of dirs) mkdirSync(join(root, d), { recursive: true });
95
+ if (!baselineOnly) writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
82
96
 
83
97
  // Worked example styles (sample-styles: brand ingredients + the override rules), so
84
98
  // styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
@@ -103,7 +117,7 @@ export function createProject(home, name) {
103
117
  // so apps/ is never an empty folder and the first app is a rename away. Same
104
118
  // rule as the styles: copied only into an empty folder, then the developer's.
105
119
  const sampleApp = join(dirname(fileURLToPath(import.meta.url)), "sample-app/chat");
106
- if (existsSync(sampleApp) && readdirSync(join(root, "apps")).length === 0) {
120
+ if (!baselineOnly && existsSync(sampleApp) && readdirSync(join(root, "apps")).length === 0) {
107
121
  cpSync(sampleApp, join(root, "apps/chat"), { recursive: true });
108
122
  // Its other half: the chat CX itself (welcome hero, timeline, suggestions,
109
123
  // composer) is a COMPONENT the app draws via `Ref`, so the surface lives once
@@ -111,6 +125,13 @@ export function createProject(home, name) {
111
125
  // it: an app whose chat-region resolves nothing renders an empty column.
112
126
  const sampleComponent = join(dirname(fileURLToPath(import.meta.url)), "sample-component/text-chat");
113
127
  if (existsSync(sampleComponent)) cpSync(sampleComponent, join(root, "components/text-chat"), { recursive: true });
128
+ // QUALIFIED WITH THIS ORG. Many orgs ship a `text-chat`, and a bare ref to a name two
129
+ // orgs hold is an error (homes.ts resolveComponentDirs), so the copy names its own.
130
+ // Only the monorepo shape takes samples, and there the folder's name is the org's.
131
+ const region = join(root, "apps/chat/components/chat-region.yaml");
132
+ if (existsSync(region)) {
133
+ writeFileSync(region, readFileSync(region, "utf8").replace(/^ref: text-chat$/m, `ref: ${basename(root)}/text-chat`));
134
+ }
114
135
  }
115
136
  return root;
116
137
  }
@@ -153,6 +174,7 @@ export function topUpSamples(home, project) {
153
174
  // An org-scoped sample has nowhere to go without a project. Called at launch (where
154
175
  // the project is not known) the org samples are simply skipped — they arrive with the
155
176
  // next `createFirstProject`, which is the only moment their destination exists.
177
+ // An org repo (UNO-88) holds the org at its root: `design/<org>/x` lands at `x`.
156
178
  const destOf = (s) => (typeof s.to === "function" ? s.to(project) : s.to);
157
179
  const placeable = (s) => typeof s.to !== "function" || !!project;
158
180
  let seen = [];
@@ -236,35 +258,37 @@ const NODE_SCHEMA_MAP = {
236
258
  [`${SCHEMAS.replace(/\/nodes$/, "/pipelines")}/interface.schema.json`]: ["design/*/pipelines/*/interface.yaml"],
237
259
  [`${SCHEMAS.replace(/\/nodes$/, "/pipelines")}/test.schema.json`]: ["design/*/pipelines/*/test.yaml"],
238
260
  };
239
- export function writeEditorSettings(home) {
261
+ export function writeEditorSettings(home, flat = false) {
240
262
  const dir = join(home, ".vscode");
241
263
  mkdirSync(dir, { recursive: true });
242
264
  const settings = join(dir, "settings.json");
243
- if (!existsSync(settings)) writeFileSync(settings, JSON.stringify({ "yaml.schemas": NODE_SCHEMA_MAP }, null, 2) + "\n");
265
+ // An org repo's folders sit at the root, so `design/*/nodes/...` becomes `nodes/...`.
266
+ const map = flat
267
+ ? Object.fromEntries(Object.entries(NODE_SCHEMA_MAP).map(([schema, globs]) => [schema, globs.map((g) => g.replace(/^design\/\*\//, ""))]))
268
+ : NODE_SCHEMA_MAP;
269
+ if (!existsSync(settings)) writeFileSync(settings, JSON.stringify({ "yaml.schemas": map }, null, 2) + "\n");
244
270
  const ext = join(dir, "extensions.json");
245
271
  if (!existsSync(ext)) writeFileSync(ext, JSON.stringify({ recommendations: ["redhat.vscode-yaml"] }, null, 2) + "\n");
246
272
  }
247
273
 
248
274
  /**
249
- * THE CI TEMPLATE (RELEASE_LIFECYCLE.md §3). The workspace is the client's Git repo, and
250
- * their pipeline deploys it: pull requests lint, main goes to UAT, a `v*` tag goes to
251
- * production. Each GitHub environment (`uat`, `production`) holds its own UNOVERSE_TOKEN,
252
- * so a UAT key can never write to production, and production's environment is where the
253
- * client adds a required approver. Addresses come from unoverse.yaml `environments:`.
254
- * Written once; a later run never replaces the team's own edits.
275
+ * THE CHECK, NOT A DEPLOY (RELEASE_LIFECYCLE.md §3a). Universes pull the org's repo
276
+ * themselves, so the repo needs no deploy pipeline and holds no keys. What it needs is the
277
+ * check every pull request runs: the same lint a universe runs before it applies a commit,
278
+ * so broken work cannot be merged. Written once; a later run never replaces the team's own.
255
279
  */
256
- const CI_WORKFLOW = `# Deploys this workspace. Pull requests lint; main goes to UAT; a v* tag goes to production.
257
- # Setup: https://docs.unoverse.ai/runbooks/publish-from-github
280
+ const CI_WORKFLOW = `# Checks every pull request with the same lint a universe runs before applying a commit.
281
+ # Universes pull this repo themselves: there is nothing to deploy from here.
282
+ # https://docs.unoverse.ai/architecture/environments
258
283
  name: unoverse
259
284
 
260
285
  on:
261
286
  pull_request:
262
287
  push:
263
288
  branches: [main]
264
- tags: ["v*"]
265
289
 
266
290
  jobs:
267
- lint:
291
+ check:
268
292
  runs-on: ubuntu-latest
269
293
  steps:
270
294
  - uses: actions/checkout@v4
@@ -272,34 +296,6 @@ jobs:
272
296
  with:
273
297
  node-version: 20
274
298
  - run: npx --yes unoverse@latest lint PROJECT
275
-
276
- uat:
277
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
278
- needs: lint
279
- runs-on: ubuntu-latest
280
- environment: uat
281
- steps:
282
- - uses: actions/checkout@v4
283
- - uses: actions/setup-node@v4
284
- with:
285
- node-version: 20
286
- - run: npx --yes unoverse@latest deploy studio PROJECT --env uat --yes
287
- env:
288
- UNOVERSE_TOKEN: \${{ secrets.UNOVERSE_TOKEN }}
289
-
290
- production:
291
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
292
- needs: lint
293
- runs-on: ubuntu-latest
294
- environment: production
295
- steps:
296
- - uses: actions/checkout@v4
297
- - uses: actions/setup-node@v4
298
- with:
299
- node-version: 20
300
- - run: npx --yes unoverse@latest deploy studio PROJECT --env production --yes
301
- env:
302
- UNOVERSE_TOKEN: \${{ secrets.UNOVERSE_TOKEN }}
303
299
  `;
304
300
 
305
301
  export function writeCiWorkflow(home, project) {
@@ -307,7 +303,70 @@ export function writeCiWorkflow(home, project) {
307
303
  const file = join(dir, "unoverse.yml");
308
304
  if (existsSync(file)) return;
309
305
  mkdirSync(dir, { recursive: true });
310
- writeFileSync(file, CI_WORKFLOW.replaceAll("PROJECT", project));
306
+ // An org repo lints the org it declares, so the check names no project.
307
+ writeFileSync(file, CI_WORKFLOW.replaceAll(" PROJECT", project ? ` ${project}` : ""));
308
+ }
309
+
310
+ const GITIGNORE = `node_modules/
311
+ .DS_Store
312
+ *.log
313
+ .env
314
+ .env.*
315
+ `;
316
+
317
+ /**
318
+ * ONE ORG, ONE REPO (RELEASE_LIFECYCLE.md §3). A workspace is never without Git: Git is its
319
+ * history and its rollback, and the repo is what a universe pulls. So the first project
320
+ * leaves the folder as a repo on `main` with everything in a first commit.
321
+ *
322
+ * Never an author it was not given: if the developer has no Git name and email set, the
323
+ * repo is left initialised with nothing staged, and they are told how to finish. A folder
324
+ * already inside a repo is left alone, and a machine with no Git is told, not changed.
325
+ * Returns what happened, for the caller to say.
326
+ */
327
+ export function startRepo(home, project) {
328
+ const git = (...args) => execFileSync("git", args, { cwd: home, stdio: "pipe" }).toString().trim();
329
+ try {
330
+ git("--version");
331
+ } catch {
332
+ return { state: "no-git", message: "Git is not installed, so this workspace has no history yet. Install Git, then run: git init -b main" };
333
+ }
334
+ try {
335
+ if (git("rev-parse", "--is-inside-work-tree") === "true") return { state: "inside-repo" };
336
+ } catch {
337
+ // not inside a repo: make one
338
+ }
339
+ if (!existsSync(join(home, ".gitignore"))) writeFileSync(join(home, ".gitignore"), GITIGNORE);
340
+ git("init", "-q", "-b", "main");
341
+ try {
342
+ git("add", "-A");
343
+ git("commit", "-q", "-m", `Start ${project}`);
344
+ return { state: "committed" };
345
+ } catch {
346
+ try { git("reset", "-q"); } catch { /* nothing staged */ }
347
+ return { state: "no-identity", message: `Set your Git name and email, then make the first commit: git add -A && git commit -m "Start ${project}"` };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * AN ORG REPO (UNO-88): `dir` becomes one org's own repo. Its folders at the root, its name
353
+ * declared in unoverse.yaml, the samples, the editor's schema globs, the pull-request check,
354
+ * and Git with a first commit. This is what `unoverse create` makes; Studio only opens it.
355
+ * Refuses a folder that already holds an org or a design tree. Returns the Git outcome.
356
+ */
357
+ export function createOrgRepo(dir, org) {
358
+ const bad = checkProjectName(org);
359
+ if (bad) throw new Error(bad);
360
+ mkdirSync(dir, { recursive: true });
361
+ if (existsSync(join(dir, "unoverse.yaml")) || existsSync(join(dir, "design")) || existsSync(join(dir, "components"))) {
362
+ throw new Error(`${dir} already holds a workspace`);
363
+ }
364
+ fillOrgFolder(dir, { baselineOnly: true });
365
+ writeFileSync(join(dir, "unoverse.yaml"), `# This repo is one org: its folders sit here, at the root.\n# Universes pull it from Git (RELEASE_LIFECYCLE.md).\norg: ${org}\n`);
366
+ writeEditorSettings(dir, true);
367
+ writeCiWorkflow(dir, null);
368
+ // No samples in an org repo: they live in their own repo, to read and copy (decided 2026-10-03).
369
+ return startRepo(dir, org);
311
370
  }
312
371
 
313
372
  export function createFirstProject(home, name) {
@@ -321,6 +380,9 @@ export function createFirstProject(home, name) {
321
380
 
322
381
  topUpSamples(home, name);
323
382
 
383
+ const repo = startRepo(home, name);
384
+ if (repo.message) console.warn(` ${repo.message}`);
385
+
324
386
  // SCAFFOLD WRITES NO `.claude/skills` (2026-08-21). It used to copy the Claude Code
325
387
  // authoring skills out of `vendor/skills` and into `<home>/.claude/skills`, which meant
326
388
  // every remote developer who launched Studio got the platform repo's own `.claude`
package/local/source.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * See docs/architecture/authoring/LOCAL_STUDIO.md §Authoring in Studio.
23
23
  */
24
- import { designHome } from "./design-home.mjs";
24
+ import { authoringRoot } from "./design-home.mjs";
25
25
  import { resolve, sep, relative } from "path";
26
26
  import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
27
27
 
@@ -179,7 +179,7 @@ export async function saveSource(
179
179
 
180
180
  writeFileSync(abs, text, "utf8");
181
181
 
182
- const designRoot = designHome(projectRoot);
182
+ const designRoot = authoringRoot(projectRoot);
183
183
  let problems: LintFinding[] = [];
184
184
  try {
185
185
  problems = (await lint(designRoot)).problems ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.105",
3
+ "version": "0.1.107",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -53,10 +53,10 @@ export function EmptyLibrary({
53
53
  <p className="mt-2 text-sm leading-relaxed text-gray-600">{shape}</p>
54
54
  <div className="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4">
55
55
  <div className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
56
- {scoped ? `Author one for ${scoped}` : "Author one"}
56
+ {scoped ? `Author one in ${scoped}'s repo` : "Author one in the org's repo"}
57
57
  </div>
58
58
  <div className="mt-1.5 font-mono text-[12.5px] text-gray-800">
59
- {scoped ? `design/${scoped}/${folder}/` : `design/<org>/${folder}/`}
59
+ {folder}/
60
60
  </div>
61
61
  {/* THE OTHER TIER, said once and quietly. An org asset is private to that
62
62
  org; an unqualified one is universal and every client can install it.
@@ -178,7 +178,7 @@ export function IdentityView({ org = "all" }: { org?: string } = {}) {
178
178
  name={current.name}
179
179
  category={current.manifest.category}
180
180
  version={current.manifest.version}
181
- id={`design/${current.org}/${current.path}`}
181
+ id={`${current.org}: ${current.path}`}
182
182
  description={current.manifest.description}
183
183
  whenToUse={current.manifest.whenToUse}
184
184
  right={<ToggleSwitch on={available.isEnabled(current.name)} onClick={() => available.toggle(current.name)} label={current.doc === "organisation" ? "Grounding" : "Available"} />}
@@ -925,7 +925,7 @@ export function TemplatesView({
925
925
  <nav className={NAV}>
926
926
  <div className={NAV_SECTION}>Apps</div>
927
927
  {templates.length === 0 && (
928
- <div className="p-2 text-xs text-gray-400">No apps yet — add one under design/&lt;project&gt;/apps/.</div>
928
+ <div className="p-2 text-xs text-gray-400">No apps yet. Add one under apps/ in the org's repo.</div>
929
929
  )}
930
930
  {templates.map((t) => (
931
931
  <div key={`${t.org ?? ""}/${t.name}`}>
package/src/App.tsx CHANGED
@@ -79,16 +79,21 @@ function Shell() {
79
79
  const [variant, setVariant] = usePersistentState<string>("studio:variant", "light");
80
80
 
81
81
  const [projects, setProjects] = useState<string[]>([]);
82
+ // Set when this folder is one org's repo: there is no second project to create here.
83
+ const [orgRepo, setOrgRepo] = useState<string | null>(null);
82
84
  const [theme, setTheme] = useState<ResolvedTheme | null>(null);
83
85
  const [foundation, setFoundation] = useState<ResolvedTheme | null>(null);
84
86
  const [error, setError] = useState<string | null>(null);
85
87
 
86
- // The projects in this developer's design/ folder. Flat, per RX_ORG_MODEL. Re-read after
87
- // creating one, so a new project appears without a reload.
88
+ // The orgs Studio can see: the one org of an org repo, or each project in the monorepo's
89
+ // design/. Re-read after creating one, so a new project appears without a reload.
88
90
  const loadProjects = () =>
89
91
  fetch("/dev/orgs")
90
92
  .then((r) => r.json())
91
- .then((d) => setProjects(d.orgs ?? []))
93
+ .then((d) => {
94
+ setProjects(d.orgs ?? []);
95
+ setOrgRepo(d.orgRepo ?? null);
96
+ })
92
97
  .catch((e) => setError(String(e)));
93
98
  useEffect(() => {
94
99
  loadProjects();
@@ -161,8 +166,8 @@ function Shell() {
161
166
  {o}
162
167
  </option>
163
168
  ))}
164
- <option disabled>──────────</option>
165
- <option value="__new">New project…</option>
169
+ {!orgRepo && <option disabled>──────────</option>}
170
+ {!orgRepo && <option value="__new">New project…</option>}
166
171
  </select>
167
172
  <button
168
173
  onClick={() => setVariant(variant === "light" ? "dark" : "light")}
@@ -9,7 +9,7 @@ import { join, dirname } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import { packagedDesignSystem } from "../dsPackage.js";
11
11
  import { defPath } from "../fsCache.js";
12
- import { DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir } from "../../paths.js";
12
+ import { DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir, getPaths } from "../../paths.js";
13
13
 
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
 
@@ -113,6 +113,9 @@ const RESERVED_DESIGN = new Set(["marketplace", "_schema"]);
113
113
  * at the rx root (`rx/<name>` — the target) or under the legacy `rx/orgs/<name>`. Flat
114
114
  * wins. A project folder carries at least one of styles/apps/components. */
115
115
  export function projectDir(name: string): string {
116
+ // An org repo (UNO-88): the repo root IS the org's folder.
117
+ const repo = getPaths().orgRepo;
118
+ if (repo && name === repo.org) return repo.dir;
116
119
  const installedProject = join(INSTALLED_DESIGN, name);
117
120
  if (databaseOnly()) return installedProject;
118
121
  const flat = join(DESIGN, name);
@@ -130,6 +133,9 @@ export function projectDir(name: string): string {
130
133
  * A flat candidate must look like a project (styles/apps/components) so a stray dir
131
134
  * isn't mistaken for one. */
132
135
  export function listOrgs(): string[] {
136
+ // An org repo holds exactly one org, declared; never its siblings or its folder names.
137
+ const repo = getPaths().orgRepo;
138
+ if (repo) return [repo.org];
133
139
  const isProject = (dir: string) =>
134
140
  existsSync(join(dir, "styles")) || existsSync(join(dir, "apps")) || existsSync(join(dir, "components")) ||
135
141
  existsSync(join(dir, "templates")) || existsSync(join(dir, "agents")) ||
@@ -17,7 +17,8 @@
17
17
  * pack writer and the Studio scaffold so a new org and a new project start identical.
18
18
  */
19
19
  import { existsSync, readFileSync } from "node:fs";
20
- import { basename, join } from "node:path";
20
+ import { basename, join, resolve } from "node:path";
21
+ import { getPaths } from "../../paths.js";
21
22
  import { parse, stringify } from "yaml";
22
23
  import { readObject, type ObjectDefinition } from "./objects.js";
23
24
 
@@ -78,7 +79,9 @@ export function instanceText(doc: OrgDocument, values: Record<string, unknown> =
78
79
  export function readOrgDocument(root: string, doc: OrgDocument): IdentityDocument | null {
79
80
  const file = join(identityDir(root), `${doc}.yaml`);
80
81
  if (!existsSync(file)) return null;
81
- const org = basename(root);
82
+ // The org this folder is: declared in an org repo (UNO-88), else the folder's name.
83
+ const repo = getPaths().orgRepo;
84
+ const org = repo && resolve(repo.dir) === resolve(root) ? repo.org : basename(root);
82
85
  const object = identityObject(doc, org);
83
86
  if (!object) throw new Error(`identity/${doc}.yaml: no object named ${doc} in any tier`);
84
87
  const instance = (parse(readFileSync(file, "utf8")) as Record<string, any>) ?? {};
@@ -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";
@@ -53,6 +53,48 @@ function designSystemDir(designRoot: string): string {
53
53
  return onDisk; // nothing found: the version will hash to an empty set, which is honest
54
54
  }
55
55
 
56
+ /**
57
+ * ONE ORG, ONE REPO (RELEASE_LIFECYCLE.md §3, UNO-88). An org's own repo IS the org: its
58
+ * folders (components/, apps/, skills/, nodes/ ...) sit at the root, and its name is
59
+ * DECLARED in `unoverse.yaml` (`org: <slug>`), never read off the folder, which may be called
60
+ * `org-acme` or anything else. `design/<org>/` remains only the platform monorepo's shape.
61
+ */
62
+ export interface OrgHome {
63
+ org: string;
64
+ dir: string;
65
+ }
66
+
67
+ const ORG_SLUG = /^[a-z][a-z0-9-]{0,63}$/;
68
+
69
+ /** The org a folder declares in its `unoverse.yaml`, or null when it is not an org repo. */
70
+ export function readOrgName(dir: string): string | null {
71
+ const file = join(dir, "unoverse.yaml");
72
+ if (!existsSync(file)) return null;
73
+ try {
74
+ const doc = parseYaml(readFileSync(file, "utf8"));
75
+ const org = typeof doc?.org === "string" ? doc.org.trim() : "";
76
+ return ORG_SLUG.test(org) ? org : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** This folder as an org repo, or null. */
83
+ export function orgRepo(dir: string): OrgHome | null {
84
+ const org = readOrgName(dir);
85
+ return org ? { org, dir } : null;
86
+ }
87
+
88
+ /** An org repo's design system: only ever the bundle installed inside that repo. */
89
+ function orgDesignSystemDir(dir: string): string {
90
+ return join(dir, "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions");
91
+ }
92
+
93
+ /** Collect an org repo into items: the same walk as a monorepo project, rooted at the repo. */
94
+ export function collectOrg(home: OrgHome): CollectedItem[] {
95
+ return collectFrom(home.dir, home.org, orgDesignSystemDir(home.dir));
96
+ }
97
+
56
98
  /** Every project a developer could publish, by name. */
57
99
  export function listProjects(designRoot: string): string[] {
58
100
  if (!existsSync(designRoot)) return [];
@@ -62,6 +104,41 @@ export function listProjects(designRoot: string): string[] {
62
104
  .sort();
63
105
  }
64
106
 
107
+ /**
108
+ * AN ORG'S IMAGES TRAVEL INSIDE ITS DEFINITIONS. On disk the loader turns `src: assets/x.png`
109
+ * into the file's data URI (definitions/load/assets.ts); a universe holds the definition and
110
+ * never the org's folder, so a deploy that sent the bare path shipped a broken image. The same
111
+ * lookup, done here before sending: the file's folder upward to its org (the one holding
112
+ * `styles/`), then the shared design system's. Same types, same 256 KB cap. Self-contained on
113
+ * purpose: this file is vendored into the published CLI with only its own imports.
114
+ */
115
+ const ASSET_TYPES: Record<string, string> = {
116
+ ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
117
+ };
118
+ function assetUri(fromDir: string, src: string): string | null {
119
+ const rel = src.slice("assets/".length);
120
+ const type = ASSET_TYPES[extname(rel).toLowerCase()];
121
+ if (!type) return null;
122
+ let file: string | null = null;
123
+ for (let d = fromDir; dirname(d) !== d; d = dirname(d)) {
124
+ if (existsSync(join(d, "assets", rel))) { file = join(d, "assets", rel); break; }
125
+ if (existsSync(join(d, "styles"))) {
126
+ const shared = join(dirname(d), "marketplace", "assets", rel);
127
+ if (existsSync(shared)) file = shared;
128
+ break;
129
+ }
130
+ }
131
+ return file && statSync(file).size <= 256 * 1024 ? `data:${type};base64,${readFileSync(file).toString("base64")}` : null;
132
+ }
133
+ const readDef = (p: string): string => {
134
+ const text = readFileSync(p, "utf8");
135
+ if (!/\.ya?ml$/.test(p)) return text;
136
+ return text.replace(/^(\s*(?:-\s+)?[\w-]+:\s*)(["']?)(assets\/[^\s"'#]+)\2([ \t]*(?:#.*)?)$/gm, (whole, key: string, _q: string, src: string, rest: string) => {
137
+ const uri = assetUri(dirname(p), src);
138
+ return uri ? `${key}"${uri}"${rest}` : whole;
139
+ });
140
+ };
141
+
65
142
  /** Every definition file under a folder, relative-pathed, as one object. */
66
143
  function filesUnder(dir: string): Record<string, string> {
67
144
  const out: Record<string, string> = {};
@@ -71,7 +148,7 @@ function filesUnder(dir: string): Record<string, string> {
71
148
  if (e.name.startsWith(".")) continue;
72
149
  const p = join(d, e.name);
73
150
  if (e.isDirectory()) walk(p);
74
- else if (isDef(e.name) || e.name.endsWith(".md")) out[relative(dir, p)] = readFileSync(p, "utf8");
151
+ else if (isDef(e.name) || e.name.endsWith(".md")) out[relative(dir, p)] = readDef(p);
75
152
  }
76
153
  };
77
154
  walk(dir);
@@ -89,11 +166,15 @@ function filesUnder(dir: string): Record<string, string> {
89
166
  export function collectProject(designRoot: string, project: string): CollectedItem[] {
90
167
  const root = join(designRoot, project);
91
168
  if (!existsSync(root)) throw new Error(`No project "${project}" in ${designRoot}`);
169
+ return collectFrom(root, project, designSystemDir(designRoot));
170
+ }
92
171
 
172
+ /** The walk itself: one org's folder, its name, and the design system it was built on. */
173
+ function collectFrom(root: string, project: string, designSystem: string): CollectedItem[] {
93
174
  // WHAT THIS WAS BUILT ON, stamped on every item. Computed once per collection: it is the
94
175
  // same design system for the whole project, and hashing it per item would be wasteful and
95
176
  // could disagree with itself mid-run if a file changed.
96
- const base_version = designSystemVersion(designSystemDir(designRoot));
177
+ const base_version = designSystemVersion(designSystem);
97
178
 
98
179
  const items: CollectedItem[] = [];
99
180
  // A COMPONENT (and, since 2026-08-24, a SKILL) row's name is the qualified ref
@@ -155,7 +236,7 @@ export function collectProject(designRoot: string, project: string): CollectedIt
155
236
  } else if (isDef(e.name)) {
156
237
  // The name is the definition's, without the extension: `card.yaml` is `card`,
157
238
  // addressed the same way whether it later grows into a folder.
158
- add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]: readFileSync(join(home, e.name), "utf8") } });
239
+ add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]: readDef(join(home, e.name)) } });
159
240
  }
160
241
  }
161
242
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { existsSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import { collectProject, type CollectedItem } from "./collect.js";
17
+ import { collectProject, type CollectedItem, type OrgHome } from "./collect.js";
18
18
 
19
19
  /** One lint finding. `error` blocks a publish; `warn` and `hint` inform. */
20
20
  export interface Finding {
@@ -98,6 +98,26 @@ export async function lintForPublish(designRoot: string, project: string): Promi
98
98
  return { problems: mine, errors: mine.filter((p: Finding) => p.level === "error") };
99
99
  }
100
100
 
101
+ /**
102
+ * Lint an org repo (UNO-88): the same four linters, told which folder is the org and what it
103
+ * is called. The repo holds one org, so every finding is that org's; nothing to filter.
104
+ */
105
+ export async function lintOrg(home: OrgHome): Promise<{ problems: Finding[]; errors: Finding[] }> {
106
+ const org = { name: home.org, dir: home.dir };
107
+ const { lintDefinitions } = await import("../lint/design/index.mjs");
108
+ const result = lintDefinitions(home.dir, { org });
109
+ const nodesHome = join(home.dir, "nodes");
110
+ if (existsSync(nodesHome)) {
111
+ const { lintNodes } = await import("../lint/nodes/index.mjs");
112
+ result.problems.push(...lintNodes(nodesHome).problems);
113
+ }
114
+ const { lintObjects } = await import("../lint/objects/index.mjs");
115
+ result.problems.push(...lintObjects(home.dir, { org }).problems);
116
+ const { lintPrompts } = await import("../lint/prompts/index.mjs");
117
+ result.problems.push(...lintPrompts(home.dir, { org }).problems);
118
+ return { problems: result.problems, errors: result.problems.filter((p: Finding) => p.level === "error") };
119
+ }
120
+
101
121
  /**
102
122
  * What publishing WOULD do, without doing it.
103
123
  *
@@ -224,7 +244,8 @@ export async function sendPublish(
224
244
  const res = await fetchImpl(`${universe}/publish`, {
225
245
  method: "POST",
226
246
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
227
- body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name }),
247
+ // The org is part of an item's identity (UNO-89): remove THIS org's row, never another's.
248
+ body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name, org: (r as { org?: string }).org }),
228
249
  });
229
250
  const body = (await res.json().catch(() => ({}))) as { error?: string };
230
251
  if (res.ok) sent.push({ ...(r as CollectedItem), mode: "removed" });
@@ -24,7 +24,14 @@ export interface DefinitionsResult {
24
24
  }
25
25
 
26
26
  /** Lint every definition under `rxHome`. Prints nothing, throws nothing for a lint failure. */
27
- export function lintDefinitions(rxHome?: string): DefinitionsResult;
27
+ export interface LintDefinitionsOptions {
28
+ /** Absolute path -> text that stands in for what is on disk (an unsaved editor). */
29
+ overlay?: Record<string, string>;
30
+ /** An org's own repo (UNO-88): its folder and its DECLARED name. The org list is then this one. */
31
+ org?: { name: string; dir: string };
32
+ }
33
+
34
+ export function lintDefinitions(rxHome?: string, options?: LintDefinitionsOptions): DefinitionsResult;
28
35
 
29
36
  /** True when anything would fail a build. */
30
37
  export function hasErrors(result: DefinitionsResult): boolean;