@unoverse-platform/studio 0.1.104 → 0.1.106
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 +30 -10
- package/bin/studio.mjs +21 -13
- package/local/authoring.ts +2 -2
- package/local/catalog.ts +10 -2
- package/local/design-home.d.mts +7 -0
- package/local/design-home.mjs +24 -1
- package/local/plugin.ts +34 -23
- package/local/sample-app/chat/components/chat-region.yaml +2 -3
- package/local/sample-app/chat/manifest.yaml +2 -2
- package/local/sample-component/text-chat/components/suggestions.yaml +1 -1
- package/local/sample-component/text-chat/layouts/chat-welcome.yaml +1 -1
- package/local/sample-component/text-chat/text-chat.yaml +3 -0
- package/local/scaffold.d.mts +11 -2
- package/local/scaffold.mjs +136 -7
- package/local/source.ts +2 -2
- package/package.json +1 -1
- package/screens/EmptyLibrary.tsx +2 -2
- package/screens/IdentityView.tsx +1 -1
- package/screens/TemplatesView.tsx +1 -1
- package/src/App.tsx +10 -5
- package/vendor/base/src/definitions/registry/homes.ts +7 -1
- package/vendor/base/src/definitions/registry/orgDocuments.ts +5 -2
- package/vendor/base/src/items/collect.ts +85 -4
- package/vendor/base/src/items/publish.ts +23 -2
- package/vendor/base/src/lint/design/index.d.mts +8 -1
- package/vendor/base/src/lint/design/index.mjs +39 -7
- package/vendor/base/src/lint/objects/index.d.mts +2 -0
- package/vendor/base/src/lint/objects/index.mjs +12 -6
- package/vendor/base/src/lint/prompts/index.d.mts +2 -0
- package/vendor/base/src/lint/prompts/index.mjs +8 -3
- package/vendor/base/src/paths.ts +5 -0
package/local/scaffold.mjs
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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,24 +258,131 @@ 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
|
-
|
|
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
|
|
|
274
|
+
/**
|
|
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.
|
|
279
|
+
*/
|
|
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
|
|
283
|
+
name: unoverse
|
|
284
|
+
|
|
285
|
+
on:
|
|
286
|
+
pull_request:
|
|
287
|
+
push:
|
|
288
|
+
branches: [main]
|
|
289
|
+
|
|
290
|
+
jobs:
|
|
291
|
+
check:
|
|
292
|
+
runs-on: ubuntu-latest
|
|
293
|
+
steps:
|
|
294
|
+
- uses: actions/checkout@v4
|
|
295
|
+
- uses: actions/setup-node@v4
|
|
296
|
+
with:
|
|
297
|
+
node-version: 20
|
|
298
|
+
- run: npx --yes unoverse@latest lint PROJECT
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
export function writeCiWorkflow(home, project) {
|
|
302
|
+
const dir = join(home, ".github", "workflows");
|
|
303
|
+
const file = join(dir, "unoverse.yml");
|
|
304
|
+
if (existsSync(file)) return;
|
|
305
|
+
mkdirSync(dir, { recursive: true });
|
|
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);
|
|
370
|
+
}
|
|
371
|
+
|
|
248
372
|
export function createFirstProject(home, name) {
|
|
249
373
|
const root = createProject(home, name);
|
|
250
374
|
// NOTHING beside the design tree. skills/ and blocks/ moved INTO the org on 2026-08-24
|
|
251
375
|
// and nodes/ followed on 2026-09-08 (the sample package lands at design/<org>/nodes/),
|
|
252
|
-
// so every authored kind sits in one place.
|
|
376
|
+
// so every authored kind sits in one place. The two exceptions are the editor's
|
|
377
|
+
// settings and the CI template, which belong to the repo, not to a project.
|
|
253
378
|
writeEditorSettings(home);
|
|
379
|
+
writeCiWorkflow(home, name);
|
|
254
380
|
|
|
255
381
|
topUpSamples(home, name);
|
|
256
382
|
|
|
383
|
+
const repo = startRepo(home, name);
|
|
384
|
+
if (repo.message) console.warn(` ${repo.message}`);
|
|
385
|
+
|
|
257
386
|
// SCAFFOLD WRITES NO `.claude/skills` (2026-08-21). It used to copy the Claude Code
|
|
258
387
|
// authoring skills out of `vendor/skills` and into `<home>/.claude/skills`, which meant
|
|
259
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 {
|
|
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 =
|
|
182
|
+
const designRoot = authoringRoot(projectRoot);
|
|
183
183
|
let problems: LintFinding[] = [];
|
|
184
184
|
try {
|
|
185
185
|
problems = (await lint(designRoot)).problems ?? [];
|
package/package.json
CHANGED
package/screens/EmptyLibrary.tsx
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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.
|
package/screens/IdentityView.tsx
CHANGED
|
@@ -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={
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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)] =
|
|
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(
|
|
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]:
|
|
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
|
-
|
|
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
|
|
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;
|