unoverse 0.1.73 → 0.1.75

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
@@ -58,7 +58,7 @@ const UNIVERSE = findUniverse();
58
58
  // type it to update the CLI far more often than to refresh a local universe's images,
59
59
  // and that refresh now belongs to `start --pull`.
60
60
  const OPERATOR_COMMANDS = new Set([
61
- "start", "stop", "check", "logs", "deploy",
61
+ "start", "stop", "check", "logs", "deploy", "destroy",
62
62
  // kept working, not advertised
63
63
  "ground", "dev", "build", "publish", "init",
64
64
  ]);
@@ -270,12 +270,18 @@ resource "digitalocean_database_firewall" "pg" {
270
270
  }
271
271
  }
272
272
 
273
- # ── Redis: ALWAYS ours (Managed Redis, TLS, droplet-only). Never BYO — it is
274
- # the shared-state backbone and the active/active future; the stack owns it. ──
273
+ # ── Valkey: ALWAYS ours (managed, TLS, droplet-only). Never BYO — it is the
274
+ # shared-state backbone and the active/active future; the stack owns it.
275
+ #
276
+ # VALKEY, NOT REDIS. DigitalOcean retired the `redis` engine: /v2/databases/options now
277
+ # lists valkey and no redis at all. Asking for a dead engine failed with "region 'lon1'
278
+ # is not valid", because an unknown engine matches no region — an error that sent us
279
+ # looking at the region for as long as it took to ask the API what engines exist.
280
+ # Valkey speaks the Redis protocol, so every client here is unchanged. ──
275
281
  resource "digitalocean_database_cluster" "redis" {
276
282
  name = "${var.name}-redis"
277
- engine = "redis"
278
- version = "7"
283
+ engine = "valkey"
284
+ version = "8"
279
285
  size = local.s.redis
280
286
  region = var.region
281
287
  node_count = 1
@@ -295,3 +301,25 @@ resource "random_password" "credential_key" {
295
301
  length = 44
296
302
  special = false
297
303
  }
304
+
305
+ # ── One project, so a universe looks like one thing ───────────────────────────
306
+ #
307
+ # Without this, a universe's parts scatter through the DigitalOcean default project among
308
+ # everything else the account runs, and "what does this universe own" has no answer you
309
+ # can see. The project is named after the universe, so the console shows the server, the
310
+ # cache and the load balancer as one group.
311
+ #
312
+ # ONLY WHAT THIS STACK OWNS. An adopted Postgres belongs to whoever created it; moving it
313
+ # into this project would quietly reorganise someone else's resource.
314
+ resource "digitalocean_project" "universe" {
315
+ name = var.name
316
+ description = "unoverse universe: ${var.name}"
317
+ purpose = "Web Application"
318
+ environment = var.size == "small" ? "Development" : "Production"
319
+ resources = compact([
320
+ digitalocean_droplet.app.urn,
321
+ digitalocean_loadbalancer.public.urn,
322
+ digitalocean_database_cluster.redis.urn,
323
+ local.provision_pg ? digitalocean_database_cluster.pg[0].urn : "",
324
+ ])
325
+ }
@@ -114,6 +114,26 @@ _ensure_ground_config() {
114
114
  cur_size=$(grep -E '^size[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
115
115
  [ -n "$cur_size" ] && info "Size: ${BOLD}${cur_size}${NC} ${DIM}(small · medium · large — change it in terraform.tfvars and deploy again whenever you outgrow it)${NC}"
116
116
 
117
+ # REGION MUST MATCH, or the platform cannot reach its own database. DigitalOcean's
118
+ # private networking is per-region: the connection string uses the cluster's PRIVATE
119
+ # host, which a droplet in another region cannot resolve. Everything provisions
120
+ # perfectly and then the platform fails to start, which is the worst way to find out.
121
+ local reuse_name ground_region db_region
122
+ reuse_name=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
123
+ ground_region=$(grep -E '^region[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
124
+ if [ -n "$reuse_name" ] && [ -n "$ground_region" ] && command -v doctl >/dev/null 2>&1; then
125
+ db_region=$(doctl databases list --format Name,Region --no-header 2>/dev/null | awk -v n="$reuse_name" '$1==n{print $2}')
126
+ if [ -n "$db_region" ] && [ "$db_region" != "$ground_region" ]; then
127
+ echo ""
128
+ fail "Region mismatch: this universe is in ${BOLD}$ground_region${NC}, ${BOLD}$reuse_name${NC} is in ${BOLD}$db_region${NC}"
129
+ info "Private networking does not cross regions, so the platform could not reach it."
130
+ info "Either set ${BOLD}region = \"$db_region\"${NC} in infra/$cloud/terraform.tfvars,"
131
+ info "or comment out existing_pg_cluster_name to create a database in $ground_region."
132
+ echo ""
133
+ return 1
134
+ fi
135
+ fi
136
+
117
137
  # THE DATABASE DECISION, STATED EVERY RUN. It is asked once and then recorded in
118
138
  # terraform.tfvars, so later deploys correctly do not re-ask — but silence reads
119
139
  # exactly like never having been asked. Say what is in force, the same way Size does.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # unoverse destroy — take the whole deployment down, in one command.
3
+ #
4
+ # The counterpart to `deploy`, and the same shape: read the ground, show in plain English
5
+ # what will go, take ONE answer, then do exactly what was shown. It exists because the
6
+ # alternative is remembering `terraform destroy` in the right folder with the right
7
+ # environment, and because half a teardown is worse than none — a load balancer nobody
8
+ # knows about bills forever.
9
+ #
10
+ # THREE THINGS MAKE THIS SAFE.
11
+ # 1. It shows what exists before it asks anything.
12
+ # 2. It names what it will NOT remove: an adopted Postgres is somebody else's cluster,
13
+ # read by this stack and never owned, so it survives. Saying so prevents both a
14
+ # nasty surprise and a false sense that the bill is now zero.
15
+ # 3. Confirmation is the universe's NAME, typed. y/N is muscle memory; a name is a
16
+ # decision, and this deletes data that no backup here can restore.
17
+ #
18
+ # It never touches the developer's terraform.tfvars, their .env, or their code. Only the
19
+ # cloud resources this ground owns.
20
+
21
+ cmd_destroy() {
22
+ local cloud="" g
23
+ for g in digitalocean aws; do
24
+ [ -f "$ROOT/infra/$g/terraform.tfvars" ] && { cloud="$g"; break; }
25
+ done
26
+ if [ -z "$cloud" ]; then
27
+ fail "No ground here. There is nothing deployed to take down"
28
+ return 1
29
+ fi
30
+
31
+ local dir="$ROOT/infra/$cloud"
32
+ if [ ! -d "$dir/.terraform" ]; then
33
+ fail "That ground was never applied. Nothing exists to destroy"
34
+ return 1
35
+ fi
36
+
37
+ _ensure_terraform || return 1
38
+
39
+ local pretty
40
+ [ "$cloud" = "aws" ] && pretty="AWS" || pretty="DigitalOcean"
41
+ echo ""
42
+ echo -e " ${RED}${BOLD}⬡ Taking down your $pretty universe${NC}"
43
+ echo ""
44
+
45
+ local tmp planfile rc
46
+ tmp=$(mktemp -d); planfile="$tmp/plan"
47
+ info "Working out what exists..."
48
+ terraform -chdir="$dir" plan -destroy -input=false -detailed-exitcode -out="$planfile" >"$tmp/log" 2>&1
49
+ rc=$?
50
+ case "$rc" in
51
+ 0) ok "Nothing is deployed. There is nothing to take down"; rm -rf "$tmp"; return 0 ;;
52
+ 2) : ;;
53
+ *) fail "Terraform could not read the deployment:"; tail -20 "$tmp/log" | sed 's/^/ /'; rm -rf "$tmp"; return 1 ;;
54
+ esac
55
+
56
+ if ! terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"; then
57
+ terraform -chdir="$dir" show "$planfile"
58
+ fi
59
+
60
+ # What SURVIVES matters as much as what goes: an adopted cluster is read, never owned.
61
+ local kept
62
+ kept=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
63
+ if [ -n "$kept" ]; then
64
+ info "Staying: ${BOLD}$kept${NC} ${DIM}(you reused it, so this stack never owned it. Its bill continues)${NC}"
65
+ fi
66
+ if grep -qE '^byo_postgres_url[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null; then
67
+ info "Staying: ${BOLD}your own database${NC} ${DIM}(byo_postgres_url — this stack never owned it)${NC}"
68
+ fi
69
+
70
+ local name
71
+ name=$(grep -E '^name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
72
+ name="${name:-universe}"
73
+
74
+ echo ""
75
+ echo -e " ${RED}This deletes the server and its data. It cannot be undone.${NC}"
76
+ echo ""
77
+ local typed
78
+ read -r -p " Type the universe name to confirm ($name): " typed
79
+ if [ "$typed" != "$name" ]; then
80
+ info "Name did not match. Nothing was destroyed"
81
+ rm -rf "$tmp"
82
+ return 1
83
+ fi
84
+
85
+ echo ""
86
+ info "Taking it down..."
87
+ echo ""
88
+ terraform -chdir="$dir" apply -input=false "$planfile" || { fail "Teardown did not finish. Re-run: unoverse destroy"; rm -rf "$tmp"; return 1; }
89
+ rm -rf "$tmp"
90
+
91
+ # .env.production described a server that no longer exists. Leaving it makes the next
92
+ # deploy think there is something to ship to.
93
+ rm -f "$ROOT/.env.production"
94
+
95
+ echo ""
96
+ ok "Your $pretty universe is gone"
97
+ info "Your ground, code and settings are untouched. ${BOLD}unoverse deploy${NC} builds it again"
98
+ echo ""
99
+ }
@@ -23,6 +23,7 @@ cmd_help() {
23
23
  echo -e " ${GREEN}check${NC} Is it healthy ${DIM}(services, schema, environment)${NC}"
24
24
  echo -e " ${GREEN}logs${NC} What is it doing ${DIM}(unoverse logs <service> for one)${NC}"
25
25
  echo -e " ${GREEN}deploy${NC} Ship it to a server ${DIM}(first run asks which cloud)${NC}"
26
+ echo -e " ${GREEN}destroy${NC} Take the deployment down ${DIM}(shows what goes, and what stays)${NC}"
26
27
  echo ""
27
28
  # Owner-only lane. Printed ONLY when publish.sh is present, so a starter kit never
28
29
  # advertises a command it does not have (sync-starter.sh deletes that file).
@@ -52,10 +52,17 @@ function describe(r) {
52
52
  switch (r.type) {
53
53
  case "digitalocean_droplet":
54
54
  return { what: "Server", detail: `${spec(a.size)} · ${a.region ?? ""}`, price: PRICE[a.size] };
55
- case "digitalocean_database_cluster":
56
- return a.engine === "redis"
57
- ? { what: "Redis cache", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] }
58
- : { what: "PostgreSQL database", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] };
55
+ case "digitalocean_database_cluster": {
56
+ // MATCH THE ENGINE, don't assume two. DigitalOcean renamed redis to valkey, and a
57
+ // lone `=== "redis"` test quietly relabelled the cache as "PostgreSQL database"
58
+ // the summary claiming one thing while the plan built another.
59
+ const cache = a.engine === "valkey" || a.engine === "redis";
60
+ return {
61
+ what: cache ? "Cache" : "PostgreSQL database",
62
+ detail: `${spec(a.size)} · ${a.engine ?? ""} v${a.version ?? ""}`,
63
+ price: PRICE[a.size],
64
+ };
65
+ }
59
66
  case "digitalocean_loadbalancer":
60
67
  return { what: "Load balancer", detail: "the public way in", price: PRICE.lb };
61
68
  case "digitalocean_firewall":
@@ -44,6 +44,7 @@ source "$GRAVITY_LIB/help.sh"
44
44
  source "$GRAVITY_LIB/db-setup.sh"
45
45
  source "$GRAVITY_LIB/db-verify.sh"
46
46
  source "$GRAVITY_LIB/deploy.sh"
47
+ source "$GRAVITY_LIB/destroy.sh"
47
48
  source "$GRAVITY_LIB/ground.sh"
48
49
  # Authoring tools (lint, new, node test/hash, studio) moved to _legacy/scripts-lib
49
50
  # 2026-07-28 — authoring lives in Studio now. The CLI is the OPERATOR tool.
@@ -64,6 +65,7 @@ case "${1:-}" in
64
65
  check) cmd_check && cmd_db_verify && cmd_doctor ;;
65
66
  dev) cmd_dev ;;
66
67
  ground) shift; cmd_ground "$@" ;;
68
+ destroy) cmd_destroy ;;
67
69
  refresh-images)
68
70
  # internal: `unoverse update` runs this after updating the CLI. Pull newer images
69
71
  # if the registry has them, and recreate only what is already running — an update
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
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",