unoverse 0.1.81 → 0.1.82

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/lib/urls.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  * PROBED, not recited. Every address is actually requested and reported by what
10
10
  * answered. A URL you cannot click is a hope, not an address.
11
11
  *
12
- * Deployed facts come from .env.production first, the ground's terraform outputs as
13
- * enrichment. Local needs no config: a port is either listening or it is not.
12
+ * Deployed facts come from the ground's terraform outputs, which built the thing they
13
+ * describe. Local needs no config: a port is either listening or it is not.
14
14
  *
15
15
  * Named `urls` until 0.1.5; that spelling still works as an alias.
16
16
  */
@@ -36,31 +36,29 @@ function section(title, rows, subtitle) {
36
36
  }
37
37
  }
38
38
 
39
- import { existsSync, readFileSync } from "node:fs";
39
+ import { existsSync } from "node:fs";
40
40
  import { join, resolve, dirname } from "node:path";
41
41
  import { spawnSync } from "node:child_process";
42
42
 
43
43
  import { bold, dim, cyan, green, red } from "./ui.mjs";
44
44
 
45
- /** Walk up from cwd to the folder holding .env.production (a universe checkout). */
45
+ /**
46
+ * Walk up from cwd to the universe root, identified by its docker-compose.yml.
47
+ *
48
+ * It used to look for .env.production, which is no longer a file: deploy renders it from
49
+ * terraform into a temp file and deletes it. Every deployed fact below comes from the
50
+ * ground's outputs now, which is where it was always true — a rendered env file was only
51
+ * ever a copy, and a copy that outlived the server it described.
52
+ */
46
53
  function findUniverseRoot() {
47
54
  for (let dir = process.cwd(); ; ) {
48
- if (existsSync(join(dir, ".env.production"))) return dir;
55
+ if (existsSync(join(dir, "docker-compose.yml"))) return dir;
49
56
  const parent = resolve(dir, "..");
50
57
  if (parent === dir) return null;
51
58
  dir = parent;
52
59
  }
53
60
  }
54
61
 
55
- function parseEnv(file) {
56
- const env = {};
57
- for (const line of readFileSync(file, "utf8").split("\n")) {
58
- const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
59
- if (m) env[m[1]] = m[2];
60
- }
61
- return env;
62
- }
63
-
64
62
  /** The ground's terraform outputs, when a state is present. Best-effort. */
65
63
  function terraformOutputs(root) {
66
64
  for (const ground of ["infra/digitalocean", "infra/aws"]) {
@@ -93,17 +91,16 @@ export async function urls() {
93
91
  // a fact, not a configuration question, so it needs no universe folder to answer.
94
92
  const local = await Promise.all(LOCAL.map(async (c) => ({ ...c, ...(await probe(c.url)) })));
95
93
 
96
- // DEPLOYED, only if this checkout has been deployed. No .env.production is not an
97
- // error: plenty of universes only ever run locally.
94
+ // DEPLOYED, only if this universe has a ground with state. No outputs is not an error:
95
+ // plenty of universes only ever run locally. Every address here comes from terraform,
96
+ // which built the thing it is describing — the ground already resolves DOMAIN into
97
+ // api_url, so there is nothing left to work out on this side.
98
98
  let deployed = [];
99
99
  let domain = "";
100
100
  if (root) {
101
- const env = parseEnv(join(root, ".env.production"));
102
101
  const tf = terraformOutputs(root);
103
- domain = env.DOMAIN || "";
104
- // The same law as the ground: DOMAIN drives HTTPS URLs; without it, the
105
- // explicit API_URL (rendered by terraform) carries the LB address.
106
- const apiBase = domain ? `https://api.${domain}` : env.API_URL || tf.api_url || "";
102
+ const apiBase = tf.api_url || "";
103
+ domain = /^https:\/\/api\.(.+)$/.exec(apiBase)?.[1] || "";
107
104
  const candidates = [];
108
105
  if (apiBase) {
109
106
  candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp" });
@@ -111,7 +108,7 @@ export async function urls() {
111
108
  }
112
109
  const canvas = tf.canvas_url && tf.canvas_url.startsWith("http") ? tf.canvas_url : null;
113
110
  if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins" });
114
- if (env.DEPLOY_HOST) candidates.push({ label: "Logs", url: `http://${env.DEPLOY_HOST}:8080`, note: "admin IP only" });
111
+ if (tf.deploy_host) candidates.push({ label: "Logs", url: `http://${tf.deploy_host}:8080`, note: "admin IP only" });
115
112
  if (tf.cognito_hosted_ui) candidates.push({ label: "Login", url: tf.cognito_hosted_ui });
116
113
  deployed = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url)) })));
117
114
  }
@@ -17,15 +17,21 @@
17
17
  become: yes
18
18
 
19
19
  tasks:
20
+ # REGISTER NAMES MUST NOT COLLIDE WITH -e VARIABLES. This registered into `env_file`,
21
+ # which is also the name deploy passes on the command line as the path to the rendered
22
+ # settings. Extra vars outrank registered ones in Ansible's precedence, so the register
23
+ # never took: the variable stayed a string and the very next line asked a string for
24
+ # `.stat`, failing with "object of type 'str' has no attribute 'stat'" — an error that
25
+ # says nothing about the actual cause.
20
26
  - name: "[1/5] Verify .env exists"
21
27
  stat:
22
28
  path: /opt/gravity/.env
23
- register: env_file
29
+ register: remote_env
24
30
 
25
31
  - name: "[1/5] Fail if .env missing"
26
32
  fail:
27
33
  msg: ".env file not found at /opt/gravity/.env - run install.yml first"
28
- when: not env_file.stat.exists
34
+ when: not remote_env.stat.exists
29
35
 
30
36
  - name: "[2/5] Check DATABASE_URL is configured"
31
37
  shell: grep -q "^DATABASE_URL=" /opt/gravity/.env
@@ -126,15 +126,12 @@
126
126
  owner: "{{ ansible_user }}"
127
127
  mode: "0644"
128
128
 
129
- # Studio (the dev workbench) is ON in the local compose. Production NEVER runs it:
130
- # force UNOVERSE_WORKBENCH=0 in the deployed compose. This is the structural off-switch
131
- # it always runs on deploy, so Studio can't reach production regardless of the source.
132
- - name: "[7b/12] Force Studio (workbench) OFF for production"
133
- ansible.builtin.replace:
134
- path: "{{ gravity_dir }}/docker-compose.yml"
135
- regexp: '^\s*-\s*UNOVERSE_WORKBENCH=1\s*$'
136
- replace: ' - UNOVERSE_WORKBENCH=0'
137
-
129
+ # NO WORKBENCH STEP HERE. This used to force UNOVERSE_WORKBENCH=0 in the deployed
130
+ # compose, back when Studio shipped inside the platform image and had to be switched
131
+ # off for production. Studio is a separate CLI-launched app now and the variable is
132
+ # gone from docker-compose.yml entirely, so the task matched nothing and reported "ok"
133
+ # anyway — a step that proves a safeguard is working while doing nothing at all is
134
+ # worse than no step.
138
135
  - name: "[8/12] Copy .env file"
139
136
  copy:
140
137
  # Same trap as the compose above: a path relative to the playbook is the monorepo's
@@ -302,7 +302,19 @@ _ground_apply() {
302
302
 
303
303
  cmd_deploy() {
304
304
 
305
- local env_prod="$ROOT/.env.production"
305
+ # .env.production IS NOT A FILE IN THE UNIVERSE. It is `terraform output -raw
306
+ # env_production`: derived, complete, and regenerated in full on every deploy. Writing it
307
+ # to the universe root put a second env file next to `.env`, which reads like something
308
+ # to edit and is not — the next deploy overwrites it — and it outlived the server it
309
+ # described, so a torn-down universe still looked deployed. Render it to a private temp
310
+ # file, ship it, delete it. The ground stays the only source of truth.
311
+ local env_prod
312
+ env_prod=$(mktemp -t unoverse-env-prod) || exit 1
313
+ chmod 600 "$env_prod"
314
+ trap 'rm -f "$env_prod" 2>/dev/null' EXIT
315
+ # Older universes have one on disk. It is stale by definition now, and leaving it means
316
+ # the confusing second env file simply never goes away.
317
+ rm -f "$ROOT/.env.production"
306
318
 
307
319
  _ground_credentials
308
320
 
@@ -343,26 +355,22 @@ cmd_deploy() {
343
355
 
344
356
  _ground_apply "$cloud"; rc=$?
345
357
  [ "$rc" = "1" ] && exit 1
346
- if [ "$rc" = "0" ] || [ ! -f "$env_prod" ]; then
347
- terraform -chdir="$ROOT/infra/$cloud" output -raw env_production > "$env_prod" 2>/dev/null \
348
- && ok "Rendered .env.production from your $cloud ground"
349
- fi
350
-
351
- if [ ! -f "$env_prod" ]; then
352
- fail ".env.production could not be rendered — has the ground been applied?"
358
+ terraform -chdir="$ROOT/infra/$cloud" output -raw env_production > "$env_prod" 2>/dev/null
359
+ if [ ! -s "$env_prod" ]; then
360
+ fail "Could not read this universe's settings from your $cloud ground. Has it been applied?"
353
361
  exit 1
354
362
  fi
355
363
 
356
364
  _adopted_db_access "$cloud" grant
357
365
 
358
366
 
359
- # Read deploy target from .env.production
367
+ # Read the deploy target from the rendered settings
360
368
  local deploy_host deploy_user
361
369
  deploy_host=$(grep '^DEPLOY_HOST=' "$env_prod" | cut -d= -f2- | tr -d '\r\n' | xargs)
362
370
  deploy_user=$(grep '^DEPLOY_USER=' "$env_prod" | cut -d= -f2- | tr -d '\r\n' | xargs)
363
371
 
364
372
  if [ -z "$deploy_host" ] || [ "$deploy_host" = "your-vm-ip" ]; then
365
- fail "DEPLOY_HOST is not set in .env.production"
373
+ fail "This ground has no server address yet. Has it been applied?"
366
374
  exit 1
367
375
  fi
368
376
  deploy_user="${deploy_user:-root}"
@@ -379,7 +387,7 @@ cmd_deploy() {
379
387
  fi
380
388
  ok "Ansible available"
381
389
 
382
- # Generate a temporary inventory from .env.production
390
+ # Generate a temporary inventory from the rendered settings
383
391
  local tmp_inventory
384
392
  tmp_inventory=$(mktemp).yml
385
393
  cat > "$tmp_inventory" << 'EOF'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
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",