unoverse 0.1.69 → 0.1.71

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/lib/create.mjs DELETED
@@ -1,369 +0,0 @@
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. The
5
- * universe (operator kit) is the professional tier and is GATED: it demands a
6
- * valid registry access token BEFORE anything downloads, because the kit is
7
- * useless without one — the platform's images only pull with it. Validation is
8
- * a real auth round-trip against the registry, not a format check.
9
- */
10
- import { createInterface } from "node:readline/promises";
11
- import { dirname, resolve } from "node:path";
12
- import { fileURLToPath } from "node:url";
13
- import { existsSync, mkdirSync, readdirSync } from "node:fs";
14
- import { spawnSync } from "node:child_process";
15
-
16
- const REGISTRY_HOST = "registry.digitalocean.com";
17
- const STARTER_TARBALL =
18
- "https://codeload.github.com/unoverse-platform/starter/tar.gz/refs/heads/main";
19
-
20
- const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
21
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
22
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
23
- const ok = (s) => console.log(` \x1b[32m✓\x1b[0m ${s}`);
24
- const fail = (s) => console.log(` \x1b[31m✗\x1b[0m ${s}`);
25
-
26
- async function ask(rl, q) {
27
- return (await rl.question(` ${q}`)).trim();
28
- }
29
-
30
- /** Operators hold the credential in two shapes: the raw dop_v1 API token, and the
31
- * pre-encoded base64 `user:token` blob that docker config (and the starter's own
32
- * `docr` setting) uses. Wrapping the second shape in base64 AGAIN is how a valid
33
- * credential gets refused, so detect it and pass it through untouched. */
34
- /** The raw dop_v1 token, whichever shape was pasted. docker login needs the raw form,
35
- * so the base64 user:token blob is decoded before anything downstream sees it. */
36
- /** The credential AS A PAIR. The downloaded Docker credentials file carries
37
- * base64("email:token") — the username is the ACCOUNT EMAIL, and logging in with the
38
- * token as username (right for API tokens) gets "unauthorized" for it. So nothing here
39
- * reduces to a bare token any more: whatever shape is pasted, the exact user:password
40
- * pair comes out and is used verbatim.
41
- * raw dop_v1 → token:token
42
- * base64("u:p") → u:p (email:token, or :token → token:token)
43
- * the whole JSON file → its auth value, decoded the same way
44
- */
45
- function parseCredential(input) {
46
- const t = input.trim().replace(/^["']|["']$/g, "");
47
- const pair = (d) => {
48
- const m = /^([\x20-\x7e]*):([\x20-\x7e]+)$/.exec(d);
49
- return m ? { user: m[1] || m[2], pass: m[2] } : null;
50
- };
51
- const auth = /"auth"\s*:\s*"([A-Za-z0-9+/=]+)"/.exec(t);
52
- if (auth) {
53
- const p = pair(Buffer.from(auth[1], "base64").toString("utf8"));
54
- if (p) return p;
55
- }
56
- if (/^[A-Za-z0-9+/]+=*$/.test(t)) {
57
- const p = pair(Buffer.from(t, "base64").toString("utf8"));
58
- if (p) return p;
59
- }
60
- const inline = /dop_v1_[0-9a-f]{64}/.exec(t);
61
- if (inline) return { user: inline[0], pass: inline[0] };
62
- return { user: t, pass: t };
63
- }
64
-
65
-
66
- function registryAuth(cred) {
67
- return Buffer.from(`${cred.user}:${cred.pass}`).toString("base64");
68
- }
69
-
70
- /** The gate: prove the token opens the registry before handing over the kit.
71
- * Docker registries use the bearer-realm flow: /v2/ answers 401 and names the
72
- * auth realm; presenting the token there yields 200 (valid) or 401 (refused). */
73
- async function validateRegistryToken(cred) {
74
- try {
75
- const probe = await fetch(`https://${REGISTRY_HOST}/v2/`);
76
- const wa = probe.headers.get("www-authenticate") ?? "";
77
- const realm = /realm="([^"]+)"/.exec(wa)?.[1];
78
- const service = /service="([^"]+)"/.exec(wa)?.[1] ?? REGISTRY_HOST;
79
- if (!realm) return probe.status === 200;
80
- const res = await fetch(
81
- `${realm}?service=${encodeURIComponent(service)}&scope=registry:catalog:*`,
82
- { headers: { Authorization: `Basic ${registryAuth(cred)}` } },
83
- );
84
- return res.ok;
85
- } catch {
86
- return false;
87
- }
88
- }
89
-
90
- /**
91
- * Where to scaffold. NO NAME MEANS THE FOLDER YOU ARE STANDING IN.
92
- *
93
- * That is what a Studio project has always done (option 1 hands the cwd to Studio), so
94
- * the other two paths agreeing removes a difference nobody could have guessed. Passing a
95
- * name still makes a subfolder: `unoverse create acme` → ./acme.
96
- *
97
- * The old guard refused any target that EXISTED, which made "." impossible — the folder
98
- * you are in always exists, so it failed with "./. already exists". Existence was never
99
- * the question; whether there is anything to overwrite is. `.git` and `.DS_Store` do not
100
- * count, so scaffolding into a folder you just `git init`ed works.
101
- */
102
- function resolveTarget(nameArg) {
103
- const dir = nameArg || ".";
104
- const here = dir === ".";
105
- // DOTFILES DO NOT COUNT. A folder holding only .git, .claude, .vscode or .DS_Store is
106
- // empty to the person standing in it: that is tool and editor config they had before
107
- // they decided to build anything. Naming a few exceptions was not enough — a fresh
108
- // folder opened in an editor already has .claude/ in it, and refusing that reads as
109
- // the tool being broken.
110
- const existing = existsSync(dir)
111
- ? readdirSync(dir).filter((f) => !f.startsWith("."))
112
- : [];
113
- if (existing.length) {
114
- fail(here ? "this folder is not empty" : `./${dir} is not empty`);
115
- console.log(` ${dim("Scaffolding would overwrite what is here. Use an empty folder, or pass a name.")}`);
116
- process.exit(1);
117
- }
118
- return dir;
119
- }
120
-
121
- /** Human name for the target, for messages that should not say "./.". */
122
- const label = (dir) => (dir === "." ? "this folder" : `./${dir}`);
123
-
124
- function dockerUp() {
125
- return spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
126
- }
127
-
128
- /** The ONLY honest validation is the operation itself. create used to probe the auth
129
- * realm with its own HTTP call and say "token accepted", then docker login failed on
130
- * the same credential two minutes later — the tool approving a token with a weaker
131
- * check than the one that counts. When Docker is up, validation IS docker login (and
132
- * the credential lands in the keychain, so nothing downstream logs in again). */
133
- function dockerLogin(cred) {
134
- const r = spawnSync("docker", ["login", REGISTRY_HOST, "-u", cred.user, "--password-stdin"], {
135
- input: cred.pass,
136
- encoding: "utf8",
137
- });
138
- const err = (r.stderr || "").split("\n").filter((l) => l && !/^WARNING/i.test(l)).pop() || "";
139
- return { ok: r.status === 0, err };
140
- }
141
-
142
- function download(dir, stripComponents = 1, filter = "") {
143
- mkdirSync(dir, { recursive: true });
144
- const r = spawnSync(
145
- "sh",
146
- ["-c", `curl -fsSL '${STARTER_TARBALL}' | tar -xz --strip-components=${stripComponents} -C '${dir}' ${filter}`],
147
- { stdio: ["ignore", "inherit", "inherit"] },
148
- );
149
- if (r.status !== 0) throw new Error("download failed");
150
- }
151
-
152
- const OPTIONS = [
153
- ["Studio", "Components, agents and workflows", "Most people start here"],
154
- ["Universe", "Run the platform yourself, on your own infrastructure"],
155
- ["Client", "A client accelerator that talks to unoverse"],
156
- ];
157
- const NAME_W = Math.max(...OPTIONS.map(([n]) => n.length)) + 3;
158
- // Lines the menu occupies, so a redraw knows how far up to go.
159
- const MENU_LINES = OPTIONS.reduce((n, [, , note]) => n + (note ? 2 : 1), 0);
160
-
161
- function renderMenu(active) {
162
- OPTIONS.forEach(([name, desc, note], i) => {
163
- const on = i === active;
164
- process.stdout.write(
165
- `\x1b[2K ${on ? cyan("❯") : " "} ${i + 1} ${on ? bold(name) : name}` +
166
- `${" ".repeat(NAME_W - name.length)}${on ? desc : dim(desc)}\n`,
167
- );
168
- if (note) process.stdout.write(`\x1b[2K ${" ".repeat(NAME_W + 1)}${dim(note)}\n`);
169
- });
170
- }
171
-
172
- /**
173
- * Pick one, with the arrow keys.
174
- *
175
- * NO DEPENDENCY. Raw stdin is all a list picker needs, and `packages/cli` is deliberately
176
- * zero-dependency (it is the first thing anyone installs). Up/down move, a digit jumps,
177
- * Enter takes it, Ctrl-C leaves without doing anything.
178
- *
179
- * NOT A TTY means something is piping input, so fall back to reading a line. A picker
180
- * that only works interactively would break every scripted install.
181
- */
182
- function select() {
183
- if (!process.stdin.isTTY) {
184
- renderMenu(0);
185
- process.stdout.write("\n Choose 1, 2 or 3 (Enter for 1): ");
186
- return new Promise((resolve) => {
187
- let buf = "";
188
- process.stdin.on("data", (d) => {
189
- buf += d;
190
- if (buf.includes("\n")) {
191
- const n = parseInt(buf.trim(), 10);
192
- resolve(Number.isInteger(n) && n >= 1 && n <= OPTIONS.length ? n - 1 : 0);
193
- }
194
- });
195
- process.stdin.on("end", () => resolve(0));
196
- });
197
- }
198
-
199
- return new Promise((resolve) => {
200
- let active = 0;
201
- process.stdout.write("\x1b[?25l"); // hide cursor while the list is live
202
- renderMenu(active);
203
- process.stdout.write(`\n ${dim("↑↓ to move, Enter to choose")}`);
204
-
205
- const redraw = () => {
206
- process.stdout.write(`\x1b[${MENU_LINES + 1}A\r`);
207
- renderMenu(active);
208
- process.stdout.write(`\n ${dim("↑↓ to move, Enter to choose")}`);
209
- };
210
-
211
- process.stdin.setRawMode(true);
212
- process.stdin.resume();
213
- // PARSE the chunk, do not compare it. Keystrokes batch: hold a key down, or paste,
214
- // and "\x1b[B\x1b[B\r" arrives as ONE data event. Comparing the whole chunk to a
215
- // single key silently ignores every one of them.
216
- const onKey = (buf) => {
217
- const k = buf.toString();
218
- let moved = false;
219
- for (let i = 0; i < k.length; i++) {
220
- if (k[i] === "\u0003") { done(); process.stdout.write("\n"); process.exit(130); }
221
- if (k[i] === "\x1b" && k[i + 1] === "[" && (k[i + 2] === "A" || k[i + 2] === "B")) {
222
- const step = k[i + 2] === "A" ? -1 : 1;
223
- active = (active + step + OPTIONS.length) % OPTIONS.length;
224
- moved = true;
225
- i += 2;
226
- continue;
227
- }
228
- if (k[i] === "\r" || k[i] === "\n") {
229
- if (moved) redraw();
230
- done();
231
- process.stdout.write("\n\n");
232
- return resolve(active);
233
- }
234
- if (/[1-9]/.test(k[i]) && +k[i] <= OPTIONS.length) { active = +k[i] - 1; moved = true; }
235
- }
236
- if (moved) redraw();
237
- };
238
- const done = () => {
239
- process.stdin.setRawMode(false);
240
- process.stdin.pause();
241
- process.stdin.off("data", onKey);
242
- process.stdout.write("\x1b[?25h"); // cursor back
243
- };
244
- process.stdin.on("data", onKey);
245
- });
246
- }
247
-
248
- export async function create(nameArg) {
249
- console.log(`\n ${cyan("⬡ What are you building?")}\n`);
250
- const choice = String((await select()) + 1);
251
-
252
- const rl = createInterface({ input: process.stdin, output: process.stdout });
253
- try {
254
-
255
- if (choice === "1") {
256
- console.log(`\n ${dim("Launching Unoverse Studio. It creates and manages your projects.")}\n`);
257
- rl.close();
258
- const r = spawnSync("npx", ["-y", "@unoverse-platform/studio@latest"], { stdio: "inherit" });
259
- process.exit(r.status ?? 0);
260
- }
261
-
262
- if (choice === "2") {
263
- // ALREADY A UNIVERSE? Then this is a re-run, not a conflict. The first create
264
- // scaffolds the files; if setup was interrupted (Ctrl-C at any question), running
265
- // create again used to refuse with "this folder is not empty" — which reads as
266
- // broken when the folder holds exactly what create itself put there. A universe is
267
- // recognized by its compose file; resume setup instead.
268
- const target = nameArg || ".";
269
- if (existsSync(`${target}/docker-compose.yml`)) {
270
- console.log(`\n ${dim(`${target === "." ? "This folder" : `./${target}`} is already a universe. Picking setup back up.`)}\n`);
271
- rl.close();
272
- const here2 = dirname(fileURLToPath(import.meta.url));
273
- const vend = resolve(here2, "../operator/operator.sh");
274
- const op = existsSync(vend) ? vend : resolve(here2, "../../../scripts/operator.sh");
275
- const r2 = spawnSync("bash", [op, "init"], { stdio: "inherit", cwd: target });
276
- process.exit(r2.status ?? 0);
277
- }
278
-
279
- // TARGET FIRST, credential second. This used to ask for the registry token, make a
280
- // network round-trip to validate it, and only then discover the folder was not
281
- // empty. Never ask for a credential you are about to throw away.
282
- const name = resolveTarget(nameArg);
283
-
284
- console.log(`\n ${cyan("A universe needs a registry access token.")}`);
285
- console.log(` ${dim("It authorizes the platform's images. Your Unoverse admin issues it.")}\n`);
286
- const useDocker = dockerUp();
287
- let token;
288
- // A refused token re-asks RIGHT HERE. Being thrown out of the wizard to run
289
- // create again is its own bug.
290
- for (;;) {
291
- token = parseCredential(await ask(rl, "Registry access token: "));
292
- if (!token.pass) {
293
- fail("no token. The universe kit is available to licensed operators");
294
- process.exit(1);
295
- }
296
- process.stdout.write(" validating against the registry...");
297
- if (useDocker) {
298
- const res = dockerLogin(token);
299
- console.log("");
300
- if (res.ok) { ok("token accepted"); break; }
301
- fail("the registry refused it:");
302
- console.log(` ${dim(res.err)}`);
303
- console.log(` ${dim("Paste the credential exactly as your admin sent it (any shape works), or Ctrl-C to stop.")}\n`);
304
- } else {
305
- const valid = await validateRegistryToken(token);
306
- console.log("");
307
- if (valid) { ok("token accepted"); break; }
308
- fail("that token was refused by the registry");
309
- console.log(` ${dim("Check it with your Unoverse admin, or Ctrl-C to stop.")}\n`);
310
- }
311
- }
312
-
313
- download(name);
314
- ok(`universe scaffolded in ${label(name)}`);
315
-
316
- // CREATE FINISHES THE JOB. It used to stop here and tell you to go and run
317
- // `./unoverse init`, which asked for this same token a second time. The token is
318
- // already in hand and already validated, so hand it over and configure now.
319
- rl.close();
320
- console.log("");
321
- // This package's own operator, run against the folder just scaffolded.
322
- const here = dirname(fileURLToPath(import.meta.url));
323
- const vendored = resolve(here, "../operator/operator.sh");
324
- const operatorScript = existsSync(vendored)
325
- ? vendored
326
- : resolve(here, "../../../scripts/operator.sh");
327
- // NO authoring skill here, deliberately. Building components, nodes and agent
328
- // skills is STUDIO work and its skill ships with Studio projects. What a universe
329
- // offers Claude Code is the canvas MCP (.mcp.json in the scaffold): building and
330
- // managing workflows on this universe's Canvas.
331
- const r = spawnSync("bash", [operatorScript, "init"], {
332
- stdio: "inherit",
333
- cwd: name,
334
- env: { ...process.env, UNOVERSE_DOCR_TOKEN: token.pass, UNOVERSE_DOCR_USER: token.user },
335
- });
336
- if (r.status !== 0) {
337
- fail(`setup did not finish. Run 'unoverse start'${name === "." ? "" : ` in ./${name}`} to pick it back up`);
338
- process.exit(r.status ?? 1);
339
- }
340
- console.log(`
341
- Next:${name === "." ? "" : `
342
- cd ${name}`}
343
- unoverse start
344
-
345
- ${cyan("Build with Claude:")} open this folder in Claude Code and describe the
346
- workflow you want. Claude builds and manages workflows live on this
347
- universe's Canvas.
348
-
349
- ${dim("Components, nodes and agent skills are built in Studio: unoverse studio")}
350
- ${dim("How it works: https://github.com/unoverse-platform/docs")}
351
- `);
352
- return;
353
- }
354
-
355
- if (choice === "3") {
356
- const name = resolveTarget(nameArg);
357
- // The reference client ships inside the starter — take just that folder.
358
- download(name, 3, "'*/packages/client'");
359
- ok(`client app scaffolded in ${label(name)}`);
360
- 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`);
361
- return;
362
- }
363
-
364
- fail(`unknown choice: ${choice}`);
365
- process.exit(1);
366
- } finally {
367
- rl.close();
368
- }
369
- }