@unoverse-platform/studio 0.1.90 → 0.1.92

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.
Files changed (132) hide show
  1. package/local/plugin.ts +21 -65
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +155 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +659 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. package/local/mcp.ts +0 -265
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The design system version an asset was authored against.
3
+ *
4
+ * TWO PARTS, because neither alone is enough:
5
+ *
6
+ * 1.4.2+a3f1
7
+ * └───┘ └──┘
8
+ * │ └── HASH over every member's content. Derived, so it cannot be forgotten or
9
+ * │ bumped wrongly, and it moves the instant anything moves. This is what
10
+ * │ actually answers "did it change".
11
+ * └──────── PACKAGE VERSION, when there is one. Declared, so it can be stale, but it
12
+ * is ORDERED, which a hash never is. This is what answers "which way".
13
+ *
14
+ * A hash alone tells you two things differ and not which is newer. A version alone can be
15
+ * wrong, and is absent entirely in the monorepo where the design system is uncommitted
16
+ * source on disk rather than an installed package. So: hash always, version when known.
17
+ *
18
+ * WHAT IT IS FOR. Every published asset is built ON the design system: a component composes
19
+ * marketplace atoms, an app composes components, an org's tokens override a foundation
20
+ * scale. When the foundation moves, an item authored against the older one may reference
21
+ * something that no longer exists. The symptom is a hole where a component should be, seen
22
+ * by an end user and traceable to nothing. This is the record that makes it traceable.
23
+ *
24
+ * It cannot be backfilled: nothing else anywhere records what a definition was written
25
+ * against. See migration 018 and DECLARATIVE_NODES.md §9.12.
26
+ */
27
+ import { createHash } from "crypto";
28
+ import { existsSync, readFileSync, readdirSync } from "fs";
29
+ import { join } from "path";
30
+ import { fingerprintOf } from "./fingerprint.js";
31
+
32
+ /** Short, readable, and only ever compared for equality. */
33
+ function shortHash(s: string): string {
34
+ return createHash("sha256").update(s).digest("hex").slice(0, 8);
35
+ }
36
+
37
+ /** Every file under a directory, sorted, so the hash is order-independent. */
38
+ function filesUnder(dir: string, prefix = "", out: Record<string, string> = {}): Record<string, string> {
39
+ if (!existsSync(dir)) return out;
40
+ for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
41
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
42
+ if (e.isDirectory()) filesUnder(join(dir, e.name), rel, out);
43
+ else if (/\.(ya?ml|json)$/.test(e.name)) out[rel] = readFileSync(join(dir, e.name), "utf8");
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * The declared version, if the design system arrived as a package.
50
+ *
51
+ * Absent in the monorepo, where it is source on disk. That absence is honest rather than a
52
+ * gap: there is no version, because nobody cut one.
53
+ */
54
+ function packageVersion(designSystemDir: string): string | null {
55
+ // The bundle lives at <package>/definitions, so its package.json is one level up.
56
+ for (const candidate of [join(designSystemDir, "..", "package.json"), join(designSystemDir, "package.json")]) {
57
+ if (!existsSync(candidate)) continue;
58
+ try {
59
+ const v = JSON.parse(readFileSync(candidate, "utf8"))?.version;
60
+ if (typeof v === "string") return v;
61
+ } catch {
62
+ /* unreadable package.json: fall through to the hash alone */
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Compute the version of the design system at `designSystemDir` (the resolved marketplace
70
+ * directory: on-disk design/marketplace, or the installed bundle).
71
+ *
72
+ * Hashes components, atoms and styles together, because they only mean anything as a set:
73
+ * a universe holding half of them renders against a contract with holes.
74
+ */
75
+ export function designSystemVersion(designSystemDir: string): string {
76
+ const parts: string[] = [];
77
+
78
+ for (const kind of ["components", "atoms"] as const) {
79
+ const home = join(designSystemDir, kind);
80
+ if (!existsSync(home)) continue;
81
+ for (const e of readdirSync(home, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
82
+ if (e.name.startsWith(".") || e.name === "README.md") continue;
83
+ const files = e.isDirectory()
84
+ ? filesUnder(join(home, e.name))
85
+ : { [e.name]: readFileSync(join(home, e.name), "utf8") };
86
+ parts.push(`${kind}/${e.name}:${fingerprintOf(files)}`);
87
+ }
88
+ }
89
+
90
+ const styles = filesUnder(join(designSystemDir, "styles"));
91
+ if (Object.keys(styles).length) parts.push(`styles:${fingerprintOf(styles)}`);
92
+
93
+ const hash = shortHash(parts.join("|"));
94
+ const declared = packageVersion(designSystemDir);
95
+ return declared ? `${declared}+${hash}` : hash;
96
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Reading a developer's project off disk into publishable items.
3
+ *
4
+ * One item per thing a universe can hold: a component, an atom, a style set, a node, a
5
+ * skill, a prompt block. Each carries its content and a fingerprint, and nothing else is
6
+ * decided here: WHO published it and WHERE it came from are set by the server from the
7
+ * authenticated caller, never from this payload (publishRoute.ts).
8
+ *
9
+ * THE MARKETPLACE IS NEVER COLLECTED. `rx/marketplace` is the design system, installed
10
+ * from `@unoverse-platform/marketplace`, and a developer builds ON it rather than owning
11
+ * it. In a developer's project the folder does not even exist; in THIS monorepo it does,
12
+ * and publishing from here would push the design system source into a universe that is
13
+ * supposed to receive it as a package. Same exclusion the linter already applies when it
14
+ * computes org dirs, for the same reason.
15
+ *
16
+ * The unit is a PROJECT, not a file and not all of rx/. A project is what a developer
17
+ * works in and what they can reason about publishing; per-file selection is a chore nobody
18
+ * performs correctly, and a half-published project is worse than either extreme.
19
+ */
20
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
21
+ import { join, relative, basename } from "node:path";
22
+ import { fingerprintOf } from "./fingerprint.js";
23
+ import { parse as parseYaml } from "yaml";
24
+ import { readDiskPackages } from "../manifests/source.js";
25
+
26
+ /** One publishable thing: what it is, what it contains, and what it was built on. */
27
+ export interface CollectedItem {
28
+ kind: string;
29
+ name: string;
30
+ /** The files as authored. A node row also carries its package envelope and folder name. */
31
+ definition: { files: Record<string, string>; package?: Record<string, unknown>; dir?: string };
32
+ fingerprint: string;
33
+ org: string;
34
+ base_version: string;
35
+ }
36
+ import { designSystemVersion } from "./baseVersion.js";
37
+
38
+ /** Folders under rx/ that are never a developer's own project. */
39
+ const NOT_A_PROJECT = new Set(["marketplace", "_schema", "orgs"]);
40
+
41
+ const isDef = (f: string) => f.endsWith(".yaml") || f.endsWith(".json");
42
+
43
+ /**
44
+ * Where the design system is, mirroring definitions.ts marketplaceDir(). In this monorepo
45
+ * it is source on disk; in a developer's project it is the installed bundle, because
46
+ * sync-starter.sh deliberately ships neither the source nor a way to publish it.
47
+ */
48
+ function designSystemDir(designRoot: string): string {
49
+ const onDisk = join(designRoot, "marketplace");
50
+ if (existsSync(onDisk)) return onDisk;
51
+ const nodesHome = join(designRoot, "..", "nodes");
52
+ for (const c of [
53
+ join(nodesHome, "marketplace", "definitions"),
54
+ join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions"),
55
+ ])
56
+ if (existsSync(c)) return c;
57
+ return onDisk; // nothing found: the version will hash to an empty set, which is honest
58
+ }
59
+
60
+ /** Every project a developer could publish, by name. */
61
+ export function listProjects(designRoot: string): string[] {
62
+ if (!existsSync(designRoot)) return [];
63
+ return readdirSync(designRoot)
64
+ .filter((e) => !e.startsWith(".") && !NOT_A_PROJECT.has(e))
65
+ .filter((e) => statSync(join(designRoot, e)).isDirectory())
66
+ .sort();
67
+ }
68
+
69
+ /** Every definition file under a folder, relative-pathed, as one object. */
70
+ function filesUnder(dir: string): Record<string, string> {
71
+ const out: Record<string, string> = {};
72
+ if (!existsSync(dir)) return out;
73
+ const walk = (d: string): void => {
74
+ for (const e of readdirSync(d, { withFileTypes: true })) {
75
+ if (e.name.startsWith(".")) continue;
76
+ const p = join(d, e.name);
77
+ if (e.isDirectory()) walk(p);
78
+ else if (isDef(e.name) || e.name.endsWith(".md")) out[relative(dir, p)] = readFileSync(p, "utf8");
79
+ }
80
+ };
81
+ walk(dir);
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Collect one project into items.
87
+ *
88
+ * A component is a FOLDER (its envelope, layouts, states, sub-partials), so the item's
89
+ * definition is every file in it keyed by relative path. That mirrors how a node row is
90
+ * already stored (`{ files: {...} }`, manifests/source.ts rowsSource), so both kinds read
91
+ * back the same way.
92
+ */
93
+ export function collectProject(designRoot: string, project: string): CollectedItem[] {
94
+ const root = join(designRoot, project);
95
+ if (!existsSync(root)) throw new Error(`No project "${project}" in ${designRoot}`);
96
+
97
+ // WHAT THIS WAS BUILT ON, stamped on every item. Computed once per collection: it is the
98
+ // same design system for the whole project, and hashing it per item would be wasteful and
99
+ // could disagree with itself mid-run if a file changed.
100
+ const base_version = designSystemVersion(designSystemDir(designRoot));
101
+
102
+ const items: CollectedItem[] = [];
103
+ // A COMPONENT (and, since 2026-08-24, a SKILL) row's name is the qualified ref
104
+ // (`<org>/<name>`): the items table's identity is (kind, name) with no org in the
105
+ // key, and both names are unique only WITHIN an org — two orgs may ship
106
+ // `course-card` or `hr-education-coach`, so the org must be in the name for the
107
+ // rows to coexist (the same trick a style row already uses: its name IS the org).
108
+ // A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
109
+ // Apps keep their bare ids: those are org-qualified by convention
110
+ // (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
111
+ const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
112
+ const add = (kind: string, name: string, definition: CollectedItem["definition"]) =>
113
+ items.push({
114
+ kind,
115
+ name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
116
+ definition,
117
+ fingerprint: fingerprintOf(definition),
118
+ org: project,
119
+ base_version,
120
+ });
121
+
122
+ // `apps` publishes as kind `app`, NOT `recipe`. A recipe is a workflow graph
123
+ // that is copied onto a canvas and never installed; an app is installed and tracked, so
124
+ // taking a newer version is the point rather than a hazard (migrations 017 + 029).
125
+ //
126
+ // A DEFINITION IS EITHER A FOLDER OR A SINGLE FILE, and both are normal. A component
127
+ // that grew layouts and states is a folder; an atom is one file, and templates are a
128
+ // mix. Collecting only folders silently dropped all 24 atoms and two templates, which
129
+ // is the worst kind of bug in a publisher: it succeeds and ships less than you authored.
130
+ //
131
+ // Both become the same shape, a map of relative path to contents, so nothing downstream
132
+ // has to care which it was. A single file is simply a map with one entry.
133
+ // APP MEANS APP, TEMPLATE MEANS TEMPLATE (owner ruling 2026-08-28, migration
134
+ // 029): apps publish as kind `app` and the Template Model kind owns the bare
135
+ // word. Forgetting a design dir here ships deploys that silently drop every
136
+ // artifact of that kind.
137
+ for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"]] as const) {
138
+ const home = join(root, dir);
139
+ if (!existsSync(home)) continue;
140
+ for (const e of readdirSync(home, { withFileTypes: true })) {
141
+ if (e.name.startsWith(".") || e.name === "README.md") continue;
142
+ if (e.isDirectory()) {
143
+ const files = filesUnder(join(home, e.name));
144
+ if (Object.keys(files).length) add(kind, e.name, { files });
145
+ } else if (isDef(e.name)) {
146
+ // The name is the definition's, without the extension: `card.yaml` is `card`,
147
+ // addressed the same way whether it later grows into a folder.
148
+ add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]: readFileSync(join(home, e.name), "utf8") } });
149
+ }
150
+ }
151
+ }
152
+
153
+ // styles/ is ONE item: the org's token overrides, which only mean anything together.
154
+ const styles = filesUnder(join(root, "styles"));
155
+ if (Object.keys(styles).length) add("style", project, { files: styles });
156
+
157
+ // skills/ — ORG-QUALIFIED, same trick as a component: `${project}/${name}` is the
158
+ // identity, so two orgs may each ship "hr-education-coach" without one silently
159
+ // taking the other's row (items/loaders.ts loadParsedSkill resolves the qualified
160
+ // form back to this same folder). One item per skill folder, same shape as a
161
+ // component: SKILL.md plus any references/ it carries.
162
+ const skillsHome = join(root, "skills");
163
+ if (existsSync(skillsHome)) {
164
+ for (const e of readdirSync(skillsHome, { withFileTypes: true })) {
165
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
166
+ if (!existsSync(join(skillsHome, e.name, "SKILL.md"))) continue; // not a skill folder
167
+ const files = filesUnder(join(skillsHome, e.name));
168
+ if (Object.keys(files).length) add("skill", e.name, { files });
169
+ }
170
+ }
171
+
172
+ // blocks/ — NAME STAYS BARE, unlike a skill. A block is referenced inline in authored
173
+ // component text as `{{prompt.name}}` (packages'/engine's template resolver), a single
174
+ // FLAT name across the whole platform with no room for an org segment. Org-qualifying
175
+ // it would break every existing `{{prompt.X}}` reference, so only where the block
176
+ // lives moves; what it is called does not. Two orgs shipping the same block name still
177
+ // collide at the row (last publish wins) — an accepted limit, not solved here.
178
+ const blocksHome = join(root, "blocks");
179
+ if (existsSync(blocksHome)) {
180
+ const walk = (d: string): void => {
181
+ for (const e of readdirSync(d, { withFileTypes: true })) {
182
+ if (e.name.startsWith(".")) continue;
183
+ const p = join(d, e.name);
184
+ if (e.isDirectory()) walk(p);
185
+ else if (e.name.endsWith(".md")) {
186
+ const definition = { files: { [relative(blocksHome, p)]: readFileSync(p, "utf8") } };
187
+ items.push({
188
+ kind: "prompt-block",
189
+ name: basename(e.name, ".md"),
190
+ definition,
191
+ fingerprint: fingerprintOf(definition),
192
+ org: project,
193
+ base_version,
194
+ });
195
+ }
196
+ }
197
+ };
198
+ walk(blocksHome);
199
+ }
200
+
201
+ // nodes/ — THE WORKSPACE'S NODE PACKAGES, one row per node, shaped exactly as the
202
+ // marketplace stores a node (core/items/catalogue.ts) so the row source composes them
203
+ // on the same path as disk: the raw files, plus the package envelope their $refs and
204
+ // credential shapes resolve against. The name is the node's `type`, bare: a saved
205
+ // workflow stores that identity and nothing else.
206
+ //
207
+ // Until 2026-09-05 deploy never looked here, so `deploy studio` shipped a developer's
208
+ // components and skills and silently left every node behind.
209
+ //
210
+ // NOT FROM THE PLATFORM TREE. The monorepo's nodes ship inside the core image and are
211
+ // read from disk there; publishing them as rows under whichever org happened to be
212
+ // deployed would double every platform node. A design system on disk is the mark of
213
+ // the platform tree (the same test designSystemDir makes), and a developer's workspace
214
+ // never has one.
215
+ const nodesHome = join(designRoot, "..", "nodes");
216
+ if (existsSync(nodesHome) && !existsSync(join(designRoot, "marketplace"))) {
217
+ for (const pkg of collectNodePackages(nodesHome)) {
218
+ for (const raw of pkg.nodes) {
219
+ const type = nodeType(raw.files["node.yaml"]);
220
+ if (!type) continue; // lint has already refused it; nothing to name a row by
221
+ add("node", type, {
222
+ package: { name: pkg.name, packageFile: pkg.packageFile ?? null, credentials: pkg.credentials, shared: pkg.shared },
223
+ dir: raw.dir,
224
+ files: raw.files,
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ return items;
231
+ }
232
+
233
+ /** The workspace's node packages, read the way the platform reads its own. */
234
+ function collectNodePackages(nodesHome: string) {
235
+ return readDiskPackages(nodesHome).filter((pkg) => pkg.name !== "marketplace");
236
+ }
237
+
238
+ /** `type:` off a node.yaml: the one scalar the row is named by. Unparseable means none. */
239
+ function nodeType(nodeYaml: string | undefined): string {
240
+ try {
241
+ const doc = parseYaml(nodeYaml ?? "");
242
+ return typeof doc?.type === "string" ? doc.type : "";
243
+ } catch {
244
+ return "";
245
+ }
246
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The content fingerprint of an item.
3
+ *
4
+ * ONE implementation, because two would decide differently and nobody would notice. The
5
+ * publisher computes it before sending; the universe compares it against the row it holds
6
+ * to decide installed / update / unchanged. A second copy that canonicalises keys in a
7
+ * different order makes every publish look like a change, and every unchanged item look
8
+ * stale, which is the kind of wrong that erodes trust in the whole marketplace view.
9
+ *
10
+ * KEY ORDER IS NOT CONTENT. Objects are serialised with sorted keys, so re-saving a
11
+ * definition through a different YAML writer does not read as an edit. That property is
12
+ * load-bearing: converting rx/ from JSON to YAML rewrote 242 files and must have altered
13
+ * nothing a universe consumes (013_items.sql).
14
+ *
15
+ * Truncated to 16 hex characters. This identifies content, it does not defend against a
16
+ * forged one: integrity is the node content hash (manifests/integrity.ts), and authority
17
+ * is the publish gate.
18
+ */
19
+ import { createHash } from "crypto";
20
+
21
+ /** Deterministic JSON: sorted keys, arrays in order, primitives as JSON. */
22
+ function canonical(v: unknown): string {
23
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
24
+ if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
25
+ const o = v as Record<string, unknown>;
26
+ return `{${Object.keys(o)
27
+ .sort()
28
+ .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
29
+ .join(",")}}`;
30
+ }
31
+
32
+ export function fingerprintOf(definition: unknown): string {
33
+ return createHash("sha256").update(canonical(definition)).digest("hex").slice(0, 16);
34
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Publishing a project: collect, lint, compare, send.
3
+ *
4
+ * The shape is deliberate. NOTHING LEAVES THE MACHINE until the rules pass, because the
5
+ * cheapest place to catch a malformed definition is where the developer can fix it. The
6
+ * universe validates again on receipt (it must not trust a client), but a developer should
7
+ * never learn about a raw hex value from an HTTP error.
8
+ *
9
+ * Then a COMPARISON before a write. "What am I about to do" is answered by fingerprints:
10
+ * new, changed, unchanged, or refused because the name belongs to someone else. Publishing
11
+ * blind is how a project half-lands and nobody can tell by looking.
12
+ *
13
+ * See docs/architecture/DECLARATIVE_NODES.md §9.
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { collectProject, type CollectedItem } from "./collect.js";
18
+
19
+ /** One lint finding. `error` blocks a publish; `warn` and `hint` inform. */
20
+ export interface Finding {
21
+ level: string;
22
+ file: string;
23
+ line?: number;
24
+ msg: string;
25
+ }
26
+
27
+ /** What publishing WOULD do, per item. `refused` carries the reason. */
28
+ export interface PublishPlan {
29
+ create: CollectedItem[];
30
+ update: CollectedItem[];
31
+ unchanged: CollectedItem[];
32
+ refused: Array<CollectedItem & { why: string }>;
33
+ /** Rows the universe holds for this project that the workspace no longer does. */
34
+ remove: Array<{ kind: string; name: string }>;
35
+ /** True when the universe could not answer the sync check (server predates it):
36
+ * removals were NOT reconciled and "up to date" must not be claimed. */
37
+ syncUnavailable?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Lint a project, returning findings. Errors mean nothing is sent.
42
+ *
43
+ * The design linter needs the whole design home, not one project: a developer's components are
44
+ * built ON TOP of the design system, so linting a project in isolation resolves every
45
+ * shared atom against nothing. It reports on all projects; we keep the findings for this
46
+ * one, plus anything about the tree itself.
47
+ */
48
+ export async function lintForPublish(designRoot: string, project: string): Promise<{ problems: Finding[]; errors: Finding[] }> {
49
+ const { lintDefinitions } = await import("../lint/design/index.mjs");
50
+ const result = lintDefinitions(designRoot);
51
+ /**
52
+ * NODES TOO. A workspace keeps its node packages beside design/ (the same `nodes/` the
53
+ * Studio Nodes screen reads), and until 2026-09-05 this gate never looked at them: a
54
+ * node with an events row out of order or a host missing from allowedHosts deployed as
55
+ * cleanly as a correct one. The node linter is the same library the platform's own CI
56
+ * runs. A workspace with no nodes/ folder has nothing to lint and is not an error.
57
+ */
58
+ const nodesHome = join(designRoot, "..", "nodes");
59
+ if (existsSync(nodesHome)) {
60
+ const { lintNodes } = await import("../lint/nodes/index.mjs");
61
+ result.problems.push(...lintNodes(nodesHome).problems);
62
+ }
63
+ /**
64
+ * SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
65
+ * check looked for "/design/" with a leading slash, which a relative path never
66
+ * has, so every other project's findings leaked through and a bpp comment blocked
67
+ * a lyceum deploy (observed live 2026-08-21). Boundary regexes hold for absolute
68
+ * and relative paths alike; findings outside the design tree (the tree itself,
69
+ * nodes, prompts) still always count.
70
+ */
71
+ const inDesign = (f: string) => /(^|\/)design\//.test(f);
72
+ const inProject = (f: string) => new RegExp(`(^|/)design/${project}(/|$)`).test(f);
73
+ const mine = result.problems.filter((p: Finding) => !p.file || !inDesign(p.file) || inProject(p.file));
74
+ return { problems: mine, errors: mine.filter((p: Finding) => p.level === "error") };
75
+ }
76
+
77
+ /**
78
+ * What publishing WOULD do, without doing it.
79
+ *
80
+ * Compares each collected item against the universe's current fingerprint for the same
81
+ * (kind, name). Four outcomes, and the fourth is the one worth surfacing early: a name
82
+ * owned by another publisher is refused, and finding that out before a publish beats
83
+ * finding it out halfway through one.
84
+ */
85
+ export async function planPublish(
86
+ items: CollectedItem[],
87
+ universe: string,
88
+ token: string,
89
+ /** The project being deployed; names the org whose universe rows the sync reconciles. */
90
+ project = "",
91
+ fetchImpl: typeof fetch = fetch,
92
+ ): Promise<PublishPlan> {
93
+ const plan: PublishPlan = { create: [], update: [], unchanged: [], refused: [], remove: [] };
94
+
95
+ /**
96
+ * DEPLOY IS A SYNC (ruled 2026-08-21): what the workspace no longer holds, the
97
+ * universe should not keep serving under this project's name. The universe lists its
98
+ * rows for the project; anything absent from the collected items joins the plan as a
99
+ * removal, shown and confirmed like everything else.
100
+ *
101
+ * ONE GUARD, against the catastrophic misread: a workspace that holds NO items for
102
+ * the project (a fresh clone missing its files, the wrong folder entirely) does not
103
+ * get to empty the universe. Removals are proposed only when the workspace holds at
104
+ * least one item — deleting the last one is done by name, deliberately.
105
+ *
106
+ * AND A SECOND GUARD, ON KINDS. Absence only means "deleted" for a kind this
107
+ * collector actually LOOKED for. A CLI that predates a kind collects none of it, sees
108
+ * rows it cannot account for, and proposes deleting every one — which is exactly what
109
+ * happened live on 2026-08-24: an older global `unoverse` removed all three freshly
110
+ * published skill and prompt-block rows, because its collector had never heard of
111
+ * them. Version skew between a machine's CLI and its universe is normal and must not
112
+ * be destructive, so a kind that contributed NOTHING to this collection is left
113
+ * entirely alone. Deleting the last item OF A KIND is therefore done by name, the
114
+ * same deliberate act as emptying a project.
115
+ */
116
+ if (project && items.length > 0) {
117
+ try {
118
+ const res = await fetchImpl(`${universe}/publish`, {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
121
+ body: JSON.stringify({ op: "list", project }),
122
+ });
123
+ if (res.ok) {
124
+ const doc = (await res.json().catch(() => null)) as { items?: Array<{ kind: string; name: string }> } | null;
125
+ const have = new Set(items.map((i) => `${i.kind}/${i.name}`));
126
+ const collectedKinds = new Set(items.map((i) => i.kind));
127
+ for (const r of doc?.items ?? []) {
128
+ if (!collectedKinds.has(r.kind)) continue; // this collector never looked for it
129
+ if (!have.has(`${r.kind}/${r.name}`)) plan.remove.push(r);
130
+ }
131
+ } else {
132
+ // An older universe answers this op with an error: no removals proposed, the
133
+ // additive behavior every deploy had before — but SAID, never silent. A deploy
134
+ // that could not check deletions and prints "up to date" is lying by omission
135
+ // (observed live 2026-08-21: an hour of retests against a server that could
136
+ // not answer, each one claiming there was nothing to do).
137
+ plan.syncUnavailable = true;
138
+ }
139
+ } catch {
140
+ plan.syncUnavailable = true; /* unreachable universe also fails later, on the real sends */
141
+ }
142
+ }
143
+
144
+ for (const item of items) {
145
+ // ASK THE GATE, not the item store. `/api/items` is deliberately shut (§9.10), so a
146
+ // publisher cannot read rows directly; and asking the publish route means the plan is
147
+ // produced by exactly the code that will act on it.
148
+ let verdict: any;
149
+ try {
150
+ const res = await fetchImpl(`${universe}/publish`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
153
+ body: JSON.stringify({ ...item, dryRun: true }),
154
+ });
155
+ verdict = await res.json().catch(() => ({}));
156
+ if (res.status === 409) {
157
+ plan.refused.push({ ...item, why: verdict?.error ?? "refused" });
158
+ continue;
159
+ }
160
+ if (!res.ok) throw new Error(verdict?.error ?? `HTTP ${res.status}`);
161
+ } catch (e: any) {
162
+ throw new Error(`Could not reach ${universe}. ${e?.message ?? ""}`.trim());
163
+ }
164
+
165
+ if (verdict.unchanged) plan.unchanged.push(item);
166
+ else if (verdict.mode === "update") plan.update.push(item);
167
+ else plan.create.push(item);
168
+ }
169
+
170
+ return plan;
171
+ }
172
+
173
+ /**
174
+ * Send what the plan says to send. Unchanged items are skipped rather than written: a
175
+ * redeploy of something nobody edited must not read as a change to anyone reviewing.
176
+ */
177
+ export async function sendPublish(
178
+ plan: PublishPlan,
179
+ universe: string,
180
+ token: string,
181
+ fetchImpl: typeof fetch = fetch,
182
+ ): Promise<{ sent: Array<CollectedItem & { mode?: string }>; failed: Array<CollectedItem & { why: string }> }> {
183
+ const sent: Array<CollectedItem & { mode?: string }> = [];
184
+ const failed: Array<CollectedItem & { why: string }> = [];
185
+
186
+ for (const item of [...plan.create, ...plan.update]) {
187
+ const res = await fetchImpl(`${universe}/publish`, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
190
+ body: JSON.stringify(item),
191
+ });
192
+ const body = (await res.json().catch(() => ({}))) as { mode?: string; error?: string };
193
+ if (res.ok) sent.push({ ...item, mode: body.mode });
194
+ // The server's refusal message is written to be read; do not paraphrase it.
195
+ else failed.push({ ...item, why: body.error ?? `HTTP ${res.status}` });
196
+ }
197
+ // Removals LAST, after the writes landed: a sync that dies mid-run has then added
198
+ // before it has taken away, which is the recoverable order.
199
+ for (const r of plan.remove ?? []) {
200
+ const res = await fetchImpl(`${universe}/publish`, {
201
+ method: "POST",
202
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
203
+ body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name }),
204
+ });
205
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
206
+ if (res.ok) sent.push({ ...(r as CollectedItem), mode: "removed" });
207
+ else failed.push({ ...(r as CollectedItem), why: body.error ?? `HTTP ${res.status}` });
208
+ }
209
+ return { sent, failed };
210
+ }
211
+
212
+ export { collectProject };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The state one lint run accumulates, and the only thing every rule module shares.
3
+ *
4
+ * It is MUTABLE and RESET per run, deliberately. The linter began as a CLI that ran once
5
+ * and exited, so module-level arrays were harmless; Studio and the publish route lint over
6
+ * and over in one process, and findings from the previous run would silently accumulate
7
+ * into the next. `reset()` is what makes a second call return the same answer as the first.
8
+ */
9
+ import { relative } from "node:path";
10
+
11
+ /** Every finding, in discovery order. Sorted for presentation by the caller. */
12
+ export const problems = [];
13
+
14
+ /** Fragment path -> the set of nodes referencing it. Powers the shared/ pruning rules. */
15
+ export const refCounts = new Map();
16
+
17
+ /** Node -> the executor kind derived for it, shown as a summary rather than a finding. */
18
+ export const derivedKinds = new Map();
19
+
20
+ /** Credential name -> where it was declared, for cross-package collision rules. */
21
+ export const allCredentials = new Map();
22
+
23
+ /** Where this run is reading. Set by lintNodes(), read by rel() and the loaders. */
24
+ export const state = { nodesHome: "", schemaDir: "", schemas: {} };
25
+
26
+ export function reset(nodesHome, schemaDir) {
27
+ problems.length = 0;
28
+ seen.clear();
29
+ refCounts.clear();
30
+ derivedKinds.clear();
31
+ allCredentials.clear();
32
+ state.nodesHome = nodesHome;
33
+ state.schemaDir = schemaDir;
34
+ state.schemas = {};
35
+ }
36
+
37
+ /**
38
+ * One finding. `error` fails a build; `warn` and `hint` inform.
39
+ *
40
+ * DEDUPED. The design linter walks a definition and its expansions, so the same rule can fire
41
+ * on the same line twice; a caller seeing it twice would think there were two problems.
42
+ * `line` is optional: the design linter knows where in the file, the node linter reports per file.
43
+ */
44
+ const seen = new Set();
45
+ export const report = (level, file, msg, line) => {
46
+ const key = `${level}|${file}|${line ?? ""}|${msg}`;
47
+ if (seen.has(key)) return;
48
+ seen.add(key);
49
+ problems.push(line === undefined ? { level, file, msg } : { level, file, msg, line });
50
+ };
51
+
52
+ /** Shortest readable form: a walk out of the tree is worse than the absolute path. */
53
+ export const rel = (p) => {
54
+ const r = relative(process.cwd(), p);
55
+ return r.startsWith("..") ? p : r;
56
+ };