unoverse 0.1.68 → 0.1.70

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
@@ -11,7 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { join, resolve, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { create } from "../lib/create.mjs";
14
+ import { create } from "../lib/create/index.mjs";
15
15
 
16
16
  /**
17
17
  * ONE BINARY. There is no `./unoverse` any more.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The registry credential: whatever shape it arrives in, and whether it actually works.
3
+ *
4
+ * Two hard-won rules live here. A credential is a PAIR (the downloaded Docker credentials
5
+ * file carries base64("email:token"), so the username is the account email, not the
6
+ * token). And the only honest validation is the operation itself: probing with a
7
+ * different check once approved a credential that `docker login` then refused.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const REGISTRY_HOST = "registry.digitalocean.com";
12
+
13
+ /** Operators hold the credential in two shapes: the raw dop_v1 API token, and the
14
+ * pre-encoded base64 `user:token` blob that docker config (and the starter's own
15
+ * `docr` setting) uses. Wrapping the second shape in base64 AGAIN is how a valid
16
+ * credential gets refused, so detect it and pass it through untouched. */
17
+ /** The raw dop_v1 token, whichever shape was pasted. docker login needs the raw form,
18
+ * so the base64 user:token blob is decoded before anything downstream sees it. */
19
+ /** The credential AS A PAIR. The downloaded Docker credentials file carries
20
+ * base64("email:token") — the username is the ACCOUNT EMAIL, and logging in with the
21
+ * token as username (right for API tokens) gets "unauthorized" for it. So nothing here
22
+ * reduces to a bare token any more: whatever shape is pasted, the exact user:password
23
+ * pair comes out and is used verbatim.
24
+ * raw dop_v1 → token:token
25
+ * base64("u:p") → u:p (email:token, or :token → token:token)
26
+ * the whole JSON file → its auth value, decoded the same way
27
+ */
28
+ export function parseCredential(input) {
29
+ const t = input.trim().replace(/^["']|["']$/g, "");
30
+ const pair = (d) => {
31
+ const m = /^([\x20-\x7e]*):([\x20-\x7e]+)$/.exec(d);
32
+ return m ? { user: m[1] || m[2], pass: m[2] } : null;
33
+ };
34
+ const auth = /"auth"\s*:\s*"([A-Za-z0-9+/=]+)"/.exec(t);
35
+ if (auth) {
36
+ const p = pair(Buffer.from(auth[1], "base64").toString("utf8"));
37
+ if (p) return p;
38
+ }
39
+ if (/^[A-Za-z0-9+/]+=*$/.test(t)) {
40
+ const p = pair(Buffer.from(t, "base64").toString("utf8"));
41
+ if (p) return p;
42
+ }
43
+ const inline = /dop_v1_[0-9a-f]{64}/.exec(t);
44
+ if (inline) return { user: inline[0], pass: inline[0] };
45
+ return { user: t, pass: t };
46
+ }
47
+
48
+
49
+ function registryAuth(cred) {
50
+ return Buffer.from(`${cred.user}:${cred.pass}`).toString("base64");
51
+ }
52
+
53
+ /** The gate: prove the token opens the registry before handing over the kit.
54
+ * Docker registries use the bearer-realm flow: /v2/ answers 401 and names the
55
+ * auth realm; presenting the token there yields 200 (valid) or 401 (refused). */
56
+ export async function validateRegistryToken(cred) {
57
+ try {
58
+ const probe = await fetch(`https://${REGISTRY_HOST}/v2/`);
59
+ const wa = probe.headers.get("www-authenticate") ?? "";
60
+ const realm = /realm="([^"]+)"/.exec(wa)?.[1];
61
+ const service = /service="([^"]+)"/.exec(wa)?.[1] ?? REGISTRY_HOST;
62
+ if (!realm) return probe.status === 200;
63
+ const res = await fetch(
64
+ `${realm}?service=${encodeURIComponent(service)}&scope=registry:catalog:*`,
65
+ { headers: { Authorization: `Basic ${registryAuth(cred)}` } },
66
+ );
67
+ return res.ok;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function dockerUp() {
74
+ return spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
75
+ }
76
+
77
+ /** The ONLY honest validation is the operation itself. create used to probe the auth
78
+ * realm with its own HTTP call and say "token accepted", then docker login failed on
79
+ * the same credential two minutes later — the tool approving a token with a weaker
80
+ * check than the one that counts. When Docker is up, validation IS docker login (and
81
+ * the credential lands in the keychain, so nothing downstream logs in again). */
82
+ export function dockerLogin(cred) {
83
+ const r = spawnSync("docker", ["login", REGISTRY_HOST, "-u", cred.user, "--password-stdin"], {
84
+ input: cred.pass,
85
+ encoding: "utf8",
86
+ });
87
+ const err = (r.stderr || "").split("\n").filter((l) => l && !/^WARNING/i.test(l)).pop() || "";
88
+ return { ok: r.status === 0, err };
89
+ }
90
+
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The starter tree, fetched from the public template repo.
3
+ */
4
+ import { mkdirSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ export const STARTER_TARBALL =
8
+ "https://codeload.github.com/unoverse-platform/starter/tar.gz/refs/heads/main";
9
+
10
+ export function download(dir, stripComponents = 1, filter = "") {
11
+ mkdirSync(dir, { recursive: true });
12
+ const r = spawnSync(
13
+ "sh",
14
+ ["-c", `curl -fsSL '${STARTER_TARBALL}' | tar -xz --strip-components=${stripComponents} -C '${dir}' ${filter}`],
15
+ { stdio: ["ignore", "inherit", "inherit"] },
16
+ );
17
+ if (r.status !== 0) throw new Error("download failed");
18
+ }
19
+
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `unoverse create` — first contact, as a question instead of a repo choice.
3
+ *
4
+ * Studio is the default path: most people author, few people operate. A universe is the
5
+ * operator tier and is GATED on a working registry credential, because the kit is useless
6
+ * without one. The steps it owns are the ORDER of the conversation; the work each step
7
+ * does lives beside it (credential, target, download) and the terminal dressing in ../ui.
8
+ */
9
+ import { createInterface } from "node:readline/promises";
10
+ import { dirname, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { existsSync, mkdirSync } from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ import { cyan, dim, ok, fail, ask, select } from "../ui.mjs";
16
+ import { parseCredential, validateRegistryToken, dockerUp, dockerLogin } from "./credential.mjs";
17
+ import { resolveTarget, label } from "./target.mjs";
18
+ import { download } from "./download.mjs";
19
+
20
+ export async function create(nameArg) {
21
+ console.log(`\n ${cyan("⬡ What are you building?")}\n`);
22
+ const choice = String((await select()) + 1);
23
+
24
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
25
+ try {
26
+
27
+ if (choice === "1") {
28
+ console.log(`\n ${dim("Launching Unoverse Studio. It creates and manages your projects.")}\n`);
29
+ rl.close();
30
+ const r = spawnSync("npx", ["-y", "@unoverse-platform/studio@latest"], { stdio: "inherit" });
31
+ process.exit(r.status ?? 0);
32
+ }
33
+
34
+ if (choice === "2") {
35
+ // ALREADY A UNIVERSE? Then this is a re-run, not a conflict. The first create
36
+ // scaffolds the files; if setup was interrupted (Ctrl-C at any question), running
37
+ // create again used to refuse with "this folder is not empty" — which reads as
38
+ // broken when the folder holds exactly what create itself put there. A universe is
39
+ // recognized by its compose file; resume setup instead.
40
+ const target = nameArg || ".";
41
+ if (existsSync(`${target}/docker-compose.yml`)) {
42
+ console.log(`\n ${dim(`${target === "." ? "This folder" : `./${target}`} is already a universe. Picking setup back up.`)}\n`);
43
+ rl.close();
44
+ const here2 = dirname(fileURLToPath(import.meta.url));
45
+ const vend = resolve(here2, "../operator/operator.sh");
46
+ const op = existsSync(vend) ? vend : resolve(here2, "../../../scripts/operator.sh");
47
+ const r2 = spawnSync("bash", [op, "init"], { stdio: "inherit", cwd: target });
48
+ process.exit(r2.status ?? 0);
49
+ }
50
+
51
+ // TARGET FIRST, credential second. This used to ask for the registry token, make a
52
+ // network round-trip to validate it, and only then discover the folder was not
53
+ // empty. Never ask for a credential you are about to throw away.
54
+ const name = resolveTarget(nameArg);
55
+
56
+ console.log(`\n ${cyan("A universe needs a registry access token.")}`);
57
+ console.log(` ${dim("It authorizes the platform's images. Your Unoverse admin issues it.")}\n`);
58
+ const useDocker = dockerUp();
59
+ let token;
60
+ // A refused token re-asks RIGHT HERE. Being thrown out of the wizard to run
61
+ // create again is its own bug.
62
+ for (;;) {
63
+ token = parseCredential(await ask(rl, "Registry access token: "));
64
+ if (!token.pass) {
65
+ fail("no token. The universe kit is available to licensed operators");
66
+ process.exit(1);
67
+ }
68
+ process.stdout.write(" validating against the registry...");
69
+ if (useDocker) {
70
+ const res = dockerLogin(token);
71
+ console.log("");
72
+ if (res.ok) { ok("token accepted"); break; }
73
+ fail("the registry refused it:");
74
+ console.log(` ${dim(res.err)}`);
75
+ console.log(` ${dim("Paste the credential exactly as your admin sent it (any shape works), or Ctrl-C to stop.")}\n`);
76
+ } else {
77
+ const valid = await validateRegistryToken(token);
78
+ console.log("");
79
+ if (valid) { ok("token accepted"); break; }
80
+ fail("that token was refused by the registry");
81
+ console.log(` ${dim("Check it with your Unoverse admin, or Ctrl-C to stop.")}\n`);
82
+ }
83
+ }
84
+
85
+ download(name);
86
+ ok(`universe scaffolded in ${label(name)}`);
87
+
88
+ // CREATE FINISHES THE JOB. It used to stop here and tell you to go and run
89
+ // `./unoverse init`, which asked for this same token a second time. The token is
90
+ // already in hand and already validated, so hand it over and configure now.
91
+ rl.close();
92
+ console.log("");
93
+ // This package's own operator, run against the folder just scaffolded.
94
+ const here = dirname(fileURLToPath(import.meta.url));
95
+ const vendored = resolve(here, "../operator/operator.sh");
96
+ const operatorScript = existsSync(vendored)
97
+ ? vendored
98
+ : resolve(here, "../../../scripts/operator.sh");
99
+ // NO authoring skill here, deliberately. Building components, nodes and agent
100
+ // skills is STUDIO work and its skill ships with Studio projects. What a universe
101
+ // offers Claude Code is the canvas MCP (.mcp.json in the scaffold): building and
102
+ // managing workflows on this universe's Canvas.
103
+ const r = spawnSync("bash", [operatorScript, "init"], {
104
+ stdio: "inherit",
105
+ cwd: name,
106
+ env: { ...process.env, UNOVERSE_DOCR_TOKEN: token.pass, UNOVERSE_DOCR_USER: token.user },
107
+ });
108
+ if (r.status !== 0) {
109
+ fail(`setup did not finish. Run 'unoverse start'${name === "." ? "" : ` in ./${name}`} to pick it back up`);
110
+ process.exit(r.status ?? 1);
111
+ }
112
+ console.log(`
113
+ Next:${name === "." ? "" : `
114
+ cd ${name}`}
115
+ unoverse start
116
+
117
+ ${cyan("Build with Claude:")} open this folder in Claude Code and describe the
118
+ workflow you want. Claude builds and manages workflows live on this
119
+ universe's Canvas.
120
+
121
+ ${dim("Components, nodes and agent skills are built in Studio: unoverse studio")}
122
+ ${dim("How it works: https://github.com/unoverse-platform/docs")}
123
+ `);
124
+ return;
125
+ }
126
+
127
+ if (choice === "3") {
128
+ const name = resolveTarget(nameArg);
129
+ // The reference client ships inside the starter — take just that folder.
130
+ download(name, 3, "'*/packages/client'");
131
+ ok(`client app scaffolded in ${label(name)}`);
132
+ console.log(`\n Next:${name === "." ? "" : `\n cd ${name}`}\n npm install && npm run dev\n\n ${cyan("Build with Claude:")} open this folder in Claude Code and describe what you want, in plain words.\n`);
133
+ return;
134
+ }
135
+
136
+ fail(`unknown choice: ${choice}`);
137
+ process.exit(1);
138
+ } finally {
139
+ rl.close();
140
+ }
141
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which folder gets scaffolded, and whether it is safe to write into.
3
+ */
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { dim, fail } from "../ui.mjs";
6
+
7
+ /**
8
+ * Where to scaffold. NO NAME MEANS THE FOLDER YOU ARE STANDING IN.
9
+ *
10
+ * That is what a Studio project has always done (option 1 hands the cwd to Studio), so
11
+ * the other two paths agreeing removes a difference nobody could have guessed. Passing a
12
+ * name still makes a subfolder: `unoverse create acme` → ./acme.
13
+ *
14
+ * The old guard refused any target that EXISTED, which made "." impossible — the folder
15
+ * you are in always exists, so it failed with "./. already exists". Existence was never
16
+ * the question; whether there is anything to overwrite is. `.git` and `.DS_Store` do not
17
+ * count, so scaffolding into a folder you just `git init`ed works.
18
+ */
19
+ export function resolveTarget(nameArg) {
20
+ const dir = nameArg || ".";
21
+ const here = dir === ".";
22
+ // DOTFILES DO NOT COUNT. A folder holding only .git, .claude, .vscode or .DS_Store is
23
+ // empty to the person standing in it: that is tool and editor config they had before
24
+ // they decided to build anything. Naming a few exceptions was not enough — a fresh
25
+ // folder opened in an editor already has .claude/ in it, and refusing that reads as
26
+ // the tool being broken.
27
+ const existing = existsSync(dir)
28
+ ? readdirSync(dir).filter((f) => !f.startsWith("."))
29
+ : [];
30
+ if (existing.length) {
31
+ fail(here ? "this folder is not empty" : `./${dir} is not empty`);
32
+ console.log(` ${dim("Scaffolding would overwrite what is here. Use an empty folder, or pass a name.")}`);
33
+ process.exit(1);
34
+ }
35
+ return dir;
36
+ }
37
+
38
+ /** Human name for the target, for messages that should not say "./.". */
39
+ export const label = (dir) => (dir === "." ? "this folder" : `./${dir}`);
40
+
package/lib/ui.mjs ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Terminal dressing, in one place.
3
+ *
4
+ * Colour helpers and the arrow-key list. They lived inside create.mjs, and urls.mjs kept
5
+ * a second copy of the colours; anything that asks a question wants the same list, so it
6
+ * lives beside them rather than inside whichever command needed it first.
7
+ */
8
+
9
+ const COLOR = !process.env.NO_COLOR;
10
+ const paint = (c) => (s) => (COLOR ? `\x1b[${c}m${s}\x1b[0m` : s);
11
+ export const bold = paint("1");
12
+ export const dim = paint("2");
13
+ export const cyan = paint("36");
14
+ export const green = paint("32");
15
+ export const red = paint("31");
16
+ export const ok = (s) => console.log(` ${green("✓")} ${s}`);
17
+ export const fail = (s) => console.log(` ${red("✗")} ${s}`);
18
+
19
+ /** One question, trimmed. */
20
+ export async function ask(rl, q) {
21
+ return (await rl.question(` ${q}`)).trim();
22
+ }
23
+
24
+ export const OPTIONS = [
25
+ ["Studio", "Components, agents and workflows", "Most people start here"],
26
+ ["Universe", "Run the platform yourself, on your own infrastructure"],
27
+ ["Client", "A client accelerator that talks to unoverse"],
28
+ ];
29
+ const NAME_W = Math.max(...OPTIONS.map(([n]) => n.length)) + 3;
30
+ // Lines the menu occupies, so a redraw knows how far up to go.
31
+ const MENU_LINES = OPTIONS.reduce((n, [, , note]) => n + (note ? 2 : 1), 0);
32
+
33
+ function renderMenu(active) {
34
+ OPTIONS.forEach(([name, desc, note], i) => {
35
+ const on = i === active;
36
+ process.stdout.write(
37
+ `\x1b[2K ${on ? cyan("❯") : " "} ${i + 1} ${on ? bold(name) : name}` +
38
+ `${" ".repeat(NAME_W - name.length)}${on ? desc : dim(desc)}\n`,
39
+ );
40
+ if (note) process.stdout.write(`\x1b[2K ${" ".repeat(NAME_W + 1)}${dim(note)}\n`);
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Pick one, with the arrow keys.
46
+ *
47
+ * NO DEPENDENCY. Raw stdin is all a list picker needs, and `packages/cli` is deliberately
48
+ * zero-dependency (it is the first thing anyone installs). Up/down move, a digit jumps,
49
+ * Enter takes it, Ctrl-C leaves without doing anything.
50
+ *
51
+ * NOT A TTY means something is piping input, so fall back to reading a line. A picker
52
+ * that only works interactively would break every scripted install.
53
+ */
54
+ export function select() {
55
+ if (!process.stdin.isTTY) {
56
+ renderMenu(0);
57
+ process.stdout.write("\n Choose 1, 2 or 3 (Enter for 1): ");
58
+ return new Promise((resolve) => {
59
+ let buf = "";
60
+ process.stdin.on("data", (d) => {
61
+ buf += d;
62
+ if (buf.includes("\n")) {
63
+ const n = parseInt(buf.trim(), 10);
64
+ resolve(Number.isInteger(n) && n >= 1 && n <= OPTIONS.length ? n - 1 : 0);
65
+ }
66
+ });
67
+ process.stdin.on("end", () => resolve(0));
68
+ });
69
+ }
70
+
71
+ return new Promise((resolve) => {
72
+ let active = 0;
73
+ process.stdout.write("\x1b[?25l"); // hide cursor while the list is live
74
+ renderMenu(active);
75
+ process.stdout.write(`\n ${dim("↑↓ to move, Enter to choose")}`);
76
+
77
+ const redraw = () => {
78
+ process.stdout.write(`\x1b[${MENU_LINES + 1}A\r`);
79
+ renderMenu(active);
80
+ process.stdout.write(`\n ${dim("↑↓ to move, Enter to choose")}`);
81
+ };
82
+
83
+ process.stdin.setRawMode(true);
84
+ process.stdin.resume();
85
+ // PARSE the chunk, do not compare it. Keystrokes batch: hold a key down, or paste,
86
+ // and "\x1b[B\x1b[B\r" arrives as ONE data event. Comparing the whole chunk to a
87
+ // single key silently ignores every one of them.
88
+ const onKey = (buf) => {
89
+ const k = buf.toString();
90
+ let moved = false;
91
+ for (let i = 0; i < k.length; i++) {
92
+ if (k[i] === "\u0003") { done(); process.stdout.write("\n"); process.exit(130); }
93
+ if (k[i] === "\x1b" && k[i + 1] === "[" && (k[i + 2] === "A" || k[i + 2] === "B")) {
94
+ const step = k[i + 2] === "A" ? -1 : 1;
95
+ active = (active + step + OPTIONS.length) % OPTIONS.length;
96
+ moved = true;
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if (k[i] === "\r" || k[i] === "\n") {
101
+ if (moved) redraw();
102
+ done();
103
+ process.stdout.write("\n\n");
104
+ return resolve(active);
105
+ }
106
+ if (/[1-9]/.test(k[i]) && +k[i] <= OPTIONS.length) { active = +k[i] - 1; moved = true; }
107
+ }
108
+ if (moved) redraw();
109
+ };
110
+ const done = () => {
111
+ process.stdin.setRawMode(false);
112
+ process.stdin.pause();
113
+ process.stdin.off("data", onKey);
114
+ process.stdout.write("\x1b[?25h"); // cursor back
115
+ };
116
+ process.stdin.on("data", onKey);
117
+ });
118
+ }
119
+
package/lib/urls.mjs CHANGED
@@ -40,13 +40,7 @@ import { existsSync, readFileSync } from "node:fs";
40
40
  import { join, resolve, dirname } from "node:path";
41
41
  import { spawnSync } from "node:child_process";
42
42
 
43
- const COLOR = !process.env.NO_COLOR;
44
- const paint = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
45
- const bold = paint("1");
46
- const dim = paint("2");
47
- const cyan = paint("36");
48
- const green = paint("32");
49
- const red = paint("31");
43
+ import { bold, dim, cyan, green, red } from "./ui.mjs";
50
44
 
51
45
  /** Walk up from cwd to the folder holding .env.production (a universe checkout). */
52
46
  function findUniverseRoot() {