unoverse 0.1.174 → 0.1.176

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.
@@ -116,13 +116,22 @@
116
116
  # (scripts/lib/db-setup.sh). The old programmatic `require('./dist/db')` table
117
117
  # setup is retired: the engine no longer ships a dist, and .sql migrations under
118
118
  # apps/unoverse/engine/migrations are the single source of truth.
119
+ # --no-check-order, and it is NOT optional here: a database from before the
120
+ # 2026-07-28 squash has 002-018 recorded in pgmigrations with no matching files
121
+ # (they live inside 001_baseline now), so the order check refuses EVERY new
122
+ # migration with "Not run migration 019_… is preceding already run migration 002_…".
123
+ # db-setup.sh has carried this flag since the squash; this playbook runs the same
124
+ # command and did not, so `unoverse db-setup` worked while `unoverse deploy db`
125
+ # failed on the same database. Ordering still holds by filename and an applied name
126
+ # is never re-run, so the check buys nothing a squashed history can satisfy.
119
127
  - name: "[4/5] Apply database migrations (node-pg-migrate)"
120
128
  shell: |
121
129
  docker compose exec -T -e NODE_TLS_REJECT_UNAUTHORIZED=0 unoverse \
122
130
  npx node-pg-migrate up \
123
131
  --migrations-dir /app/apps/unoverse/engine/migrations \
124
132
  --migration-file-language sql \
125
- --no-lock
133
+ --no-lock \
134
+ --no-check-order
126
135
  args:
127
136
  chdir: /opt/gravity
128
137
  register: migrate_result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.174",
3
+ "version": "0.1.176",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -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 } from "node:path";
21
+ import { join, relative, basename } from "node:path";
22
22
  import { fingerprintOf } from "./fingerprint.js";
23
23
  import { designSystemVersion } from "./baseVersion.js";
24
24
  /** Folders under rx/ that are never a developer's own project. */
@@ -87,15 +87,18 @@ export function collectProject(designRoot, project) {
87
87
  // could disagree with itself mid-run if a file changed.
88
88
  const base_version = designSystemVersion(designSystemDir(designRoot));
89
89
  const items = [];
90
- // A COMPONENT row's name is the qualified ref (`<org>/<name>`): the items table's
91
- // identity is (kind, name) with no org in the key, and component names are unique
92
- // only WITHIN an org — two orgs may ship `course-card`, so the org must be in the
93
- // name for the rows to coexist (the same trick a style row already uses: its name
94
- // IS the org). Templates keep their bare ids: those are org-qualified by
95
- // convention (`<org>-chat-layout`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
90
+ // A COMPONENT (and, since 2026-08-24, a SKILL) row's name is the qualified ref
91
+ // (`<org>/<name>`): the items table's identity is (kind, name) with no org in the
92
+ // key, and both names are unique only WITHIN an org — two orgs may ship
93
+ // `course-card` or `hr-education-coach`, so the org must be in the name for the
94
+ // rows to coexist (the same trick a style row already uses: its name IS the org).
95
+ // A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
96
+ // Templates keep their bare ids: those are org-qualified by convention
97
+ // (`<org>-chat-layout`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
98
+ const QUALIFIED_KINDS = new Set(["component", "skill"]);
96
99
  const add = (kind, name, definition) => items.push({
97
100
  kind,
98
- name: kind === "component" ? `${project}/${name}` : name,
101
+ name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
99
102
  definition,
100
103
  fingerprint: fingerprintOf(definition),
101
104
  org: project,
@@ -135,6 +138,53 @@ export function collectProject(designRoot, project) {
135
138
  const styles = filesUnder(join(root, "styles"));
136
139
  if (Object.keys(styles).length)
137
140
  add("style", project, { files: styles });
141
+ // skills/ — ORG-QUALIFIED, same trick as a component: `${project}/${name}` is the
142
+ // identity, so two orgs may each ship "hr-education-coach" without one silently
143
+ // taking the other's row (items/loaders.ts loadParsedSkill resolves the qualified
144
+ // form back to this same folder). One item per skill folder, same shape as a
145
+ // component: SKILL.md plus any references/ it carries.
146
+ const skillsHome = join(root, "skills");
147
+ if (existsSync(skillsHome)) {
148
+ for (const e of readdirSync(skillsHome, { withFileTypes: true })) {
149
+ if (!e.isDirectory() || e.name.startsWith("."))
150
+ continue;
151
+ if (!existsSync(join(skillsHome, e.name, "SKILL.md")))
152
+ continue; // not a skill folder
153
+ const files = filesUnder(join(skillsHome, e.name));
154
+ if (Object.keys(files).length)
155
+ add("skill", e.name, { files });
156
+ }
157
+ }
158
+ // blocks/ — NAME STAYS BARE, unlike a skill. A block is referenced inline in authored
159
+ // component text as `{{prompt.name}}` (packages'/engine's template resolver), a single
160
+ // FLAT name across the whole platform with no room for an org segment. Org-qualifying
161
+ // it would break every existing `{{prompt.X}}` reference, so only where the block
162
+ // lives moves; what it is called does not. Two orgs shipping the same block name still
163
+ // collide at the row (last publish wins) — an accepted limit, not solved here.
164
+ const blocksHome = join(root, "blocks");
165
+ if (existsSync(blocksHome)) {
166
+ const walk = (d) => {
167
+ for (const e of readdirSync(d, { withFileTypes: true })) {
168
+ if (e.name.startsWith("."))
169
+ continue;
170
+ const p = join(d, e.name);
171
+ if (e.isDirectory())
172
+ walk(p);
173
+ else if (e.name.endsWith(".md")) {
174
+ const definition = { files: { [relative(blocksHome, p)]: readFileSync(p, "utf8") } };
175
+ items.push({
176
+ kind: "prompt-block",
177
+ name: basename(e.name, ".md"),
178
+ definition,
179
+ fingerprint: fingerprintOf(definition),
180
+ org: project,
181
+ base_version,
182
+ });
183
+ }
184
+ }
185
+ };
186
+ walk(blocksHome);
187
+ }
138
188
  return items;
139
189
  }
140
190
  //# sourceMappingURL=collect.js.map
@@ -59,6 +59,16 @@ project = "", fetchImpl = fetch) {
59
59
  * the project (a fresh clone missing its files, the wrong folder entirely) does not
60
60
  * get to empty the universe. Removals are proposed only when the workspace holds at
61
61
  * least one item — deleting the last one is done by name, deliberately.
62
+ *
63
+ * AND A SECOND GUARD, ON KINDS. Absence only means "deleted" for a kind this
64
+ * collector actually LOOKED for. A CLI that predates a kind collects none of it, sees
65
+ * rows it cannot account for, and proposes deleting every one — which is exactly what
66
+ * happened live on 2026-08-24: an older global `unoverse` removed all three freshly
67
+ * published skill and prompt-block rows, because its collector had never heard of
68
+ * them. Version skew between a machine's CLI and its universe is normal and must not
69
+ * be destructive, so a kind that contributed NOTHING to this collection is left
70
+ * entirely alone. Deleting the last item OF A KIND is therefore done by name, the
71
+ * same deliberate act as emptying a project.
62
72
  */
63
73
  if (project && items.length > 0) {
64
74
  try {
@@ -70,7 +80,10 @@ project = "", fetchImpl = fetch) {
70
80
  if (res.ok) {
71
81
  const doc = (await res.json().catch(() => null));
72
82
  const have = new Set(items.map((i) => `${i.kind}/${i.name}`));
83
+ const collectedKinds = new Set(items.map((i) => i.kind));
73
84
  for (const r of doc?.items ?? []) {
85
+ if (!collectedKinds.has(r.kind))
86
+ continue; // this collector never looked for it
74
87
  if (!have.has(`${r.kind}/${r.name}`))
75
88
  plan.remove.push(r);
76
89
  }