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 +7 -0
- package/lib/create.mjs +13 -2
- package/lib/urls.mjs +115 -0
- package/package.json +3 -3
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 ${
|
|
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.
|
|
4
|
-
"description": "The Unoverse front door
|
|
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
|
+
}
|