unoverse 0.1.216 → 0.1.218
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 +16 -25
- package/lib/base.mjs +28 -0
- package/lib/lint.mjs +2 -2
- package/operator/lib/deploy.sh +6 -1
- package/operator/lib/help.sh +1 -2
- package/operator/operator.sh +16 -16
- package/package.json +1 -1
- package/vendor/base/items/collect.js +1 -1
- package/vendor/base/lint/objects/_schema/objects/dataset.schema.json +1 -1
- package/vendor/base/lint/objects/_schema/objects/object.schema.json +15 -0
- package/vendor/base/lint/objects/_schema/pipelines/pipeline.schema.json +4 -0
- package/vendor/base/lint/objects/rules.mjs +22 -2
- package/lib/publish.mjs +0 -218
- package/operator/lib/publish-assets.mjs +0 -123
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
|
|
100
|
-
row("deploy", "
|
|
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
|
|
248
|
-
//
|
|
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/
|
|
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
|
-
//
|
|
281
|
-
// ships this
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
|
302
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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 }) {
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -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
|
|
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).
|
package/operator/lib/help.sh
CHANGED
|
@@ -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}
|
|
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}"
|
package/operator/operator.sh
CHANGED
|
@@ -198,28 +198,24 @@ case "${1:-}" in
|
|
|
198
198
|
fi
|
|
199
199
|
;;
|
|
200
200
|
publish)
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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
|
-
|
|
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
|
|
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 "
|
|
221
|
-
echo "
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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:
|
|
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,21 @@
|
|
|
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
|
+
"createFrom": { "type": "array", "items": { "type": "string" }, "description": "The lines, by name (a document's about), whose name the judge is sure is no record held makes the record. A mention on any other line, or a name of one word, never does." }
|
|
240
|
+
}
|
|
241
|
+
},
|
|
227
242
|
"dataset": {
|
|
228
243
|
"oneOf": [
|
|
229
244
|
{
|
|
@@ -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,20 @@ 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, createFrom`);
|
|
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.createFrom != null && !fields(m.createFrom)) refuse(`object ${name}: matching: createFrom is a list of line names (about)`);
|
|
118
|
+
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`);
|
|
119
|
+
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)`);
|
|
120
|
+
for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge", "createFrom"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
109
123
|
return out;
|
|
110
124
|
}
|
|
111
125
|
|
|
@@ -251,6 +265,11 @@ export function rowKeyFindings(name, envelope, props, file = `${name}.yaml`) {
|
|
|
251
265
|
if (!Array.isArray(list)) continue;
|
|
252
266
|
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
267
|
}
|
|
268
|
+
const m = isRecord(envelope.matching) ? envelope.matching : null;
|
|
269
|
+
if (m) {
|
|
270
|
+
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)` });
|
|
271
|
+
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)` });
|
|
272
|
+
}
|
|
254
273
|
return out;
|
|
255
274
|
}
|
|
256
275
|
|
|
@@ -285,7 +304,7 @@ export const PIPELINE_READ_KEYS = new Set(["each", "sourceUrl", "sourceId", "con
|
|
|
285
304
|
const DROPPED_MAKE_KEYS = ["partsOf", "by", "under", "when", "from", "any", "index"];
|
|
286
305
|
|
|
287
306
|
/** 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"]);
|
|
307
|
+
const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per"]);
|
|
289
308
|
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
290
309
|
|
|
291
310
|
/**
|
|
@@ -361,7 +380,8 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
|
|
|
361
380
|
}
|
|
362
381
|
if (made.includes(o)) refuse(`pipeline ${name}: ${at}.${o} is made above already; one entry makes an object`);
|
|
363
382
|
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
|
|
383
|
+
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})`);
|
|
384
|
+
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
385
|
// HOW MANY IS SAID by every object the model writes (owner, 2026-09-13).
|
|
366
386
|
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
387
|
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);
|