unoverse 0.1.1 → 0.1.3

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
@@ -18,6 +18,7 @@ const HELP = `
18
18
 
19
19
  unoverse create [name] What are you building? Studio project · universe · client app
20
20
  unoverse studio Launch Unoverse Studio (authoring: components, nodes, skills)
21
+ unoverse urls Your deployed universe's addresses — probed live, not recited
21
22
  unoverse help This
22
23
 
23
24
  Operating a running universe? That lives in your universe folder:
@@ -29,6 +30,12 @@ switch (cmd) {
29
30
  await create(args[0]);
30
31
  break;
31
32
 
33
+ case "urls": {
34
+ const { urls } = await import("../lib/urls.mjs");
35
+ await urls();
36
+ break;
37
+ }
38
+
32
39
  case "studio": {
33
40
  // Studio is its own npm app; this is a launcher, not a wrapper.
34
41
  const r = spawnSync("npx", ["-y", "@unoverse-platform/studio@latest", ...args], {
package/lib/create.mjs CHANGED
@@ -24,6 +24,18 @@ async function ask(rl, q) {
24
24
  return (await rl.question(` ${q}`)).trim();
25
25
  }
26
26
 
27
+ /** Operators hold the credential in two shapes: the raw dop_v1 API token, and the
28
+ * pre-encoded base64 `user:token` blob that docker config (and the starter's own
29
+ * `docr` setting) uses. Wrapping the second shape in base64 AGAIN is how a valid
30
+ * credential gets refused, so detect it and pass it through untouched. */
31
+ function registryAuth(token) {
32
+ if (/^[A-Za-z0-9+/]+=*$/.test(token)) {
33
+ const decoded = Buffer.from(token, "base64").toString("utf8");
34
+ if (/^[\x20-\x7e]+:[\x20-\x7e]+$/.test(decoded)) return token;
35
+ }
36
+ return Buffer.from(`${token}:${token}`).toString("base64");
37
+ }
38
+
27
39
  /** The gate: prove the token opens the registry before handing over the kit.
28
40
  * Docker registries use the bearer-realm flow: /v2/ answers 401 and names the
29
41
  * auth realm; presenting the token there yields 200 (valid) or 401 (refused). */
@@ -34,10 +46,9 @@ async function validateRegistryToken(token) {
34
46
  const realm = /realm="([^"]+)"/.exec(wa)?.[1];
35
47
  const service = /service="([^"]+)"/.exec(wa)?.[1] ?? REGISTRY_HOST;
36
48
  if (!realm) return probe.status === 200;
37
- const auth = Buffer.from(`${token}:${token}`).toString("base64");
38
49
  const res = await fetch(
39
50
  `${realm}?service=${encodeURIComponent(service)}&scope=registry:catalog:*`,
40
- { headers: { Authorization: `Basic ${auth}` } },
51
+ { headers: { Authorization: `Basic ${registryAuth(token)}` } },
41
52
  );
42
53
  return res.ok;
43
54
  } catch {
package/lib/urls.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `unoverse urls` — the deployed stack's addresses, PROBED, not recited.
3
+ *
4
+ * Run it in a universe folder (or anywhere above one). It reads the deployment's
5
+ * own facts — .env.production first, the ground's terraform outputs as enrichment —
6
+ * builds every URL a person or client would use, then actually requests each one
7
+ * and reports what answered. A URL you cannot click is a hope, not an address.
8
+ */
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join, resolve, dirname } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ const COLOR = !process.env.NO_COLOR;
14
+ const paint = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
15
+ const bold = paint("1");
16
+ const dim = paint("2");
17
+ const cyan = paint("36");
18
+ const green = paint("32");
19
+ const red = paint("31");
20
+
21
+ /** Walk up from cwd to the folder holding .env.production (a universe checkout). */
22
+ function findUniverseRoot() {
23
+ for (let dir = process.cwd(); ; ) {
24
+ if (existsSync(join(dir, ".env.production"))) return dir;
25
+ const parent = resolve(dir, "..");
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+
31
+ function parseEnv(file) {
32
+ const env = {};
33
+ for (const line of readFileSync(file, "utf8").split("\n")) {
34
+ const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
35
+ if (m) env[m[1]] = m[2];
36
+ }
37
+ return env;
38
+ }
39
+
40
+ /** The ground's terraform outputs, when a state is present. Best-effort. */
41
+ function terraformOutputs(root) {
42
+ for (const ground of ["infra/digitalocean", "infra/aws"]) {
43
+ const dir = join(root, ground);
44
+ if (!existsSync(join(dir, "terraform.tfstate")) && !existsSync(join(dir, ".terraform"))) continue;
45
+ const r = spawnSync("terraform", ["output", "-json"], { cwd: dir, encoding: "utf8" });
46
+ if (r.status === 0) {
47
+ try {
48
+ const out = JSON.parse(r.stdout);
49
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.value]));
50
+ } catch { /* fall through */ }
51
+ }
52
+ }
53
+ return {};
54
+ }
55
+
56
+ async function probe(url) {
57
+ try {
58
+ const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
59
+ return { up: true, status: res.status };
60
+ } catch {
61
+ return { up: false };
62
+ }
63
+ }
64
+
65
+ export async function urls() {
66
+ const root = findUniverseRoot();
67
+ if (!root) {
68
+ console.log(`\n ${red("✗")} no .env.production found here or above — run this inside a universe folder`);
69
+ console.log(` ${dim("(a universe is deployed from its checkout: terraform apply renders .env.production)")}\n`);
70
+ process.exit(1);
71
+ }
72
+
73
+ const env = parseEnv(join(root, ".env.production"));
74
+ const tf = terraformOutputs(root);
75
+ const domain = env.DOMAIN || "";
76
+
77
+ // The same law as the ground: DOMAIN drives HTTPS URLs; without it, the
78
+ // explicit API_URL (rendered by terraform) carries the LB address.
79
+ const apiBase = domain ? `https://api.${domain}` : env.API_URL || tf.api_url || "";
80
+ const candidates = [];
81
+ if (apiBase) {
82
+ candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp" });
83
+ candidates.push({ label: "Health", url: `${apiBase}/health` });
84
+ }
85
+ const canvas = tf.canvas_url && tf.canvas_url.startsWith("http") ? tf.canvas_url : null;
86
+ if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins" });
87
+ if (env.DEPLOY_HOST) candidates.push({ label: "Dozzle", url: `http://${env.DEPLOY_HOST}:8080`, note: "logs, admin IP only" });
88
+ if (tf.cognito_hosted_ui) candidates.push({ label: "Login", url: tf.cognito_hosted_ui });
89
+
90
+ if (!candidates.length) {
91
+ console.log(`\n ${red("✗")} .env.production has neither DOMAIN nor API_URL — re-render it from the ground:`);
92
+ console.log(` ${dim("cd infra/<ground> && terraform output -raw env_production > ../../.env.production")}\n`);
93
+ process.exit(1);
94
+ }
95
+
96
+ const results = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url)) })));
97
+
98
+ const labelWidth = Math.max(...results.map((r) => r.label.length));
99
+ const urlWidth = Math.max(...results.map((r) => r.url.length));
100
+ const upCount = results.filter((r) => r.up).length;
101
+ const rule = dim("─".repeat(Math.min(70, urlWidth + labelWidth + 12)));
102
+ const mark = (r) => (r.up ? green("●") : red("✗"));
103
+
104
+ console.log("");
105
+ console.log(` ${rule}`);
106
+ console.log(` ${upCount === results.length ? green("●") : red("●")} ${bold("universe")} ${dim(`${upCount} of ${results.length} answering${domain ? "" : " · domainless (plain HTTP) — set domain in terraform.tfvars to upgrade"}`)}`);
107
+ console.log("");
108
+ for (const r of results) {
109
+ const status = r.up ? dim(`${r.status}`) : red("no answer");
110
+ console.log(` ${mark(r)} ${dim(r.label.padEnd(labelWidth))} ${cyan(r.url.padEnd(urlWidth))} ${status}${r.note ? ` ${dim(r.note)}` : ""}`);
111
+ }
112
+ console.log(` ${rule}`);
113
+ console.log("");
114
+ if (upCount < results.length) process.exit(1);
115
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.1",
4
- "description": "The Unoverse front door \u2014 create a Studio project, a universe, or a client app, and launch Studio.",
3
+ "version": "0.1.3",
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",
7
7
  "bin": {
@@ -20,4 +20,4 @@
20
20
  "type": "git",
21
21
  "url": "git+https://github.com/unoverse-platform/unoverse.git"
22
22
  }
23
- }
23
+ }