unoverse 0.1.72 → 0.1.74

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
+ }
@@ -162,12 +162,15 @@ _ground_apply() {
162
162
 
163
163
  # `read -p` does NOT interpret escapes, so ${DIM} printed as literal \033[2m. Colour
164
164
  # the hint with echo -e first, then take a plain prompt.
165
+ # EXPLICIT YES, and it says so. Everywhere else in this CLI Enter means "go"; here it
166
+ # means "stop", because this is the one prompt that starts a monthly bill. Keeping the
167
+ # safety is right — leaving it as a bare [y/N] among Enter-means-go prompts is not.
165
168
  local REPLY
166
- echo -e " ${DIM}(d shows the full technical plan)${NC}"
167
- read -r -p " Go ahead? [y/N] " REPLY
169
+ echo -e " ${DIM}d shows the full technical plan${NC}"
170
+ read -r -p " Type y to build this, or Enter to stop: " REPLY
168
171
  if [[ "$REPLY" =~ ^[Dd]$ ]]; then
169
172
  terraform -chdir="$dir" show "$planfile"
170
- read -r -p " Go ahead? [y/N] " REPLY
173
+ read -r -p " Type y to build this, or Enter to stop: " REPLY
171
174
  fi
172
175
  if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
173
176
  info "Nothing was created. Run ${BOLD}unoverse deploy${NC} when you are ready"
@@ -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.72",
3
+ "version": "0.1.74",
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",