unoverse 0.1.216 → 0.1.217

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/bin/unoverse.mjs CHANGED
@@ -96,9 +96,8 @@ const HELP = [
96
96
  "",
97
97
  ` ${bold("After that")}`,
98
98
  row("studio", "Design components, nodes and agent skills"),
99
- row("lint", "Check your components, nodes and skills, the same way deploy does"),
100
- row("deploy", "Ship it"),
101
- ` ${dim("deploy studio your components, nodes and skills → your universe")}`,
99
+ row("lint", "Check your components, nodes and skills, the same way your universe does"),
100
+ row("deploy", "Put a universe on a cloud"),
102
101
  ` ${dim("deploy aws your universe → AWS")}`,
103
102
  ` ${dim("deploy digitalocean your universe → DigitalOcean")}`,
104
103
  row("login", "Sign in to a universe (deploy asks by itself when needed)"),
@@ -244,10 +243,10 @@ switch (cmd) {
244
243
  }
245
244
 
246
245
  case "lint": {
247
- // THE DEPLOY GATE, on its own. `deploy studio` runs exactly this before it sends, so a
248
- // developer can ask the question without the answer costing them a deploy.
246
+ // THE CHECK, on its own: the same lint the org repo's pull-request check runs, and the one
247
+ // a universe runs before it applies a commit.
249
248
  const { lint } = await import("../lib/lint.mjs");
250
- const { importBase } = await import("../lib/publish.mjs");
249
+ const { importBase } = await import("../lib/base.mjs");
251
250
  await lint(args, importBase);
252
251
  break;
253
252
  }
@@ -277,37 +276,29 @@ switch (cmd) {
277
276
  }
278
277
 
279
278
  case "deploy": {
280
- // ONE VERB FOR THE REMOTE DEVELOPER, the target says what ships. `deploy studio`
281
- // ships this design workspace's items to the signed-in universe (lint gates it);
282
- // any other target is the operator's cloud deploy, unchanged. `publish` is the
283
- // PLATFORM OWNER's word (the release) and stays out of this lane on purpose.
284
- if (args[0] === "studio") {
285
- const { findWorkspace } = await import("../lib/workspace.mjs");
286
- const ws = findWorkspace();
287
- if (ws?.kind !== "assets" && ws?.kind !== "org") {
288
- console.log(`\n deploy studio ships an org repo or design workspace, and there is none above ${process.cwd()}.\n Start one with: unoverse create\n`);
289
- process.exit(1);
290
- }
291
- const { publish } = await import("../lib/publish.mjs");
292
- await publish(args.slice(1));
293
- break;
279
+ // THE OPERATOR'S CLOUD DEPLOY: puts a universe on a cloud. A developer's own work never
280
+ // ships this way; it reaches a universe through the org's Git repo (RELEASE_LIFECYCLE.md §3).
281
+ // A word that is not a cloud is refused here, before the operator runs anything.
282
+ if (args[0] && !args[0].startsWith("-") && !["aws", "amazon", "digitalocean", "do"].includes(args[0])) {
283
+ console.log(`\n 'deploy ${args[0]}' is not a cloud. Use: unoverse deploy aws, or unoverse deploy digitalocean.\n Your own work ships by a push to your org's Git repo.\n`);
284
+ process.exit(1);
294
285
  }
295
286
  if (UNIVERSE) operator(UNIVERSE, ["deploy", ...args]);
296
- console.log(`\n '${["deploy", args[0]].filter(Boolean).join(" ")}' operates a universe, and this folder is not one.\n cd into your universe — or did you mean: unoverse deploy studio\n`);
287
+ console.log(`\n '${["deploy", args[0]].filter(Boolean).join(" ")}' operates a universe, and this folder is not one.\n cd into your universe. To ship your own work, push it to your org's Git repo.\n`);
297
288
  process.exit(1);
298
289
  }
299
290
 
300
291
  case "publish": {
301
- // THE PLATFORM OWNER'S VERB. A remote developer never publishes: their word is
302
- // `deploy studio`, and this says so instead of quietly doing it under the wrong
303
- // name. In a universe checkout the operator's publish lane runs as before (on a
292
+ // THE PLATFORM OWNER'S VERB. A remote developer never publishes: their work ships by a
293
+ // push to their org's Git repo, and this says so instead of quietly doing it under the
294
+ // wrong name. In a universe checkout the operator's publish lane runs as before (on a
304
295
  // starter kit that lane refuses by itself and names the owner's script).
305
296
  const { findWorkspace } = await import("../lib/workspace.mjs");
306
297
  const ws = findWorkspace();
307
298
  if (ws?.kind === "universe") {
308
299
  operator({ root: ws.root, script: OPERATOR }, ["publish", ...args]);
309
300
  } else {
310
- console.log(`\n publish is the platform owner's release. To ship your work: unoverse deploy studio\n`);
301
+ console.log(`\n publish is the platform owner's release. To ship your work, push it to your org's Git repo.\n`);
311
302
  process.exit(1);
312
303
  }
313
304
  break;
package/lib/base.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Base, as the CLI uses it: the lint the org repo's own pull-request check runs, and the same
3
+ * one a universe runs before it applies a commit.
4
+ *
5
+ * Base is consumed as BUILT dist, never source: in this monorepo straight from
6
+ * packages/base/dist (the in-repo copy wins, same rule as the vendored operator), and in the
7
+ * published package from the copy vendor-base.mjs froze at pack time.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { join, dirname, resolve } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const IN_REPO_BASE = resolve(here, "../../base/dist");
15
+ const VENDORED_BASE = resolve(here, "../vendor/base");
16
+
17
+ export async function importBase(subpath) {
18
+ const root = existsSync(IN_REPO_BASE) ? IN_REPO_BASE : VENDORED_BASE;
19
+ const file = join(root, subpath);
20
+ if (!existsSync(file)) {
21
+ throw new Error(
22
+ root === IN_REPO_BASE
23
+ ? `packages/base is not built (${subpath} missing). Run: npm run build -w packages/base`
24
+ : `This install is missing its vendored base (${subpath}). Reinstall: npm i -g unoverse@latest`,
25
+ );
26
+ }
27
+ return import(pathToFileURL(file).href);
28
+ }
package/lib/lint.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * unoverse lint — check a workspace before you deploy it.
3
3
  *
4
- * THE SAME GATE `deploy studio` RUNS, and nothing else: one function in base
4
+ * THE SAME CHECK A UNIVERSE RUNS before it applies a commit, and nothing else: one function in base
5
5
  * (items/publish lintForPublish) lints the project's design/ and the workspace's nodes/,
6
6
  * and this file only prints what it found. A developer who runs this and sees a tick will
7
7
  * deploy without a surprise, because deploy asks the same function the same question.
@@ -16,7 +16,7 @@ import { findWorkspace, designHome } from "./workspace.mjs";
16
16
  const c = { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", off: "\x1b[0m" };
17
17
 
18
18
  /**
19
- * Lint one project and print the findings. Returns the findings so `deploy studio` can
19
+ * Lint one project and print the findings. Returns the findings so a caller can
20
20
  * decide what to do with them; exits nobody.
21
21
  */
22
22
  export async function lintProject({ lintForPublish, designRoot, project }) {
@@ -706,6 +706,11 @@ cmd_deploy() {
706
706
  local GROUND_ARG=""
707
707
  case "${1:-}" in
708
708
  do|digitalocean|aws|amazon) GROUND_ARG="$1"; shift ;;
709
+ ""|-*) ;;
710
+ # A WORD THAT IS NOT A CLOUD IS REFUSED, before anything runs. An unknown word used to be
711
+ # ignored and the full deploy went ahead: the retired command for shipping an org's work
712
+ # (UNO-85) walked an operator into "Where should this universe live?".
713
+ *) fail "'deploy $1' is not a cloud. Use: unoverse deploy aws, or unoverse deploy digitalocean. Your own work ships by a push to your org's Git repo."; return 1 ;;
709
714
  esac
710
715
 
711
716
  # .env.production IS NOT A FILE IN THE UNIVERSE. It is `terraform output -raw
@@ -893,7 +898,7 @@ EOF
893
898
  ""|deploy)
894
899
  # THE deploy: the server takes the latest platform images (pull + restart).
895
900
  # Content does NOT ride deploys — it arrives from the marketplace as database
896
- # ROWS (`POST /marketplace/install`) or from `unoverse deploy studio`.
901
+ # ROWS (`POST /marketplace/install`) or from the org's Git repo, through the sync.
897
902
  #
898
903
  # "self-healing at boot" was the npm install lane (`plugins/startup.ts`),
899
904
  # deleted 2026-08-30 — nothing is fetched from a registry (MARKETPLACE.md §10 q3).
@@ -16,8 +16,7 @@ cmd_help() {
16
16
  echo -e " ${BOLD}Anywhere${NC}"
17
17
  echo -e " ${GREEN}create${NC} Start something new, here"
18
18
  echo -e " ${GREEN}studio${NC} Author components, nodes, agent skills"
19
- echo -e " ${GREEN}deploy${NC} Ship it"
20
- echo -e " ${DIM}deploy studio your components, nodes and skills → your universe${NC}"
19
+ echo -e " ${GREEN}deploy${NC} Put a universe on a cloud"
21
20
  echo -e " ${DIM}deploy aws your universe → AWS${NC}"
22
21
  echo -e " ${DIM}deploy digitalocean your universe → DigitalOcean${NC}"
23
22
  echo -e " ${GREEN}login${NC} Sign in to a universe ${DIM}(deploy asks by itself; this is for up front)${NC}"
@@ -198,28 +198,24 @@ case "${1:-}" in
198
198
  fi
199
199
  ;;
200
200
  publish)
201
- # TWO MEANINGS, decided by which kit you are in.
202
- # monorepo `publish` is the PLATFORM RELEASE (publish.sh, owner-only), so a
203
- # developer publish is `publish assets <project>`.
204
- # starter sync-starter.sh strips publish.sh, so the name is free and `publish`
205
- # means the only publish a developer has.
201
+ # ONE MEANING: the PLATFORM RELEASE (publish.sh, owner-only). A developer never publishes
202
+ # into a universe: that door is closed (UNO-85), and their work ships by a push to their
203
+ # org's Git repo, which every connected universe pulls.
206
204
  if [ "${2:-}" = "assets" ]; then
207
- shift 2; node --import tsx "$GRAVITY_LIB/publish-assets.mjs" "$@"
205
+ echo "" >&2
206
+ echo " Publishing work into a universe is closed. Push it to your org's Git repo;" >&2
207
+ echo " every universe connected to the repo pulls it (RELEASE_LIFECYCLE.md §3)." >&2
208
+ echo "" >&2
209
+ exit 1
208
210
  elif type cmd_publish >/dev/null 2>&1; then
209
211
  shift; cmd_publish "$@"
210
212
  elif _in_platform_monorepo; then
211
- # THE INSTALLED CLI CANNOT RELEASE THE PLATFORM, and inside this repo the fallthrough
212
- # below is never what anyone meant: it would start publishing design assets to a universe
213
- # while the operator believes they are shipping the platform. It used to do exactly
214
- # that, silently, which is the whole reason this branch exists. Refuse and say where
215
- # to go. A starter kit never reaches here (no `unoverse-platform` package above it),
216
- # so the developer publish keeps working untouched.
213
+ # THE INSTALLED CLI CANNOT RELEASE THE PLATFORM: refuse and say where to go.
217
214
  echo "" >&2
218
215
  echo " The unoverse CLI does not release the platform." >&2
219
216
  echo "" >&2
220
- echo " You are inside the platform monorepo, where \`publish\` would publish rx" >&2
221
- echo " ASSETS to a universe — not ship the platform. The release lives in this" >&2
222
- echo " repo's own script, which the published CLI does not carry:" >&2
217
+ echo " The release lives in this repo's own script, which the published CLI does" >&2
218
+ echo " not carry:" >&2
223
219
  echo "" >&2
224
220
  echo " scripts/operator.sh publish" >&2
225
221
  echo "" >&2
@@ -227,7 +223,11 @@ case "${1:-}" in
227
223
  echo "" >&2
228
224
  exit 1
229
225
  else
230
- shift; node --import tsx "$GRAVITY_LIB/publish-assets.mjs" "$@"
226
+ echo "" >&2
227
+ echo " Publishing work into a universe is closed. Push it to your org's Git repo;" >&2
228
+ echo " every universe connected to the repo pulls it." >&2
229
+ echo "" >&2
230
+ exit 1
231
231
  fi
232
232
  ;;
233
233
  build) cmd_build "${2:-}" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.216",
3
+ "version": "0.1.217",
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",
@@ -279,7 +279,7 @@ function collectFrom(root, project, designSystem) {
279
279
  // package envelope their $refs and credential shapes resolve against. The name is the
280
280
  // node's `type`, bare: a saved workflow stores that identity and nothing else.
281
281
  //
282
- // Until 2026-09-05 deploy never looked at nodes at all, so `deploy studio` shipped a
282
+ // Until 2026-09-05 the collect never looked at nodes at all, so shipping an org sent a
283
283
  // developer's components and skills and silently left every node behind. Until
284
284
  // 2026-09-08 it looked at a `nodes/` folder beside design/ while every org already
285
285
  // kept its nodes INSIDE its project, so the same nodes were left behind again. Org
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://docs.unoverse.ai/schemas/objects/dataset.schema.json",
4
4
  "title": "A dataset's table",
5
- "description": "The shape of the table an object owns (docs/unoverse/data/objects/dataset.md). The declaration IS the schema: it generates a migration a person reviews, and nobody edits a database schema by hand.",
5
+ "description": "The shape of the table an object owns (docs/unoverse/data/objects/dataset.md). The declaration IS the schema: the platform makes the table from it. Nobody writes SQL and nobody edits a database schema by hand.",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "columns"
@@ -224,6 +224,20 @@
224
224
  ]
225
225
  }
226
226
  },
227
+ "matching": {
228
+ "type": "object",
229
+ "description": "How a name is matched to a row of this object (docs/unoverse/data/objects/entity-resolution.md, \"How an org declares it\"): ids decide, then any of its names as words, then a few candidates put to a judge. Every field is a prop of the object.",
230
+ "additionalProperties": false,
231
+ "required": ["names"],
232
+ "properties": {
233
+ "ids": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Fields whose equal value on both rows, compared as words, decides it is the same thing." },
234
+ "names": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "The fields that hold its names; a mention matching any of them, as words, is this row." },
235
+ "remember": { "type": "string", "description": "One of names, a list: where a name confirmed by the judge or a person is added, so the next one matches at once." },
236
+ "evidence": { "type": "array", "items": { "type": "string" }, "description": "Fields shown to the judge beside each candidate, to tell like names apart." },
237
+ "ruleOut": { "type": "array", "items": { "type": "string" }, "description": "Fields that, filled on both and different as words, mean never the same thing." },
238
+ "judge": { "type": "string", "description": "The node that decides a name the rules leave open, choosing among the candidates or none (TypeSafeChoice). Absent, an open name goes to review." }
239
+ }
240
+ },
227
241
  "dataset": {
228
242
  "oneOf": [
229
243
  {
@@ -125,6 +125,10 @@
125
125
  "const": true,
126
126
  "description": "The item MAY be one; the model says."
127
127
  },
128
+ "per": {
129
+ "const": "section",
130
+ "description": "Written after the document is cut, one read per stored piece of each named section, instead of in the document's first read."
131
+ },
128
132
  "parts": {
129
133
  "type": "object",
130
134
  "additionalProperties": {
@@ -106,6 +106,19 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
106
106
  for (const k of Object.keys(l)) if (!["type", "between", "order", "when", "alike"].includes(k)) say(`object ${name}: link ${ln}: "${k}" is not a word here`);
107
107
  }
108
108
  }
109
+ // HOW A NAME IS MATCHED TO ITS ROWS (entity-resolution.md, "How an org declares it", 2026-10-05).
110
+ if (envelope.matching != null) {
111
+ const m = envelope.matching;
112
+ const fields = (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === "string" && f);
113
+ if (!isRecord(m)) refuse(`object ${name}: matching is a record: ids, names, remember, evidence, ruleOut, judge`);
114
+ else {
115
+ if (!fields(m.names)) refuse(`object ${name}: matching: names lists the fields that hold this object's names`);
116
+ for (const k of ["ids", "evidence", "ruleOut"]) if (m[k] != null && !fields(m[k])) refuse(`object ${name}: matching: ${k} is a list of field names`);
117
+ if (m.remember != null && (typeof m.remember !== "string" || !(m.names ?? []).includes(m.remember))) refuse(`object ${name}: matching: remember names one of names, the list a confirmed name is added to`);
118
+ if (m.judge != null && (typeof m.judge !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(m.judge))) refuse(`object ${name}: matching: judge names a node by its type (TypeSafeChoice)`);
119
+ for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
120
+ }
121
+ }
109
122
  return out;
110
123
  }
111
124
 
@@ -251,6 +264,11 @@ export function rowKeyFindings(name, envelope, props, file = `${name}.yaml`) {
251
264
  if (!Array.isArray(list)) continue;
252
265
  for (const f of list.flat()) if (typeof f === "string" && !known.has(f) && !known.has(camel(f))) out.push({ file, msg: `object ${name}: ${key} names "${f}", which is not a prop of this object or its base. A row key is a field (README 9a)` });
253
266
  }
267
+ const m = isRecord(envelope.matching) ? envelope.matching : null;
268
+ if (m) {
269
+ for (const k of ["ids", "names", "evidence", "ruleOut"]) for (const f of Array.isArray(m[k]) ? m[k] : []) if (typeof f === "string" && !known.has(f)) out.push({ file, msg: `object ${name}: matching: ${k} names "${f}", which is not a prop of this object or its base (entity-resolution.md)` });
270
+ if (typeof m.remember === "string" && props[m.remember] && props[m.remember].type !== "array") out.push({ file, msg: `object ${name}: matching: remember names "${m.remember}", which is not a list; a confirmed name is added to a list (entity-resolution.md)` });
271
+ }
254
272
  return out;
255
273
  }
256
274
 
@@ -285,7 +303,7 @@ export const PIPELINE_READ_KEYS = new Set(["each", "sourceUrl", "sourceId", "con
285
303
  const DROPPED_MAKE_KEYS = ["partsOf", "by", "under", "when", "from", "any", "index"];
286
304
 
287
305
  /** What an object may say: under `each`, how many and what it holds; made once per pack, how many and from which rows. */
288
- const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where"]);
306
+ const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per"]);
289
307
  const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
290
308
 
291
309
  /**
@@ -361,7 +379,8 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
361
379
  }
362
380
  if (made.includes(o)) refuse(`pipeline ${name}: ${at}.${o} is made above already; one entry makes an object`);
363
381
  const allowed = ITEM_OBJECT_KEYS;
364
- for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts and where (${see})`);
382
+ for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts, where and per (${see})`);
383
+ if ("per" in spec && spec.per !== "section") refuse(`pipeline ${name}: ${at}.${o}: per says section, the only unit an object is written per (${see})`);
365
384
  // HOW MANY IS SAID by every object the model writes (owner, 2026-09-13).
366
385
  if (spec.cardinality !== "one" && spec.cardinality !== "many") say(`pipeline ${name}: ${at}.${o} says cardinality: one or many, how many of it one row makes (${see})`);
367
386
  if ("any" in spec && spec.any !== true) refuse(`pipeline ${name}: ${at}.${o}: any: true is its only value, the item MAY be one`);
package/lib/publish.mjs DELETED
@@ -1,218 +0,0 @@
1
- /**
2
- * unoverse deploy studio: ship an asset workspace's items to its universe.
3
- *
4
- * The terminal face of @unoverse-platform/base items/publish. The order is the safety:
5
- * LINT, then COMPARE (a dry-run per item against the universe), then a plan and a
6
- * question, then send. Nothing leaves the machine until the rules pass.
7
- * (LOCAL_STUDIO.md §Publish is terminal-only; the engine lives in base, this file only
8
- * resolves context, formats and asks.)
9
- *
10
- * Base is consumed as BUILT dist, never source: in this monorepo straight from
11
- * packages/base/dist (the in-repo copy wins, same rule as the vendored operator), and in
12
- * the published package from the copy vendor-base.mjs froze at pack time.
13
- */
14
- import { existsSync } from "node:fs";
15
- import { join, dirname, resolve } from "node:path";
16
- import { fileURLToPath, pathToFileURL } from "node:url";
17
- import { createInterface } from "node:readline/promises";
18
- import { findWorkspace, designHome, readConfig, writeConfig } from "./workspace.mjs";
19
- import { normaliseUrl, tokenFor } from "./login.mjs";
20
-
21
- const here = dirname(fileURLToPath(import.meta.url));
22
- const IN_REPO_BASE = resolve(here, "../../base/dist");
23
- const VENDORED_BASE = resolve(here, "../vendor/base");
24
-
25
- export async function importBase(subpath) {
26
- const root = existsSync(IN_REPO_BASE) ? IN_REPO_BASE : VENDORED_BASE;
27
- const file = join(root, subpath);
28
- if (!existsSync(file)) {
29
- throw new Error(
30
- root === IN_REPO_BASE
31
- ? `packages/base is not built (${subpath} missing). Run: npm run build -w packages/base`
32
- : `This install is missing its vendored base (${subpath}). Reinstall: npm i -g unoverse@latest`,
33
- );
34
- }
35
- return import(pathToFileURL(file).href);
36
- }
37
-
38
- const c = { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", off: "\x1b[0m" };
39
- const die = (msg) => {
40
- console.error(`\n ${c.red}✗${c.off} ${msg}\n`);
41
- process.exit(1);
42
- };
43
-
44
- const ask = async (question) => {
45
- const rl = createInterface({ input: process.stdin, output: process.stdout });
46
- const answer = await rl.question(question);
47
- rl.close();
48
- return answer.trim();
49
- };
50
-
51
- /**
52
- * `unoverse deploy studio [project] [--to <universe> | --env <name>] [--dry-run] [--yes]`
53
- * Run from anywhere inside an asset workspace. bin/unoverse.mjs routes here only when
54
- * the nearest marker up from cwd is a design home.
55
- */
56
- export async function publish(args) {
57
- const flag = (name) => args.includes(`--${name}`);
58
- const opt = (name) => {
59
- const i = args.indexOf(`--${name}`);
60
- return i >= 0 ? args[i + 1] : undefined;
61
- };
62
- // An option's value is never the project: `--env uat` must not read "uat" as one.
63
- const optionValues = new Set(["to", "env"].map(opt).filter(Boolean));
64
- const positional = args.find((a) => !a.startsWith("--") && !optionValues.has(a));
65
-
66
- const ws = findWorkspace();
67
- if (ws?.kind !== "assets" && ws?.kind !== "org") die("no org repo or design/ folder here or above. Run this inside your workspace.");
68
-
69
- const { collectProject, listProjects, collectOrg } = await importBase("items/collect.js");
70
- const { lintForPublish, planPublish, sendPublish, lintOrg } = await importBase("items/publish.js");
71
-
72
- // ── which project ───────────────────────────────────────────────────────────
73
- // An org repo (UNO-88) IS one org, declared in its unoverse.yaml; the monorepo shape
74
- // (design/<project>/) may hold several and asks which.
75
- let project = positional;
76
- let designRoot;
77
- let collect;
78
- let lintFor = lintForPublish;
79
- if (ws.kind === "org") {
80
- if (project && project !== ws.org) die(`this repo is the org "${ws.org}", not "${project}"`);
81
- project = ws.org;
82
- designRoot = ws.root;
83
- collect = () => collectOrg({ org: ws.org, dir: ws.root });
84
- lintFor = () => lintOrg({ org: ws.org, dir: ws.root });
85
- } else {
86
- designRoot = designHome(ws.root);
87
- const projects = listProjects(designRoot);
88
- if (!projects.length) die(`nothing to publish: ${designRoot} holds no projects`);
89
- if (!project) {
90
- if (projects.length > 1) {
91
- die(`several projects here. Say which:\n\n unoverse deploy studio <project>\n\n Found: ${projects.join(", ")}`);
92
- }
93
- project = projects[0];
94
- } else if (!projects.includes(project)) {
95
- die(`no project "${project}" in ${designRoot}. Found: ${projects.join(", ") || "none"}`);
96
- }
97
- collect = () => collectProject(designRoot, project);
98
- }
99
-
100
- // ── which universe: --to overrides, --env names one from unoverse.yaml's
101
- // `environments:`, `universe:` is the default, the login answers, and only a
102
- // machine that knows nothing asks ─────────────────────────────────────────
103
- const config = await readConfig(ws.root);
104
- const envName = opt("env");
105
- let envUniverse;
106
- if (envName && !opt("to")) {
107
- envUniverse = config.environments?.[envName];
108
- if (!envUniverse) {
109
- const known = Object.keys(config.environments ?? {});
110
- die(`no environment "${envName}" in unoverse.yaml. ${known.length ? `Found: ${known.join(", ")}` : "Add one:\n\n environments:\n " + envName + ": https://your-universe.example"}`);
111
- }
112
- }
113
- let universe = opt("to") ?? envUniverse ?? config.universe;
114
- if (!universe) {
115
- // THE LOGIN ALREADY NAMED THE UNIVERSE. `unoverse login` takes an address and
116
- // keeps a session for it, so a machine signed in to exactly one universe has
117
- // already answered this question — asking it again reads as the CLI forgetting
118
- // what it was just told (observed live 2026-08-21). One session: use it, say so,
119
- // and remember it in unoverse.yaml like a typed answer. Several sessions: the
120
- // choice is real, so the question remains.
121
- const { sessionOrigins } = await import("./session.mjs");
122
- const origins = sessionOrigins();
123
- if (origins.length === 1) {
124
- universe = origins[0];
125
- await writeConfig(ws.root, { universe });
126
- console.log(`
127
- ${c.dim}→ ${universe} (your signed-in universe; saved to unoverse.yaml)${c.off}`);
128
- }
129
- }
130
- if (!universe) {
131
- if (!process.stdin.isTTY) die("no universe. Pass --to https://your-universe.example, or commit it in unoverse.yaml");
132
- const typed = await ask(`\n Where does this workspace deploy to? ${c.dim}(e.g. universe.example.com)${c.off} `);
133
- if (!typed) die("no universe given");
134
- universe = normaliseUrl(typed);
135
- await writeConfig(ws.root, { universe });
136
- console.log(` ${c.green}✓${c.off} saved to unoverse.yaml ${c.dim}(commit it: the whole team deploys there)${c.off}`);
137
- } else {
138
- universe = normaliseUrl(universe);
139
- }
140
-
141
- // ── 1. lint. Nothing is sent if this fails ──────────────────────────────────
142
- console.log(`\n ${c.bold}deploy ${project}${c.off} ${c.dim}→ ${universe}${c.off}\n`);
143
- const { lintProject } = await import("./lint.mjs");
144
- const { errors } = await lintProject({ lintForPublish: lintFor, designRoot, project });
145
- if (errors.length) {
146
- console.error(` ${c.red}Nothing was sent.${c.off}\n`);
147
- process.exit(1);
148
- }
149
-
150
- // ── 2. the credential, only now that there is something worth sending ───────
151
- const items = collect();
152
- if (!items.length) die(`nothing to publish in ${project}`);
153
- let token;
154
- try {
155
- token = await tokenFor(universe, { interactive: !flag("yes") });
156
- } catch (e) {
157
- die(e.message);
158
- }
159
-
160
- // ── 3. the plan: what would this publish actually do ────────────────────────
161
- let plan;
162
- try {
163
- plan = await planPublish(items, universe, token, project);
164
- } catch (e) {
165
- die(e.message);
166
- }
167
-
168
- console.log("");
169
- const label = (i) => `${i.kind}/${i.name}`;
170
- // No "(lands PENDING review)" on a node (removed 2026-09-05): the review flow in
171
- // DECLARATIVE_NODES.md §9.5 is not built, and the route registers a node row at once.
172
- for (const i of plan.create) console.log(` ${c.green}+${c.off} ${label(i)} ${c.dim}(new)${c.off}`);
173
- for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}`);
174
- if (plan.unchanged.length) console.log(` ${c.dim}= ${plan.unchanged.length} unchanged${c.off}`);
175
- for (const r of plan.refused) console.log(` ${c.red}✗ ${label(r)}${c.off} ${c.dim}${r.why}${c.off}`);
176
- // Deploy is a SYNC: what left the workspace leaves the universe, said before it happens.
177
- for (const r of plan.remove ?? []) console.log(` ${c.red}-${c.off} ${label(r)} ${c.dim}(no longer in this workspace — will be removed)${c.off}`);
178
-
179
- if (plan.syncUnavailable) {
180
- console.log(` ${c.yellow}⚠${c.off} this universe cannot answer the sync check yet ${c.dim}(its server predates it)${c.off}`);
181
- console.log(` ${c.dim}items you deleted from the workspace were NOT reconciled. Update the universe`);
182
- console.log(` ${c.dim}(unoverse update on its box), then deploy again to remove them.${c.off}`);
183
- }
184
-
185
- const toSend = plan.create.length + plan.update.length + (plan.remove?.length ?? 0);
186
- if (!toSend) {
187
- if (plan.syncUnavailable) {
188
- console.log(`\n ${c.green}✓${c.off} nothing new to send ${c.dim}(deletions unchecked, see above)${c.off}\n`);
189
- } else {
190
- console.log(`\n ${c.green}✓${c.off} nothing to do, ${project} is up to date\n`);
191
- }
192
- process.exit(plan.refused.length ? 1 : 0);
193
- }
194
- if (flag("dry-run")) {
195
- console.log(`\n ${c.dim}dry run: ${toSend} item(s) would be sent${c.off}\n`);
196
- process.exit(0);
197
- }
198
-
199
- // ── 4. confirm, unless told not to ──────────────────────────────────────────
200
- if (!flag("yes")) {
201
- const answer = await ask(`\n ${toSend} item(s) → ${universe}. Deploy? [y/N] `);
202
- if (!/^y(es)?$/i.test(answer)) {
203
- console.log(` ${c.dim}cancelled${c.off}\n`);
204
- process.exit(0);
205
- }
206
- }
207
-
208
- // ── 5. send ─────────────────────────────────────────────────────────────────
209
- const { sent, failed } = await sendPublish(plan, universe, token);
210
- console.log("");
211
- for (const s of sent) console.log(` ${c.green}✓${c.off} ${s.mode ?? "sent"} ${c.dim}${s.kind}/${s.name}${c.off}`);
212
- for (const f of failed) console.log(` ${c.red}✗${c.off} ${f.kind}/${f.name} ${f.why}`);
213
- console.log(
214
- `\n ${failed.length ? c.red + "✗" : c.green + "✓"}${c.off} ${sent.length} deployed` +
215
- (failed.length ? `, ${failed.length} failed` : "") + "\n",
216
- );
217
- process.exit(failed.length ? 1 : 0);
218
- }
@@ -1,123 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * unoverse publish <project> — push a project's assets into a universe.
4
- *
5
- * The terminal face of `packages/base/src/items/publish.ts`. Studio will call the same
6
- * functions from a button; this file only formats and asks.
7
- *
8
- * The order is the safety: LINT, then COMPARE, then confirm, then send. Nothing leaves the
9
- * machine until the rules pass, because a developer should learn about a raw hex value from
10
- * their own terminal, not from an HTTP error. The universe validates again on receipt, as
11
- * it must not trust a client, but by then it is a second opinion rather than the only one.
12
- *
13
- * Usage:
14
- * publish-assets.mjs <project> --to <universe> --token <jwt> [--dry-run] [--yes]
15
- */
16
- import { resolve, dirname, join } from "node:path";
17
- import { existsSync } from "node:fs";
18
- import { fileURLToPath } from "node:url";
19
- import { createInterface } from "node:readline/promises";
20
-
21
- const here = dirname(fileURLToPath(import.meta.url));
22
- const base = join(here, "../../packages/base/src/items");
23
- const { collectProject, listProjects } = await import(join(base, "collect.ts"));
24
- const { lintForPublish, planPublish, sendPublish } = await import(join(base, "publish.ts"));
25
-
26
- const arg = (name) => {
27
- const i = process.argv.indexOf(`--${name}`);
28
- return i >= 0 ? process.argv[i + 1] : undefined;
29
- };
30
- const flag = (name) => process.argv.includes(`--${name}`);
31
-
32
- const project = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : undefined;
33
- const universe = arg("to");
34
- const token = arg("token") ?? process.env.UNOVERSE_TOKEN;
35
- const designRoot = resolve(arg("design") ?? "apps/unoverse/design");
36
-
37
- const c = { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", off: "\x1b[0m" };
38
- const die = (msg) => {
39
- console.error(`\n ${c.red}✗${c.off} ${msg}\n`);
40
- process.exit(1);
41
- };
42
-
43
- // The platform repo's design/ was retired 2026-10-04: no org is authored here any more. An
44
- // absent folder is no org tier, said plainly, rather than a lint error about a missing folder.
45
- if (!existsSync(designRoot)) {
46
- die(`no design/ folder at ${designRoot}. An org lives in its own repo and publishes from there; pass --design <folder> for a design tree kept elsewhere.`);
47
- }
48
-
49
- if (!project) {
50
- const projects = listProjects(designRoot);
51
- die(`which project?\n\n unoverse publish <project> --to <universe>\n\n Found: ${projects.join(", ") || "none"}`);
52
- }
53
- if (!universe) die("no universe. Pass --to https://your-universe.com");
54
- if (!token) die("no credential. Pass --token, or set UNOVERSE_TOKEN");
55
-
56
- // ── 1. lint. Nothing is sent if this fails ────────────────────────────────────
57
- console.log(`\n ${c.bold}publish ${project}${c.off} ${c.dim}→ ${universe}${c.off}\n`);
58
- process.stdout.write(` ${c.dim}checking…${c.off}`);
59
- const { problems, errors } = await lintForPublish(designRoot, project);
60
- process.stdout.write("\r \r");
61
-
62
- if (errors.length) {
63
- console.error(` ${c.red}✗ ${errors.length} error(s) in ${project}. Nothing was sent.${c.off}\n`);
64
- for (const p of errors.slice(0, 10)) console.error(` ${p.file}${p.line ? ":" + p.line : ""} ${p.msg}`);
65
- if (errors.length > 10) console.error(` ${c.dim}…and ${errors.length - 10} more${c.off}`);
66
- console.error("");
67
- process.exit(1);
68
- }
69
- const warnings = problems.filter((p) => p.level === "warn").length;
70
- console.log(` ${c.green}✓${c.off} checks passed${warnings ? ` ${c.dim}(${warnings} warning(s))${c.off}` : ""}`);
71
-
72
- // ── 2. compare against what the universe already holds ────────────────────────
73
- const items = collectProject(designRoot, project);
74
- if (!items.length) die(`nothing to publish in ${project}`);
75
-
76
- let plan;
77
- try {
78
- plan = await planPublish(items, universe, token);
79
- } catch (e) {
80
- die(e.message);
81
- }
82
-
83
- const line = (label, list, colour) =>
84
- list.length && console.log(` ${colour}${String(list.length).padStart(3)}${c.off} ${label.padEnd(10)} ${c.dim}${list.map((i) => i.name).join(", ")}${c.off}`);
85
-
86
- console.log("");
87
- line("new", plan.create, c.green);
88
- line("updated", plan.update, c.yellow);
89
- line("unchanged", plan.unchanged, c.dim);
90
- line("refused", plan.refused, c.red);
91
- for (const r of plan.refused) console.log(` ${c.red}${r.name}${c.off} ${c.dim}${r.why}${c.off}`);
92
-
93
- const toSend = plan.create.length + plan.update.length;
94
- if (!toSend) {
95
- console.log(`\n ${c.green}✓${c.off} nothing to do, ${project} is up to date\n`);
96
- process.exit(0);
97
- }
98
- if (flag("dry-run")) {
99
- console.log(`\n ${c.dim}dry run: ${toSend} item(s) would be sent${c.off}\n`);
100
- process.exit(0);
101
- }
102
-
103
- // ── 3. confirm, unless told not to ────────────────────────────────────────────
104
- if (!flag("yes")) {
105
- const rl = createInterface({ input: process.stdin, output: process.stdout });
106
- const answer = await rl.question(`\n Publish ${toSend} item(s) to ${universe}? [y/N] `);
107
- rl.close();
108
- if (!/^y(es)?$/i.test(answer.trim())) {
109
- console.log(` ${c.dim}cancelled${c.off}\n`);
110
- process.exit(0);
111
- }
112
- }
113
-
114
- // ── 4. send ───────────────────────────────────────────────────────────────────
115
- const { sent, failed } = await sendPublish(plan, universe, token);
116
- console.log("");
117
- for (const s of sent) console.log(` ${c.green}✓${c.off} ${s.mode ?? "sent"} ${c.dim}${s.kind}/${s.name}${c.off}`);
118
- for (const f of failed) console.log(` ${c.red}✗${c.off} ${f.kind}/${f.name} ${f.why}`);
119
- console.log(
120
- `\n ${failed.length ? c.red + "✗" : c.green + "✓"}${c.off} ${sent.length} published` +
121
- (failed.length ? `, ${failed.length} failed` : "") + "\n",
122
- );
123
- process.exit(failed.length ? 1 : 0);