unoverse 0.1.2 → 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/urls.mjs +115 -0
- package/package.json +1 -1
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/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