@unoverse-platform/studio 0.1.90 → 0.1.91

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 (130) hide show
  1. package/local/plugin.ts +20 -12
  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 +140 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +111 -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 +172 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +130 -0
  13. package/vendor/base/src/auth/auth.ts +278 -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 +128 -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 +643 -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 +260 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +199 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +198 -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.mjs +64 -0
  53. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  54. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  55. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  56. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  57. package/vendor/base/src/logger.ts +33 -0
  58. package/vendor/base/src/manifests/compose.ts +539 -0
  59. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  60. package/vendor/base/src/manifests/executor/context.ts +107 -0
  61. package/vendor/base/src/manifests/executor/index.ts +47 -0
  62. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  63. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  64. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  65. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  66. package/vendor/base/src/manifests/executor/send.ts +65 -0
  67. package/vendor/base/src/manifests/executor/session.ts +46 -0
  68. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  69. package/vendor/base/src/manifests/index.ts +280 -0
  70. package/vendor/base/src/manifests/integrity.ts +93 -0
  71. package/vendor/base/src/manifests/rows.ts +33 -0
  72. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  73. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  74. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  75. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  76. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  77. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  78. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  79. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  80. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  81. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  82. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  83. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  86. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  87. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  88. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  89. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  90. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  91. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  92. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  93. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  94. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  95. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  96. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  97. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  98. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  99. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  100. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  101. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  102. package/vendor/base/src/manifests/source.ts +187 -0
  103. package/vendor/base/src/manifests/watch.ts +67 -0
  104. package/vendor/base/src/markdown/document.ts +141 -0
  105. package/vendor/base/src/paths.ts +180 -0
  106. package/vendor/base/src/platform/audioManager.ts +100 -0
  107. package/vendor/base/src/platform/clientTransport.ts +65 -0
  108. package/vendor/base/src/platform/executionContext.ts +154 -0
  109. package/vendor/base/src/platform/index.ts +10 -0
  110. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  111. package/vendor/base/src/platform/redis.ts +54 -0
  112. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  113. package/vendor/base/src/pluginBase.ts +248 -0
  114. package/vendor/base/src/plugins/README.md +57 -0
  115. package/vendor/base/src/plugins/discovery.ts +87 -0
  116. package/vendor/base/src/plugins/index.ts +16 -0
  117. package/vendor/base/src/plugins/loader.ts +179 -0
  118. package/vendor/base/src/plugins/redis.ts +36 -0
  119. package/vendor/base/src/plugins/state.ts +53 -0
  120. package/vendor/base/src/registry.ts +95 -0
  121. package/vendor/base/src/security/permissions.ts +93 -0
  122. package/vendor/base/src/security/rules.ts +73 -0
  123. package/vendor/base/src/template/SafeExpression.ts +556 -0
  124. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  125. package/vendor/base/src/template/index.ts +22 -0
  126. package/vendor/base/src/types.ts +50 -0
  127. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  128. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  129. package/vendor/base/src/workflow/routing.ts +122 -0
  130. package/vendor/sdk/lib/core/store.ts +26 -6
@@ -0,0 +1,199 @@
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
+
24
+ /** One publishable thing: what it is, what it contains, and what it was built on. */
25
+ export interface CollectedItem {
26
+ kind: string;
27
+ name: string;
28
+ definition: { files: Record<string, string> };
29
+ fingerprint: string;
30
+ org: string;
31
+ base_version: string;
32
+ }
33
+ import { designSystemVersion } from "./baseVersion.js";
34
+
35
+ /** Folders under rx/ that are never a developer's own project. */
36
+ const NOT_A_PROJECT = new Set(["marketplace", "_schema", "orgs"]);
37
+
38
+ const isDef = (f: string) => f.endsWith(".yaml") || f.endsWith(".json");
39
+
40
+ /**
41
+ * Where the design system is, mirroring definitions.ts marketplaceDir(). In this monorepo
42
+ * it is source on disk; in a developer's project it is the installed bundle, because
43
+ * sync-starter.sh deliberately ships neither the source nor a way to publish it.
44
+ */
45
+ function designSystemDir(designRoot: string): string {
46
+ const onDisk = join(designRoot, "marketplace");
47
+ if (existsSync(onDisk)) return onDisk;
48
+ const nodesHome = join(designRoot, "..", "nodes");
49
+ for (const c of [
50
+ join(nodesHome, "marketplace", "definitions"),
51
+ join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions"),
52
+ ])
53
+ if (existsSync(c)) return c;
54
+ return onDisk; // nothing found: the version will hash to an empty set, which is honest
55
+ }
56
+
57
+ /** Every project a developer could publish, by name. */
58
+ export function listProjects(designRoot: string): string[] {
59
+ if (!existsSync(designRoot)) return [];
60
+ return readdirSync(designRoot)
61
+ .filter((e) => !e.startsWith(".") && !NOT_A_PROJECT.has(e))
62
+ .filter((e) => statSync(join(designRoot, e)).isDirectory())
63
+ .sort();
64
+ }
65
+
66
+ /** Every definition file under a folder, relative-pathed, as one object. */
67
+ function filesUnder(dir: string): Record<string, string> {
68
+ const out: Record<string, string> = {};
69
+ if (!existsSync(dir)) return out;
70
+ const walk = (d: string): void => {
71
+ for (const e of readdirSync(d, { withFileTypes: true })) {
72
+ if (e.name.startsWith(".")) continue;
73
+ const p = join(d, e.name);
74
+ if (e.isDirectory()) walk(p);
75
+ else if (isDef(e.name) || e.name.endsWith(".md")) out[relative(dir, p)] = readFileSync(p, "utf8");
76
+ }
77
+ };
78
+ walk(dir);
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Collect one project into items.
84
+ *
85
+ * A component is a FOLDER (its envelope, layouts, states, sub-partials), so the item's
86
+ * definition is every file in it keyed by relative path. That mirrors how a node row is
87
+ * already stored (`{ files: {...} }`, manifests/source.ts rowsSource), so both kinds read
88
+ * back the same way.
89
+ */
90
+ export function collectProject(designRoot: string, project: string): CollectedItem[] {
91
+ const root = join(designRoot, project);
92
+ if (!existsSync(root)) throw new Error(`No project "${project}" in ${designRoot}`);
93
+
94
+ // WHAT THIS WAS BUILT ON, stamped on every item. Computed once per collection: it is the
95
+ // same design system for the whole project, and hashing it per item would be wasteful and
96
+ // could disagree with itself mid-run if a file changed.
97
+ const base_version = designSystemVersion(designSystemDir(designRoot));
98
+
99
+ const items: CollectedItem[] = [];
100
+ // A COMPONENT (and, since 2026-08-24, a SKILL) row's name is the qualified ref
101
+ // (`<org>/<name>`): the items table's identity is (kind, name) with no org in the
102
+ // key, and both names are unique only WITHIN an org — two orgs may ship
103
+ // `course-card` or `hr-education-coach`, so the org must be in the name for the
104
+ // rows to coexist (the same trick a style row already uses: its name IS the org).
105
+ // A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
106
+ // Apps keep their bare ids: those are org-qualified by convention
107
+ // (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
108
+ const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
109
+ const add = (kind: string, name: string, definition: { files: Record<string, string> }) =>
110
+ items.push({
111
+ kind,
112
+ name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
113
+ definition,
114
+ fingerprint: fingerprintOf(definition),
115
+ org: project,
116
+ base_version,
117
+ });
118
+
119
+ // `apps` publishes as kind `app`, NOT `recipe`. A recipe is a workflow graph
120
+ // that is copied onto a canvas and never installed; an app is installed and tracked, so
121
+ // taking a newer version is the point rather than a hazard (migrations 017 + 029).
122
+ //
123
+ // A DEFINITION IS EITHER A FOLDER OR A SINGLE FILE, and both are normal. A component
124
+ // that grew layouts and states is a folder; an atom is one file, and templates are a
125
+ // mix. Collecting only folders silently dropped all 24 atoms and two templates, which
126
+ // is the worst kind of bug in a publisher: it succeeds and ships less than you authored.
127
+ //
128
+ // Both become the same shape, a map of relative path to contents, so nothing downstream
129
+ // has to care which it was. A single file is simply a map with one entry.
130
+ // APP MEANS APP, TEMPLATE MEANS TEMPLATE (owner ruling 2026-08-28, migration
131
+ // 029): apps publish as kind `app` and the Template Model kind owns the bare
132
+ // word. Forgetting a design dir here ships deploys that silently drop every
133
+ // artifact of that kind.
134
+ for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"]] as const) {
135
+ const home = join(root, dir);
136
+ if (!existsSync(home)) continue;
137
+ for (const e of readdirSync(home, { withFileTypes: true })) {
138
+ if (e.name.startsWith(".") || e.name === "README.md") continue;
139
+ if (e.isDirectory()) {
140
+ const files = filesUnder(join(home, e.name));
141
+ if (Object.keys(files).length) add(kind, e.name, { files });
142
+ } else if (isDef(e.name)) {
143
+ // The name is the definition's, without the extension: `card.yaml` is `card`,
144
+ // addressed the same way whether it later grows into a folder.
145
+ add(kind, e.name.replace(/\.(ya?ml|json)$/, ""), { files: { [e.name]: readFileSync(join(home, e.name), "utf8") } });
146
+ }
147
+ }
148
+ }
149
+
150
+ // styles/ is ONE item: the org's token overrides, which only mean anything together.
151
+ const styles = filesUnder(join(root, "styles"));
152
+ if (Object.keys(styles).length) add("style", project, { files: styles });
153
+
154
+ // skills/ — ORG-QUALIFIED, same trick as a component: `${project}/${name}` is the
155
+ // identity, so two orgs may each ship "hr-education-coach" without one silently
156
+ // taking the other's row (items/loaders.ts loadParsedSkill resolves the qualified
157
+ // form back to this same folder). One item per skill folder, same shape as a
158
+ // component: SKILL.md plus any references/ it carries.
159
+ const skillsHome = join(root, "skills");
160
+ if (existsSync(skillsHome)) {
161
+ for (const e of readdirSync(skillsHome, { withFileTypes: true })) {
162
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
163
+ if (!existsSync(join(skillsHome, e.name, "SKILL.md"))) continue; // not a skill folder
164
+ const files = filesUnder(join(skillsHome, e.name));
165
+ if (Object.keys(files).length) add("skill", e.name, { files });
166
+ }
167
+ }
168
+
169
+ // blocks/ — NAME STAYS BARE, unlike a skill. A block is referenced inline in authored
170
+ // component text as `{{prompt.name}}` (packages'/engine's template resolver), a single
171
+ // FLAT name across the whole platform with no room for an org segment. Org-qualifying
172
+ // it would break every existing `{{prompt.X}}` reference, so only where the block
173
+ // lives moves; what it is called does not. Two orgs shipping the same block name still
174
+ // collide at the row (last publish wins) — an accepted limit, not solved here.
175
+ const blocksHome = join(root, "blocks");
176
+ if (existsSync(blocksHome)) {
177
+ const walk = (d: string): void => {
178
+ for (const e of readdirSync(d, { withFileTypes: true })) {
179
+ if (e.name.startsWith(".")) continue;
180
+ const p = join(d, e.name);
181
+ if (e.isDirectory()) walk(p);
182
+ else if (e.name.endsWith(".md")) {
183
+ const definition = { files: { [relative(blocksHome, p)]: readFileSync(p, "utf8") } };
184
+ items.push({
185
+ kind: "prompt-block",
186
+ name: basename(e.name, ".md"),
187
+ definition,
188
+ fingerprint: fingerprintOf(definition),
189
+ org: project,
190
+ base_version,
191
+ });
192
+ }
193
+ }
194
+ };
195
+ walk(blocksHome);
196
+ }
197
+
198
+ return items;
199
+ }
@@ -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,198 @@
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 { collectProject, type CollectedItem } from "./collect.js";
16
+
17
+ /** One lint finding. `error` blocks a publish; `warn` and `hint` inform. */
18
+ export interface Finding {
19
+ level: string;
20
+ file: string;
21
+ line?: number;
22
+ msg: string;
23
+ }
24
+
25
+ /** What publishing WOULD do, per item. `refused` carries the reason. */
26
+ export interface PublishPlan {
27
+ create: CollectedItem[];
28
+ update: CollectedItem[];
29
+ unchanged: CollectedItem[];
30
+ refused: Array<CollectedItem & { why: string }>;
31
+ /** Rows the universe holds for this project that the workspace no longer does. */
32
+ remove: Array<{ kind: string; name: string }>;
33
+ /** True when the universe could not answer the sync check (server predates it):
34
+ * removals were NOT reconciled and "up to date" must not be claimed. */
35
+ syncUnavailable?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Lint a project, returning findings. Errors mean nothing is sent.
40
+ *
41
+ * The design linter needs the whole design home, not one project: a developer's components are
42
+ * built ON TOP of the design system, so linting a project in isolation resolves every
43
+ * shared atom against nothing. It reports on all projects; we keep the findings for this
44
+ * one, plus anything about the tree itself.
45
+ */
46
+ export async function lintForPublish(designRoot: string, project: string): Promise<{ problems: Finding[]; errors: Finding[] }> {
47
+ const { lintDefinitions } = await import("../lint/design/index.mjs");
48
+ const result = lintDefinitions(designRoot);
49
+ /**
50
+ * SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
51
+ * check looked for "/design/" with a leading slash, which a relative path never
52
+ * has, so every other project's findings leaked through and a bpp comment blocked
53
+ * a lyceum deploy (observed live 2026-08-21). Boundary regexes hold for absolute
54
+ * and relative paths alike; findings outside the design tree (the tree itself,
55
+ * nodes, prompts) still always count.
56
+ */
57
+ const inDesign = (f: string) => /(^|\/)design\//.test(f);
58
+ const inProject = (f: string) => new RegExp(`(^|/)design/${project}(/|$)`).test(f);
59
+ const mine = result.problems.filter((p: Finding) => !p.file || !inDesign(p.file) || inProject(p.file));
60
+ return { problems: mine, errors: mine.filter((p: Finding) => p.level === "error") };
61
+ }
62
+
63
+ /**
64
+ * What publishing WOULD do, without doing it.
65
+ *
66
+ * Compares each collected item against the universe's current fingerprint for the same
67
+ * (kind, name). Four outcomes, and the fourth is the one worth surfacing early: a name
68
+ * owned by another publisher is refused, and finding that out before a publish beats
69
+ * finding it out halfway through one.
70
+ */
71
+ export async function planPublish(
72
+ items: CollectedItem[],
73
+ universe: string,
74
+ token: string,
75
+ /** The project being deployed; names the org whose universe rows the sync reconciles. */
76
+ project = "",
77
+ fetchImpl: typeof fetch = fetch,
78
+ ): Promise<PublishPlan> {
79
+ const plan: PublishPlan = { create: [], update: [], unchanged: [], refused: [], remove: [] };
80
+
81
+ /**
82
+ * DEPLOY IS A SYNC (ruled 2026-08-21): what the workspace no longer holds, the
83
+ * universe should not keep serving under this project's name. The universe lists its
84
+ * rows for the project; anything absent from the collected items joins the plan as a
85
+ * removal, shown and confirmed like everything else.
86
+ *
87
+ * ONE GUARD, against the catastrophic misread: a workspace that holds NO items for
88
+ * the project (a fresh clone missing its files, the wrong folder entirely) does not
89
+ * get to empty the universe. Removals are proposed only when the workspace holds at
90
+ * least one item — deleting the last one is done by name, deliberately.
91
+ *
92
+ * AND A SECOND GUARD, ON KINDS. Absence only means "deleted" for a kind this
93
+ * collector actually LOOKED for. A CLI that predates a kind collects none of it, sees
94
+ * rows it cannot account for, and proposes deleting every one — which is exactly what
95
+ * happened live on 2026-08-24: an older global `unoverse` removed all three freshly
96
+ * published skill and prompt-block rows, because its collector had never heard of
97
+ * them. Version skew between a machine's CLI and its universe is normal and must not
98
+ * be destructive, so a kind that contributed NOTHING to this collection is left
99
+ * entirely alone. Deleting the last item OF A KIND is therefore done by name, the
100
+ * same deliberate act as emptying a project.
101
+ */
102
+ if (project && items.length > 0) {
103
+ try {
104
+ const res = await fetchImpl(`${universe}/publish`, {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
107
+ body: JSON.stringify({ op: "list", project }),
108
+ });
109
+ if (res.ok) {
110
+ const doc = (await res.json().catch(() => null)) as { items?: Array<{ kind: string; name: string }> } | null;
111
+ const have = new Set(items.map((i) => `${i.kind}/${i.name}`));
112
+ const collectedKinds = new Set(items.map((i) => i.kind));
113
+ for (const r of doc?.items ?? []) {
114
+ if (!collectedKinds.has(r.kind)) continue; // this collector never looked for it
115
+ if (!have.has(`${r.kind}/${r.name}`)) plan.remove.push(r);
116
+ }
117
+ } else {
118
+ // An older universe answers this op with an error: no removals proposed, the
119
+ // additive behavior every deploy had before — but SAID, never silent. A deploy
120
+ // that could not check deletions and prints "up to date" is lying by omission
121
+ // (observed live 2026-08-21: an hour of retests against a server that could
122
+ // not answer, each one claiming there was nothing to do).
123
+ plan.syncUnavailable = true;
124
+ }
125
+ } catch {
126
+ plan.syncUnavailable = true; /* unreachable universe also fails later, on the real sends */
127
+ }
128
+ }
129
+
130
+ for (const item of items) {
131
+ // ASK THE GATE, not the item store. `/api/items` is deliberately shut (§9.10), so a
132
+ // publisher cannot read rows directly; and asking the publish route means the plan is
133
+ // produced by exactly the code that will act on it.
134
+ let verdict: any;
135
+ try {
136
+ const res = await fetchImpl(`${universe}/publish`, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
139
+ body: JSON.stringify({ ...item, dryRun: true }),
140
+ });
141
+ verdict = await res.json().catch(() => ({}));
142
+ if (res.status === 409) {
143
+ plan.refused.push({ ...item, why: verdict?.error ?? "refused" });
144
+ continue;
145
+ }
146
+ if (!res.ok) throw new Error(verdict?.error ?? `HTTP ${res.status}`);
147
+ } catch (e: any) {
148
+ throw new Error(`Could not reach ${universe}. ${e?.message ?? ""}`.trim());
149
+ }
150
+
151
+ if (verdict.unchanged) plan.unchanged.push(item);
152
+ else if (verdict.mode === "update") plan.update.push(item);
153
+ else plan.create.push(item);
154
+ }
155
+
156
+ return plan;
157
+ }
158
+
159
+ /**
160
+ * Send what the plan says to send. Unchanged items are skipped rather than written: a
161
+ * redeploy of something nobody edited must not read as a change to anyone reviewing.
162
+ */
163
+ export async function sendPublish(
164
+ plan: PublishPlan,
165
+ universe: string,
166
+ token: string,
167
+ fetchImpl: typeof fetch = fetch,
168
+ ): Promise<{ sent: Array<CollectedItem & { mode?: string }>; failed: Array<CollectedItem & { why: string }> }> {
169
+ const sent: Array<CollectedItem & { mode?: string }> = [];
170
+ const failed: Array<CollectedItem & { why: string }> = [];
171
+
172
+ for (const item of [...plan.create, ...plan.update]) {
173
+ const res = await fetchImpl(`${universe}/publish`, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
176
+ body: JSON.stringify(item),
177
+ });
178
+ const body = (await res.json().catch(() => ({}))) as { mode?: string; error?: string };
179
+ if (res.ok) sent.push({ ...item, mode: body.mode });
180
+ // The server's refusal message is written to be read; do not paraphrase it.
181
+ else failed.push({ ...item, why: body.error ?? `HTTP ${res.status}` });
182
+ }
183
+ // Removals LAST, after the writes landed: a sync that dies mid-run has then added
184
+ // before it has taken away, which is the recoverable order.
185
+ for (const r of plan.remove ?? []) {
186
+ const res = await fetchImpl(`${universe}/publish`, {
187
+ method: "POST",
188
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
189
+ body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name }),
190
+ });
191
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
192
+ if (res.ok) sent.push({ ...(r as CollectedItem), mode: "removed" });
193
+ else failed.push({ ...(r as CollectedItem), why: body.error ?? `HTTP ${res.status}` });
194
+ }
195
+ return { sent, failed };
196
+ }
197
+
198
+ 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
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Definition file formats: how a design definition is named, found and parsed.
3
+ *
4
+ * Mirrors the server's fsCache.ts, deliberately. A definition is `.yaml` or `.json`, and
5
+ * the linter must agree with the loader about which file IS the definition, or it lints a
6
+ * file nothing reads.
7
+ *
8
+ * The YAML parse is hand-rolled frontmatter-free minimal parsing rather than a dependency:
9
+ * this linter stays runnable with nothing installed.
10
+ */
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { createRequire } from "node:module";
14
+
15
+ // ── definition file formats (mirrors server/src/fsCache.ts) ──
16
+ // A LOCAL copy, not an import: this script ships standalone to the starter and cannot
17
+ // reach the server's TypeScript. `.yaml` first, matching the loader's resolution order.
18
+ // If these ever disagree with fsCache.ts, lint checks a different set of files than the
19
+ // server serves — which is exactly the silent gap this block exists to close.
20
+ export const DEF_EXTS = [".yaml", ".json"];
21
+ export const isDefFile = (f) => DEF_EXTS.some((e) => f.endsWith(e));
22
+ export const defName = (f) => f.replace(/\.(yaml|json)$/, "");
23
+ export const defPath = (dir, name) => {
24
+ for (const e of DEF_EXTS) {
25
+ const p = join(dir, name + e);
26
+ if (existsSync(p)) return p;
27
+ }
28
+ return undefined;
29
+ };
30
+
31
+ // YAML is required LAZILY, only when a .yaml definition is actually read, so a JSON-only
32
+ // project (and a fresh starter clone that has not installed devDependencies) never needs
33
+ // the package — the "no deps" property above holds for everyone not authoring YAML.
34
+ let _yamlParse = null;
35
+ export function parseDef(text, file) {
36
+ if (!file.endsWith(".yaml")) return JSON.parse(text);
37
+ if (!_yamlParse) {
38
+ try {
39
+ _yamlParse = createRequire(import.meta.url)("yaml").parse;
40
+ } catch {
41
+ throw new Error('YAML definitions need the "yaml" package. Run: npm i -D yaml');
42
+ }
43
+ }
44
+ return _yamlParse(text);
45
+ }
46
+ export const readDef = (file) => parseDef(readFileSync(file, "utf8"), file);