unoverse 0.1.88 → 0.1.90

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.
@@ -260,28 +260,68 @@ _ensure_ground_config() {
260
260
  fi
261
261
  fi
262
262
 
263
- # POSTGRES: ASK, DO NOT ASSUME. The ground discovers an existing cluster and writes
264
- # it as a COMMENTED line, so the default silently provisions a SECOND database next
265
- # to one the account already pays for. A choice with a monthly bill attached is a
266
- # question, not a line to notice in a file.
263
+ # PRODUCTION'S DATABASE IS NOT THE ONE IN .env, AND THE QUESTION MUST SAY SO.
264
+ #
265
+ # `.env` holds the DEVELOPMENT database what `npm run dev` talks to on the laptop. The
266
+ # deployed universe gets its own, so a developer cannot break production by experimenting
267
+ # locally. That separation is right and is the default.
268
+ #
269
+ # What was wrong was the words. This asked "You already have a database — use it?", where
270
+ # "it" meant the CLUSTER and "use" meant "create a new database inside it". A developer
271
+ # who had typed a DATABASE_URL during setup read that as "use the database I gave you",
272
+ # answered yes, and got an empty one — then could not find their 21 workflows and
273
+ # reasonably concluded the deploy had lost them. Nothing was lost; the sentence was.
274
+ #
275
+ # So: name the cluster, say a NEW database goes in it, and say what happens to the one in
276
+ # .env. Anyone wanting production to share the development database sets byo_postgres_url
277
+ # deliberately, which is a different and much louder act.
267
278
  if [ "$cloud" = "digitalocean" ] \
268
- && grep -q '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null \
269
279
  && ! grep -q '^existing_pg_cluster_name' "$tfv" 2>/dev/null \
270
280
  && ! grep -q '^byo_postgres_url' "$tfv" 2>/dev/null; then
271
- local found
272
- found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" | sed -E 's/.*"([^"]+)".*/\1/')
273
- if [ -n "$found" ]; then
274
- echo ""
275
- echo -e " ${CYAN}${BOLD}You already have a database.${NC} ${DIM}$found${NC}"
276
- echo ""
277
- pick_option "Use it" "Create a new one ~\$15/month"
278
- if [ "$PICKED" = "0" ]; then
279
- 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"
280
- ok "Reusing $found"
281
- else
282
- ok "A new database will be created"
283
- fi
281
+ local found env_db db_name
282
+ found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
283
+ env_db=$(grep -E '^DATABASE_URL=' "$ROOT/.env" 2>/dev/null | head -1 | cut -d= -f2-)
284
+ db_name=$(echo "$env_db" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
285
+
286
+ echo ""
287
+ echo -e " ${CYAN}${BOLD}Which database should the DEPLOYED universe use?${NC}"
288
+ echo ""
289
+ echo -e " ${DIM}Your .env is your local development database and does not change.${NC}"
290
+ echo ""
291
+ local opt_new_db="" opt_share=""
292
+ [ -n "$found" ] && opt_new_db="Its own new database on ${found} free"
293
+ [ -n "$db_name" ] && opt_share="The same one you develop against ${db_name}"
294
+
295
+ # Order is the recommendation. Its own database first: production and development stay
296
+ # independent, which is what almost everyone wants and what nobody regrets.
297
+ if [ -n "$opt_new_db" ] && [ -n "$opt_share" ]; then
298
+ pick_option "$opt_new_db" "$opt_share" "A new cluster of its own ~\$15/month"
299
+ elif [ -n "$opt_new_db" ]; then
300
+ pick_option "$opt_new_db" "A new cluster of its own ~\$15/month"
301
+ elif [ -n "$opt_share" ]; then
302
+ pick_option "$opt_share" "A new cluster of its own ~\$15/month"
303
+ else
304
+ PICKED=99 # nothing to reuse: the ground provisions a cluster, no question worth asking
284
305
  fi
306
+
307
+ local choice=""
308
+ case "$PICKED" in
309
+ 0) [ -n "$opt_new_db" ] && choice="own" || choice="share" ;;
310
+ 1) [ -n "$opt_new_db" ] && [ -n "$opt_share" ] && choice="share" || choice="fresh" ;;
311
+ *) choice="fresh" ;;
312
+ esac
313
+
314
+ case "$choice" in
315
+ own)
316
+ node -e 'const fs=require("fs");const[f]=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"
317
+ ok "Production gets its own database on $found ${DIM}(your development data is untouched)${NC}"
318
+ ;;
319
+ share)
320
+ node -e 'const fs=require("fs");const[f,v]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#?\s*byo_postgres_url\s*=.*$/m,"byo_postgres_url = "+JSON.stringify(v));fs.writeFileSync(f,s)' "$tfv" "$env_db"
321
+ ok "Production reads ${BOLD}$db_name${NC} ${DIM}(the same database you develop against — local changes are live)${NC}"
322
+ ;;
323
+ *) ok "A new cluster will be created for production" ;;
324
+ esac
285
325
  fi
286
326
 
287
327
  _tf_fill docr_token DOCR_TOKEN "Registry access token (from your Unoverse admin)"
@@ -401,12 +441,26 @@ _ground_apply() {
401
441
 
402
442
  if [ "$summary_rc" = "3" ]; then
403
443
  local REPLY
444
+ # ENTER GOES AHEAD, ESC STOPS. This asked for a typed `y` and made Enter mean "stop",
445
+ # which is the opposite of every other prompt in this CLI — the developer who has just
446
+ # read the plan and decided has to break the habit of the whole tool to act on it. One
447
+ # meaning for Enter throughout, and the escape hatch is the key literally named for it.
404
448
  echo -e " ${RED}This removes or rebuilds things that already exist.${NC}"
405
- read -r -p " Type y to go ahead, or Enter to stop: " REPLY
406
- if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
449
+ echo ""
450
+ echo -e " ${DIM}Enter to go ahead · Esc to stop${NC}"
451
+ printf " "
452
+ local key=""
453
+ if [ -t 0 ]; then
454
+ IFS= read -r -s -n 1 key
455
+ fi
456
+ # \e is Escape; anything else typed (n, q, a stray letter) also stops, because only a
457
+ # deliberate empty Enter should destroy something.
458
+ if [ -n "$key" ]; then
459
+ echo ""
407
460
  info "Nothing was changed. Run ${BOLD}unoverse deploy${NC} when you are ready"
408
461
  rm -rf "$tmp"; return 1
409
462
  fi
463
+ echo ""
410
464
  fi
411
465
 
412
466
  echo ""
@@ -13,6 +13,31 @@ _ground_my_ip() {
13
13
  curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null
14
14
  }
15
15
 
16
+ # A GROUND WRITES SECRETS TO DISK, so the ignore rule ships with the ground itself.
17
+ #
18
+ # terraform.tfvars holds the database URL, the registry token and every service key; the
19
+ # state file holds those plus everything Terraform generated, in plain text. The platform
20
+ # repo has ignored them since the grounds landed. A universe scaffolded by the CLI did not,
21
+ # because its .gitignore comes from the starter — which only changes when the starter is
22
+ # re-synced and pushed, long after somebody's first `git init && git add .`.
23
+ #
24
+ # Fixing it here means the rule exists the moment the file it protects does, on every
25
+ # universe, without waiting for a starter release. Idempotent: it appends only what is
26
+ # missing, and never rewrites a line the developer put there.
27
+ _ground_protect_secrets() {
28
+ local gi="$ROOT/.gitignore" rule
29
+ [ -f "$gi" ] || : > "$gi"
30
+ local added=0
31
+ for rule in 'infra/**/.terraform/' 'infra/**/*.tfstate*' 'infra/**/terraform.tfvars' '.unoverse/'; do
32
+ grep -qxF "$rule" "$gi" 2>/dev/null && continue
33
+ [ "$added" = "0" ] && printf '\n# Terraform (infra/*) — state and vars carry secrets\n' >> "$gi"
34
+ printf '%s\n' "$rule" >> "$gi"
35
+ added=1
36
+ done
37
+ [ "$added" = "1" ] && ok "Added the terraform files to .gitignore ${DIM}(they hold your database URL and keys)${NC}"
38
+ return 0
39
+ }
40
+
16
41
  # THE UNIVERSE HAS A NAME, so ask for one. This file used to write name = "universe-poc"
17
42
  # as a literal, so every universe on every account carried the same placeholder — it names
18
43
  # the droplet, the cache, the load balancer and the cloud project, and the developer first
@@ -132,6 +157,7 @@ _ground_do() {
132
157
  echo ""
133
158
  echo -e " ${DIM}Regions available to you: $regions${NC}"
134
159
  fi
160
+ _ground_protect_secrets
135
161
  _ground_identity "$regions"
136
162
 
137
163
  cat > "$out" <<EOF
@@ -232,6 +258,7 @@ _ground_aws() {
232
258
  fi
233
259
 
234
260
  # Region already came from the AWS CLI's own configuration, so only the name is asked.
261
+ _ground_protect_secrets
235
262
  _ground_identity "-"
236
263
 
237
264
  cat > "$out" <<EOF
@@ -71,6 +71,17 @@ function describe(r) {
71
71
  price: PRICE[a.size],
72
72
  };
73
73
  }
74
+ // NEVER "SUPPORTING". These were counted as plumbing, which is fair when they are being
75
+ // created alongside the cluster they live on — and badly wrong on a REPLACE, where a
76
+ // dropped and recreated database takes its contents with it. "plus 1 supporting
77
+ // resource (users, rules, keys)" is not something anyone reads as "your data is about
78
+ // to be deleted". Naming them costs one line and makes that impossible to miss.
79
+ case "digitalocean_database_db":
80
+ return { what: "Database", detail: a.name ? `${a.name} — its contents live here` : "" };
81
+ case "digitalocean_database_user":
82
+ return { what: "Database user", detail: a.name ?? "" };
83
+ case "digitalocean_database_connection_pool":
84
+ return { what: "Connection pool", detail: a.name ?? "" };
74
85
  case "digitalocean_loadbalancer":
75
86
  return { what: "Load balancer", detail: "the public way in", price: PRICE.lb };
76
87
  case "digitalocean_firewall":
@@ -168,7 +179,11 @@ process.stdin.on("end", () => {
168
179
  plan.planned_values?.outputs?.monthly_estimate?.value ??
169
180
  plan.outputs?.monthly_estimate?.value;
170
181
  const monthly = ground ?? Object.values(groups).flat().reduce((sum, d) => sum + (d.price ?? 0), 0);
182
+ // THE TOTAL, NOT THE PRICE OF THIS CHANGE. `monthly_estimate` is what the whole universe
183
+ // costs to run, and printing it under a change list read as the bill for that change:
184
+ // a one-line firewall update appeared to cost $135. Say which number this is.
171
185
  if (monthly) {
186
+ out.push(` ${dim("Running total for the whole universe, not the cost of this change:")}`);
172
187
  out.push(` ${bold("Roughly")} ${bold(`$${monthly}/month`)} ${dim("at your provider's current prices, billed by them, not by unoverse")}`);
173
188
  out.push("");
174
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
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",