unoverse 0.1.62 → 0.1.64

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.
@@ -1,6 +1,54 @@
1
1
  #!/usr/bin/env bash
2
2
  # unoverse deploy — deploy to production VM from local
3
3
 
4
+ # ── Provisioning, spoken plainly ─────────────────────────────────────────────
5
+ #
6
+ # `terraform apply` shows ~200 lines of attributes and asks for a typed "yes". That is
7
+ # the right ceremony for an infrastructure engineer and the wrong first impression for a
8
+ # developer, who needs to know what will exist and what it costs. So: plan into a file,
9
+ # summarise it (tfsummary.mjs), take ONE clear answer, apply that exact plan.
10
+ #
11
+ # Nothing is softened. Destroys are shouted, the full technical plan is one keystroke
12
+ # away, and the SAVED plan is what runs, so what was approved is what happens. Identical
13
+ # for every cloud: the summary reads the plan JSON, which is provider-agnostic.
14
+ _ground_apply() {
15
+ local cloud="$1" dir="$ROOT/infra/$cloud" tmp planfile rc
16
+ tmp=$(mktemp -d); planfile="$tmp/plan"
17
+
18
+ terraform -chdir="$dir" init -input=false >/dev/null 2>&1 || { fail "terraform init failed"; rm -rf "$tmp"; return 1; }
19
+
20
+ info "Working out what needs to change..."
21
+ terraform -chdir="$dir" plan -input=false -detailed-exitcode -out="$planfile" >"$tmp/plan.log" 2>&1
22
+ rc=$?
23
+ case "$rc" in
24
+ 0) rm -rf "$tmp"; return 3 ;;
25
+ 2) : ;;
26
+ *) fail "Terraform could not plan the change:"; tail -20 "$tmp/plan.log" | sed 's/^/ /'; rm -rf "$tmp"; return 1 ;;
27
+ esac
28
+
29
+ if ! terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"; then
30
+ terraform -chdir="$dir" show "$planfile"
31
+ fi
32
+
33
+ local REPLY
34
+ read -r -p " Go ahead? [y/N] ${DIM}(d for the full technical plan)${NC} " REPLY
35
+ if [[ "$REPLY" =~ ^[Dd]$ ]]; then
36
+ terraform -chdir="$dir" show "$planfile"
37
+ read -r -p " Go ahead? [y/N] " REPLY
38
+ fi
39
+ if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
40
+ info "Nothing was created. Run ${BOLD}unoverse deploy${NC} when you are ready"
41
+ rm -rf "$tmp"; return 1
42
+ fi
43
+
44
+ echo ""
45
+ info "Building. Managed databases take a few minutes on the provider's side"
46
+ echo ""
47
+ terraform -chdir="$dir" apply -input=false "$planfile" || { fail "The build did not finish. Re-run: unoverse deploy"; rm -rf "$tmp"; return 1; }
48
+ rm -rf "$tmp"
49
+ return 0
50
+ }
51
+
4
52
  cmd_deploy() {
5
53
 
6
54
  local env_prod="$ROOT/.env.production"
@@ -28,12 +76,11 @@ cmd_deploy() {
28
76
  local g rc
29
77
  for g in digitalocean aws; do
30
78
  [ -d "$ROOT/infra/$g/.terraform" ] || continue
31
- terraform -chdir="$ROOT/infra/$g" plan -input=false -detailed-exitcode >/dev/null 2>&1
32
- rc=$?
33
- if [ "$rc" = "2" ]; then
34
- info "Your $g infrastructure has pending changes"
35
- terraform -chdir="$ROOT/infra/$g" apply || { fail "apply did not complete. Re-run: unoverse deploy"; exit 1; }
36
- terraform -chdir="$ROOT/infra/$g" output -raw env_production > "$env_prod" 2>/dev/null && ok "Refreshed .env.production from the ground"
79
+ _ground_apply "$g"; rc=$?
80
+ [ "$rc" = "1" ] && exit 1
81
+ if [ "$rc" = "0" ]; then
82
+ terraform -chdir="$ROOT/infra/$g" output -raw env_production > "$env_prod" 2>/dev/null \
83
+ && ok "Refreshed .env.production from the ground"
37
84
  g_applied=1
38
85
  fi
39
86
  break
@@ -105,6 +152,32 @@ cmd_deploy() {
105
152
  fi
106
153
  }
107
154
  echo ""
155
+ # POSTGRES: ASK, DO NOT ASSUME. The ground discovers an existing cluster and writes
156
+ # it as a COMMENTED line, so the default silently provisions a SECOND database next
157
+ # to one the account already pays for. A choice with a monthly bill attached is a
158
+ # question, not a line to notice in a file.
159
+ if [ "$cloud" = "digitalocean" ] \
160
+ && grep -q '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null \
161
+ && ! grep -q '^existing_pg_cluster_name' "$tfv" 2>/dev/null \
162
+ && ! grep -q '^byo_postgres_url' "$tfv" 2>/dev/null; then
163
+ local found
164
+ found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" | sed -E 's/.*"([^"]+)".*/\1/')
165
+ if [ -n "$found" ]; then
166
+ echo ""
167
+ echo -e " ${CYAN}${BOLD}Your account already has a PostgreSQL cluster.${NC}"
168
+ echo -e " ${DIM}$found${NC}"
169
+ echo ""
170
+ pick_option "Reuse it (this universe gets its own database, user and pool inside it)" \
171
+ "Create a new one (~\$15/month, fully separate)"
172
+ if [ "$PICKED" = "0" ]; then
173
+ node -e 'const fs=require("fs");const[f,c]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#\s*(existing_pg_cluster_name\s*=.*)$/m,"$1");fs.writeFileSync(f,s)' "$tfv" "$found"
174
+ ok "Reusing $found"
175
+ else
176
+ ok "A new database will be created"
177
+ fi
178
+ fi
179
+ fi
180
+
108
181
  _tf_fill docr_token DOCR_TOKEN "Registry access token (from your Unoverse admin)"
109
182
  _tf_fill openai_api_key OPENAI_API_KEY "OPENAI_API_KEY (powers the platform's AI)"
110
183
  if grep -q '^auth_issuer[[:space:]]*=[[:space:]]*"FILL_ME"' "$tfv" 2>/dev/null; then
@@ -135,8 +208,6 @@ cmd_deploy() {
135
208
  # for billable infrastructure; a [Y/n] in front of it was asking permission to ask
136
209
  # permission. Answering "no" is terraform's "no".
137
210
  echo ""
138
- info "Provisioning. Terraform shows the plan and asks for your ${BOLD}yes${NC} before creating anything"
139
- echo ""
140
211
  # Terraform is one static binary: install it directly from the official release
141
212
  # rather than sending anyone to brew (whose compile chain can demand Xcode Command
142
213
  # Line Tools updates for a tool that needs no compiling).
@@ -154,8 +225,7 @@ cmd_deploy() {
154
225
  rm -rf "$tf_dir"
155
226
  ok "terraform $(terraform version | head -1 | awk '{print $2}') installed"
156
227
  fi
157
- terraform -chdir="$ROOT/infra/$cloud" init -input=false >/dev/null 2>&1 || { fail "terraform init failed"; exit 1; }
158
- terraform -chdir="$ROOT/infra/$cloud" apply || { fail "apply did not complete. Re-run: unoverse deploy"; exit 1; }
228
+ _ground_apply "$cloud" || exit 1
159
229
  echo ""
160
230
  ok "Infrastructure is up. Shipping the platform onto it..."
161
231
  echo ""
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Terraform plan, in plain English.
4
+ *
5
+ * `terraform plan` prints ~200 lines of every attribute of every resource, most of them
6
+ * "(known after apply)". It is written for someone auditing infrastructure, and it is the
7
+ * first thing a developer deploying a universe ever sees. They need two facts: what will
8
+ * exist afterwards, and what it will cost. Everything else is noise at that moment.
9
+ *
10
+ * Reads `terraform show -json <planfile>` on stdin. Prints the headline resources with
11
+ * their size, counts the plumbing (database users, firewall rules, generated passwords)
12
+ * rather than listing it, and shouts about anything being destroyed.
13
+ *
14
+ * NOT A COST ORACLE. Prices are DigitalOcean's published monthly rates for the sizes this
15
+ * ground uses, so the developer sees an order of magnitude rather than a surprise. They
16
+ * move, so the total is labelled approximate and the real bill is the provider's.
17
+ */
18
+
19
+ const COLOR = !process.env.NO_COLOR;
20
+ const paint = (c) => (s) => (COLOR ? `\x1b[${c}m${s}\x1b[0m` : s);
21
+ const bold = paint("1");
22
+ const dim = paint("2");
23
+ const green = paint("32");
24
+ const red = paint("31");
25
+ const yellow = paint("33");
26
+
27
+ /** Monthly USD for the sizes these grounds use. Absent size → no guess. */
28
+ const PRICE = {
29
+ "s-1vcpu-1gb": 6, "s-1vcpu-2gb": 12, "s-2vcpu-2gb": 18, "s-2vcpu-4gb": 24,
30
+ "s-4vcpu-8gb": 48, "s-4vcpu-16gb-amd": 96, "s-8vcpu-16gb": 96,
31
+ "db-s-1vcpu-1gb": 15, "db-s-1vcpu-2gb": 30, "db-s-2vcpu-4gb": 60,
32
+ lb: 12,
33
+ "t3.large": 60, "t3.xlarge": 120, "db.t3.medium": 50, "cache.t3.micro": 12,
34
+ };
35
+
36
+ /** vCPU/RAM read out of a size slug, when it says so. */
37
+ function spec(size) {
38
+ if (!size) return "";
39
+ const m = /(\d+)vcpu-(\d+)gb/.exec(size);
40
+ return m ? `${m[1]} vCPU, ${m[2]} GB` : size;
41
+ }
42
+
43
+ /**
44
+ * What a resource IS, to a person. Returns null for plumbing: a database user, a
45
+ * connection pool and a generated password are all part of "a PostgreSQL database",
46
+ * and listing them separately turns one decision into eleven.
47
+ */
48
+ function describe(r) {
49
+ const a = r.change?.after ?? {};
50
+ switch (r.type) {
51
+ case "digitalocean_droplet":
52
+ return { what: "Server", detail: `${spec(a.size)} · ${a.region ?? ""}`, price: PRICE[a.size] };
53
+ case "digitalocean_database_cluster":
54
+ return a.engine === "redis"
55
+ ? { what: "Redis cache", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] }
56
+ : { what: "PostgreSQL database", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] };
57
+ case "digitalocean_loadbalancer":
58
+ return { what: "Load balancer", detail: "the public way in", price: PRICE.lb };
59
+ case "digitalocean_firewall":
60
+ return { what: "Firewall", detail: "admin ports limited to your IP" };
61
+ case "digitalocean_certificate":
62
+ return { what: "TLS certificate", detail: "HTTPS for your domain" };
63
+ case "digitalocean_record":
64
+ return { what: "DNS record", detail: a.name ? `${a.name}.${a.domain ?? ""}` : "" };
65
+ case "aws_instance":
66
+ return { what: "Server", detail: a.instance_type ?? "", price: PRICE[a.instance_type] };
67
+ case "aws_db_instance":
68
+ return { what: "PostgreSQL database", detail: a.instance_class ?? "", price: PRICE[a.instance_class] };
69
+ case "aws_elasticache_cluster":
70
+ case "aws_elasticache_replication_group":
71
+ return { what: "Redis cache", detail: a.node_type ?? "", price: PRICE[a.node_type] };
72
+ case "aws_lb":
73
+ return { what: "Load balancer", detail: "the public way in", price: 18 };
74
+ case "aws_cognito_user_pool":
75
+ return { what: "Login (Cognito)", detail: "who may use your universe" };
76
+ case "aws_acm_certificate":
77
+ return { what: "TLS certificate", detail: "HTTPS for your domain" };
78
+ default:
79
+ return null; // plumbing: counted, not listed
80
+ }
81
+ }
82
+
83
+ const ACTION = { create: "Creating", update: "Changing", delete: "Destroying", "replace": "Replacing" };
84
+
85
+ function action(ch) {
86
+ const acts = ch.actions ?? [];
87
+ if (acts.includes("delete") && acts.includes("create")) return "replace";
88
+ if (acts.includes("create")) return "create";
89
+ if (acts.includes("delete")) return "delete";
90
+ if (acts.includes("update")) return "update";
91
+ return null; // no-op / read
92
+ }
93
+
94
+ let raw = "";
95
+ process.stdin.on("data", (d) => (raw += d));
96
+ process.stdin.on("end", () => {
97
+ let plan;
98
+ try {
99
+ plan = JSON.parse(raw);
100
+ } catch {
101
+ process.exit(2); // caller falls back to raw terraform output
102
+ }
103
+
104
+ const groups = { create: [], update: [], delete: [], replace: [] };
105
+ const supporting = { create: 0, update: 0, delete: 0, replace: 0 };
106
+
107
+ for (const r of plan.resource_changes ?? []) {
108
+ const act = action(r.change ?? {});
109
+ if (!act) continue;
110
+ const d = describe(r);
111
+ if (d) groups[act].push(d);
112
+ else supporting[act]++;
113
+ }
114
+
115
+ const total = Object.values(groups).flat().length + Object.values(supporting).reduce((a, b) => a + b, 0);
116
+ const out = [];
117
+ if (total === 0) {
118
+ console.log(`\n ${green("●")} Your infrastructure already matches. Nothing to change.\n`);
119
+ process.exit(0);
120
+ }
121
+
122
+ const width = Math.max(...Object.values(groups).flat().map((d) => d.what.length), 0) + 2;
123
+
124
+ for (const act of ["create", "update", "replace", "delete"]) {
125
+ const list = groups[act];
126
+ const extra = supporting[act];
127
+ if (!list.length && !extra) continue;
128
+ const colour = act === "delete" || act === "replace" ? red : act === "update" ? yellow : green;
129
+ out.push(` ${bold(ACTION[act])}${act === "delete" ? red(" (this removes things that exist)") : ""}`);
130
+ for (const d of list) {
131
+ const price = d.price ? dim(` ~$${d.price}/mo`) : "";
132
+ out.push(` ${colour("●")} ${d.what.padEnd(width)}${dim(d.detail)}${price}`);
133
+ }
134
+ if (extra) out.push(` ${dim(`plus ${extra} supporting ${extra === 1 ? "resource" : "resources"} (users, rules, keys)`)}`);
135
+ out.push("");
136
+ }
137
+
138
+ const monthly = Object.values(groups).flat().reduce((sum, d) => sum + (d.price ?? 0), 0);
139
+ if (monthly) {
140
+ out.push(` ${bold("Roughly")} ${bold(`$${monthly}/month`)} ${dim("at your provider's current prices, billed by them, not by unoverse")}`);
141
+ out.push("");
142
+ }
143
+
144
+ console.log("\n" + out.join("\n"));
145
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
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",