ozali 0.4.1 → 0.6.0

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,7 +1,7 @@
1
1
  // detect.mjs — detección read-only del entorno del proyecto destino.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { exists, which, gitInfo, nodeMajor, HOME } from "./util.mjs";
4
+ import { exists, which, gitInfo, nodeMajor, HOME, readJSON } from "./util.mjs";
5
5
 
6
6
  /** Variante de fuente de verdad: {found, doc, dir, variant} */
7
7
  export function detectSourceOfTruth(cwd) {
@@ -47,6 +47,13 @@ export function detectEngram() {
47
47
  return { available: !!bin, bin: bin || null };
48
48
  }
49
49
 
50
+ /** Metadatos compartibles de Engram Cloud del proyecto (sin secretos). */
51
+ export function detectCloud(cwd) {
52
+ const metaPath = path.join(cwd, ".ozali", "cloud.json");
53
+ const meta = readJSON(metaPath);
54
+ return { present: !!meta, path: metaPath, meta: meta || null };
55
+ }
56
+
50
57
  /**
51
58
  * Capacidades de testing (heurística read-only). Devuelve runner(s), comando y
52
59
  * un conteo aproximado de archivos de prueba. NO resuelve strict_tdd (eso lo
@@ -98,6 +105,7 @@ export function detectAll(cwd) {
98
105
  agents: detectAgents(cwd),
99
106
  skill: detectInstalledSkill(cwd),
100
107
  engram: detectEngram(),
108
+ cloud: detectCloud(cwd),
101
109
  testing: detectTesting(cwd),
102
110
  };
103
111
  }
package/cli/lib/util.mjs CHANGED
@@ -10,6 +10,7 @@ import { execFileSync } from "node:child_process";
10
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
11
11
  export const PKG_ROOT = path.resolve(HERE, "..", "..");
12
12
  export const SKILL_SRC = path.join(PKG_ROOT, "skill");
13
+ export const COMMIT_SKILL_SRC = path.join(PKG_ROOT, "skill-commit");
13
14
  export const TEMPLATES_SRC = path.join(PKG_ROOT, "templates");
14
15
 
15
16
  export function pkgVersion() {
@@ -21,6 +22,21 @@ export function pkgVersion() {
21
22
  }
22
23
  }
23
24
 
25
+ /**
26
+ * Nombre del asset de release de Engram para un SO/arch/versión dados.
27
+ * Convención oficial: engram_<version>_<os>_<arch>.<ext> (.tar.gz en linux/darwin, .zip en windows).
28
+ * Devuelve null si el SO o la arquitectura no están soportados. Función pura (sin red).
29
+ */
30
+ export function engramAssetName(platform, arch, version) {
31
+ const osMap = { darwin: "darwin", linux: "linux", win32: "windows" };
32
+ const archMap = { x64: "amd64", arm64: "arm64" };
33
+ const os = osMap[platform];
34
+ const a = archMap[arch];
35
+ if (!os || !a) return null;
36
+ const ext = platform === "win32" ? "zip" : "tar.gz";
37
+ return `engram_${version}_${os}_${a}.${ext}`;
38
+ }
39
+
24
40
  // ---- colores (sin deps; respeta NO_COLOR y no-TTY) --------------------------
25
41
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
26
42
  const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
@@ -70,6 +86,14 @@ export function writeJSON(p, obj) {
70
86
  fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
71
87
  }
72
88
 
89
+ /** Abre una URL en el navegador por defecto. Devuelve true si pudo lanzar el comando. */
90
+ export function openURL(url) {
91
+ if (!url) return false;
92
+ if (process.platform === "darwin") return spawnCmd("open", [url]) === 0;
93
+ if (process.platform === "win32") return spawnCmd("cmd", ["/c", "start", "", url]) === 0;
94
+ return spawnCmd("xdg-open", [url]) === 0;
95
+ }
96
+
73
97
  // ---- ejecución de comandos (read-only / git) --------------------------------
74
98
  /** Ejecuta un binario y devuelve stdout recortado, o null si falla. */
75
99
  export function tryExec(cmd, args = [], opts = {}) {
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { engramAssetName } from "../lib/util.mjs";
9
10
 
10
11
  const HERE = path.dirname(fileURLToPath(import.meta.url));
11
12
  const BIN = path.resolve(HERE, "..", "bin", "ozali.mjs");
@@ -132,6 +133,80 @@ test("update agrega ozali-jarvis en un repo que no lo tenía", () => {
132
133
  }
133
134
  });
134
135
 
136
+ function initRepo(dir) {
137
+ run(["init", "--yes", "--no-engram", "--no-trust", "--no-jarvis", "--agent", "claude-code", "--scope", "project", "--knowledge-repo", path.join(dir, ".k")], dir);
138
+ }
139
+
140
+ function writeCdkStub(dir, body) {
141
+ const f = path.join(dir, ".claude", "skills", "cdk", "SKILL.md");
142
+ fs.mkdirSync(path.dirname(f), { recursive: true });
143
+ fs.writeFileSync(f, body);
144
+ return f;
145
+ }
146
+
147
+ test("init instala la skill ozali-commit", () => {
148
+ const dir = tmpProject();
149
+ try {
150
+ initRepo(dir);
151
+ assert.ok(fs.existsSync(path.join(dir, ".claude", "skills", "ozali-commit", "SKILL.md")), "ozali-commit SKILL.md instalada");
152
+ } finally {
153
+ fs.rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("doctor marca cdk al día cuando la versión de contrato coincide", () => {
158
+ const dir = tmpProject();
159
+ try {
160
+ initRepo(dir);
161
+ writeCdkStub(dir, "---\nname: cdk\ncdk_contract_version: 1\n---\n# cdk\n");
162
+ const { stdout } = run(["doctor"], dir, true);
163
+ assert.match(stdout, /Skill cdk/, "doctor reporta la fila Skill cdk");
164
+ assert.match(stdout, /contrato v1 \(al día\)/, "doctor marca cdk al día");
165
+ } finally {
166
+ fs.rmSync(dir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("doctor marca cdk desactualizada si referencia copsis-commit", () => {
171
+ const dir = tmpProject();
172
+ try {
173
+ initRepo(dir);
174
+ writeCdkStub(dir, "---\nname: cdk\ncdk_contract_version: 1\n---\n# cdk\ninvoca copsis-commit al cierre del hito\n");
175
+ const { stdout } = run(["doctor"], dir, true);
176
+ assert.match(stdout, /copsis-commit/, "doctor avisa de copsis-commit");
177
+ } finally {
178
+ fs.rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("update avisa y da pasos manuales si cdk está desactualizada (legado)", () => {
183
+ const dir = tmpProject();
184
+ try {
185
+ initRepo(dir);
186
+ writeCdkStub(dir, "---\nname: cdk\n---\n# cdk legado que invoca copsis-commit\n");
187
+ const { stdout } = run(["update"], dir);
188
+ assert.match(stdout, /cdk desactualizada/, "update avisa de cdk desactualizada");
189
+ assert.match(stdout, /copsis-commit/, "update menciona la migración de copsis-commit");
190
+ assert.match(stdout, /skill ozali|pre-flight|migra/i, "update da pasos manuales");
191
+ } finally {
192
+ fs.rmSync(dir, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test("update también instala ozali-commit en repos previos", () => {
197
+ const dir = tmpProject();
198
+ try {
199
+ initRepo(dir);
200
+ // Simula instalación previa sin ozali-commit.
201
+ fs.rmSync(path.join(dir, ".claude", "skills", "ozali-commit"), { recursive: true, force: true });
202
+ assert.ok(!fs.existsSync(path.join(dir, ".claude", "skills", "ozali-commit", "SKILL.md")), "precondición: sin ozali-commit");
203
+ run(["update"], dir);
204
+ assert.ok(fs.existsSync(path.join(dir, ".claude", "skills", "ozali-commit", "SKILL.md")), "update instala ozali-commit");
205
+ } finally {
206
+ fs.rmSync(dir, { recursive: true, force: true });
207
+ }
208
+ });
209
+
135
210
  test("init --dry-run no escribe nada", () => {
136
211
  const dir = tmpProject();
137
212
  try {
@@ -150,3 +225,52 @@ test("el paquete NO tiene dependencias ni lifecycle scripts (seguridad)", () =>
150
225
  assert.ok(!(pkg.scripts && pkg.scripts[s]), `sin script de ciclo de vida: ${s}`);
151
226
  }
152
227
  });
228
+
229
+ test("--help menciona el comando cloud", () => {
230
+ const { stdout } = run(["--help"]);
231
+ assert.match(stdout, /cloud/, "help debe mencionar cloud");
232
+ assert.match(stdout, /--dashboard/, "help debe mencionar --dashboard");
233
+ assert.match(stdout, /--conflicts/, "help debe mencionar --conflicts");
234
+ });
235
+
236
+ test("cloud status imprime cabecera y no rompe", () => {
237
+ const dir = tmpProject();
238
+ try {
239
+ const { code, stdout } = run(["cloud", "status"], dir, true);
240
+ assert.match(stdout, /ozali cloud status/, "debe imprimir cabecera de cloud status");
241
+ } finally {
242
+ fs.rmSync(dir, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test("cloud con subcomando desconocido sale con código 1", () => {
247
+ const dir = tmpProject();
248
+ try {
249
+ const { code, stdout } = run(["cloud", "frobnicate"], dir, true);
250
+ assert.equal(code, 1, "debe salir con código 1");
251
+ assert.match(stdout, /Subcomando desconocido/, "debe indicar subcomando desconocido");
252
+ } finally {
253
+ fs.rmSync(dir, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ test(".gitignore del repo destino permite .ozali/cloud.json (commiteable)", () => {
258
+ const dir = tmpProject();
259
+ try {
260
+ run(["init", "--yes", "--no-engram", "--no-trust", "--no-jarvis", "--agent", "claude-code", "--scope", "project", "--knowledge-repo", path.join(dir, ".k")], dir);
261
+ const gi = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
262
+ assert.match(gi, /\.ozali\/\*/, "debe ignorar .ozali/*");
263
+ assert.match(gi, /!\.ozali\/cloud\.json/, "debe permitir .ozali/cloud.json");
264
+ } finally {
265
+ fs.rmSync(dir, { recursive: true, force: true });
266
+ }
267
+ });
268
+
269
+ test("engramAssetName respeta la convención de release por SO/arch", () => {
270
+ assert.equal(engramAssetName("linux", "x64", "1.17.0"), "engram_1.17.0_linux_amd64.tar.gz");
271
+ assert.equal(engramAssetName("linux", "arm64", "1.17.0"), "engram_1.17.0_linux_arm64.tar.gz");
272
+ assert.equal(engramAssetName("darwin", "arm64", "1.17.0"), "engram_1.17.0_darwin_arm64.tar.gz");
273
+ assert.equal(engramAssetName("win32", "x64", "1.17.0"), "engram_1.17.0_windows_amd64.zip");
274
+ assert.equal(engramAssetName("linux", "ia32", "1.17.0"), null, "arch no soportada → null");
275
+ assert.equal(engramAssetName("freebsd", "x64", "1.17.0"), null, "SO no soportado → null");
276
+ });
@@ -0,0 +1,149 @@
1
+ # Desplegar Engram Cloud en Google Cloud
2
+
3
+ ← [README](../README.md)
4
+
5
+ Dos opciones para desplegar el **servidor de Engram Cloud** en Google Cloud Platform, según el
6
+ perfil de carga y mantenimiento que prefieras.
7
+
8
+ ## Opción A: Cloud Run + Cloud SQL (serverless, managed)
9
+
10
+ La opción más **hands-off**: Postgres gestionado por Google, Engram en Cloud Run (serverless,
11
+ escala a cero). Ideal si no quieres mantener VMs.
12
+
13
+ ### Componentes
14
+
15
+ | Servicio | Qué hace | Coste aprox. |
16
+ |---|---|---|
17
+ | Cloud SQL (Postgres 16) | Base de datos gestionada | ~$15-30/mes (db-f1-micro) |
18
+ | Cloud Run | Ejecuta el servidor Engram | $0 por inactivo + por uso |
19
+ | Secret Manager | Guarda el token y DB URL | gratis hasta 6 secretos |
20
+ | Artifact Registry | Imagen Docker de Engram | gratis hasta 0.5 GB |
21
+
22
+ ### Pasos
23
+
24
+ 1. **Crear instancia de Cloud SQL:**
25
+ ```bash
26
+ gcloud sql instances create engram-db \
27
+ --database-version=POSTGRES_16 \
28
+ --tier=db-f1-micro \
29
+ --region=us-central1 \
30
+ --root-password=$(openssl rand -hex 32)
31
+ ```
32
+
33
+ 2. **Crear base de datos y usuario:**
34
+ ```bash
35
+ gcloud sql databases create engram --instance=engram-db
36
+ gcloud sql users create engram --instance=engram-db --password=$(openssl rand -hex 16)
37
+ ```
38
+
39
+ 3. **Subir la imagen a Artifact Registry:**
40
+ ```bash
41
+ gcloud artifacts repositories create engram --repository-format=docker --location=us-central1
42
+ docker pull ghcr.io/gentleman-programming/engram:latest
43
+ docker tag ghcr.io/gentleman-programming/engram:latest \
44
+ us-central1-docker.pkg.dev/$PROJECT_ID/engram/cloud:latest
45
+ docker push us-central1-docker.pkg.dev/$PROJECT_ID/engram/cloud:latest
46
+ ```
47
+
48
+ 4. **Crear secretos en Secret Manager:**
49
+ ```bash
50
+ echo -n "postgres://engram:...@/engram?cloudsql=/cloudsql/$PROJECT_ID:us-central1:engram-db" \
51
+ | gcloud secrets create ENGRAM_CLOUD_DATABASE_URL --data-file=-
52
+ echo -n "$(openssl rand -hex 32)" \
53
+ | gcloud secrets create ENGRAM_CLOUD_TOKEN --data-file=-
54
+ ```
55
+
56
+ 5. **Desplegar en Cloud Run:**
57
+ ```bash
58
+ gcloud run deploy engram-cloud \
59
+ --image=us-central1-docker.pkg.dev/$PROJECT_ID/engram/cloud:latest \
60
+ --region=us-central1 \
61
+ --port=18080 \
62
+ --no-allow-unauthenticated \
63
+ --add-cloudsql-instances=$PROJECT_ID:us-central1:engram-db \
64
+ --set-secrets="ENGRAM_CLOUD_DATABASE_URL=ENGRAM_CLOUD_DATABASE_URL:latest" \
65
+ --set-secrets="ENGRAM_CLOUD_TOKEN=ENGRAM_CLOUD_TOKEN:latest" \
66
+ --set-env-vars="ENGRAM_CLOUD_DASHBOARD=true"
67
+ ```
68
+
69
+ 6. **IAM — quién puede acceder:**
70
+ - Los devs no acceden a Cloud Run directamente; lo hace el CLI de Engram.
71
+ - Si Cloud Run está `--no-allow-unauthenticated`, configura un endpoint público con
72
+ Cloud Armor (IP allowlist) o un Identity-Aware Proxy (IAP).
73
+
74
+ 7. **VPC connector para Cloud SQL:**
75
+ Si Cloud SQL tiene IP privada (recomendado), crea un Serverless VPC connector:
76
+ ```bash
77
+ gcloud compute networks vpc-access connectors create engram-conn \
78
+ --network=default --region=us-central1 --range=10.8.0.0/28
79
+ ```
80
+ Y en el deploy de Cloud Run añade `--vpc-connector=engram-conn`.
81
+
82
+ ### Verificar
83
+ ```bash
84
+ gcloud run services describe engram-cloud --region=us-central1 --format='value(status.url)'
85
+ curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
86
+ https://engram-cloud-xxxx-uc.a.run.app/health
87
+ ```
88
+
89
+ ## Opción B: GCE VM + docker-compose (similar al VPS)
90
+
91
+ Si prefieres control total (equivalente al [despliegue VPS](deploy-cloud-vps.md) pero en GCE):
92
+
93
+ 1. **Crear VM:**
94
+ ```bash
95
+ gcloud compute instances create engram-cloud \
96
+ --machine-type=e2-small \
97
+ --zone=us-central1-a \
98
+ --image-family=ubuntu-2204-lts \
99
+ --image-project=ubuntu-os-cloud \
100
+ --boot-disk-size=30GB \
101
+ --tags=engram-cloud
102
+ ```
103
+
104
+ 2. **Firewall (solo HTTP/HTTPS + SSH):**
105
+ ```bash
106
+ gcloud compute firewall-rules create engram-allow-http \
107
+ --allow=tcp:80,tcp:443 --target-tags=engram-cloud
108
+ ```
109
+
110
+ 3. **SSH e instalar Docker:**
111
+ ```bash
112
+ gcloud compute ssh engram-cloud --zone=us-central1-a
113
+ # dentro de la VM:
114
+ sudo apt update && sudo apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx
115
+ sudo usermod -aG docker $USER
116
+ ```
117
+
118
+ 4. **Desplegar igual que el VPS:**
119
+ - Copia `docker-compose.yml` y `.env` (ver [deploy-cloud-vps.md](deploy-cloud-vps.md))
120
+ - Configura nginx + Let's Encrypt
121
+ - Configura systemd para auto-start
122
+
123
+ ### Ventajas de GCE vs VPS genérico
124
+ - **Secret Manager:** los secretos viven en GCP, no en un `.env` en disco
125
+ - **Snapshots programados** del disco para backups
126
+ - **Cloud Logging** centralizado
127
+ - **MIG (Managed Instance Group)** si necesitas HA
128
+
129
+ ## Comparación rápida
130
+
131
+ | | Opción A (Cloud Run) | Opción B (GCE VM) |
132
+ |---|---|---|
133
+ | Mantenimiento | Mínimo (serverless) | Medio (gestionas la VM) |
134
+ | Coste en reposo | ~$0 (Cloud Run escala a 0) | ~$13/mes (e2-small) |
135
+ | Cold start | Sí (1-3s tras inactividad) | No |
136
+ | Control | Limitado | Total |
137
+ | Backups | Automáticos (Cloud SQL) | Manuales (snapshots) |
138
+ | Recomendado para | Equipos pequeños/medianos | Equipos con carga constante o compliance estricto |
139
+
140
+ ## Conectar los devs
141
+
142
+ Independiente de la opción elegida, cada dev conecta con:
143
+
144
+ ```bash
145
+ ozali init
146
+ # → "¿Habilitar Engram Cloud?" → sí
147
+ # → URL: https://engram-cloud-xxxx.a.run.app (A) o https://engram.mi-empresa.com (B)
148
+ # → Token: <ENGRAM_CLOUD_TOKEN>
149
+ ```
@@ -0,0 +1,174 @@
1
+ # Desplegar Engram Cloud en un VPS
2
+
3
+ ← [README](../README.md)
4
+
5
+ Guía para desplegar el **servidor de Engram Cloud** en un VPS genérico (DigitalOcean, Hetzner,
6
+ Linode, AWS EC2, etc.). El servidor centraliza la memoria de equipo: los devs replican a él
7
+ con `ozali sync --cloud` (o automáticamente con autosync).
8
+
9
+ ## Requisitos
10
+
11
+ - VPS con Linux (Ubuntu 22.04+ recomendado) y acceso SSH
12
+ - Docker Engine + Docker Compose v2
13
+ - Un dominio (o subdominio) apuntando al VPS
14
+ - Puertos: 80/443 (nginx) abiertos al público; 18080 solo localhost
15
+
16
+ ## 1. docker-compose.yml
17
+
18
+ ```yaml
19
+ services:
20
+ postgres:
21
+ image: postgres:16-alpine
22
+ restart: unless-stopped
23
+ env_file: .env
24
+ volumes:
25
+ - pgdata:/var/lib/postgresql/data
26
+ healthcheck:
27
+ test: ["CMD-SHELL", "pg_isready -U engram"]
28
+ interval: 5s
29
+ timeout: 3s
30
+ retries: 5
31
+
32
+ cloud:
33
+ image: ghcr.io/gentleman-programming/engram:latest
34
+ restart: unless-stopped
35
+ depends_on:
36
+ postgres:
37
+ condition: service_healthy
38
+ env_file: .env
39
+ ports:
40
+ - "127.0.0.1:18080:18080" # solo localhost; nginx expone al exterior
41
+
42
+ volumes:
43
+ pgdata:
44
+ ```
45
+
46
+ > El `cloud` escucha en `127.0.0.1:18080` (no `0.0.0.0`): nginx hace de reverse proxy con TLS
47
+ > y es el único expuesto al público.
48
+
49
+ ## 2. .env (secretos fuertes)
50
+
51
+ ```bash
52
+ # Postgres
53
+ POSTGRES_USER=engram
54
+ POSTGRES_PASSWORD=<genera un secreto fuerte: openssl rand -hex 32>
55
+ POSTGRES_DB=engram
56
+
57
+ # Engram Cloud
58
+ ENGRAM_CLOUD_DATABASE_URL=postgres://engram:<PASSWORD>@postgres:5432/engram?sslmode=disable
59
+ ENGRAM_CLOUD_TOKEN=<genera otro secreto fuerte: openssl rand -hex 32>
60
+ ENGRAM_CLOUD_DASHBOARD=true
61
+ ```
62
+
63
+ > **El `ENGRAM_CLOUD_TOKEN` es lo que pide `ozali init` a cada dev.** Compártelo por canal
64
+ > seguro (1Password, Bitwarden, Signal), nunca por email/chat del repo.
65
+
66
+ ## 3. Hardening
67
+
68
+ ### 3.1 Firewall (ufw)
69
+
70
+ ```bash
71
+ sudo ufw default deny incoming
72
+ sudo ufw default allow outgoing
73
+ sudo ufw allow 22/tcp # SSH (considera cambiar el puerto)
74
+ sudo ufw allow 80/tcp # HTTP (nginx → redirige a HTTPS)
75
+ sudo ufw allow 443/tcp # HTTPS (nginx)
76
+ sudo ufw enable
77
+ ```
78
+
79
+ ### 3.2 nginx reverse proxy con SSL (Let's Encrypt)
80
+
81
+ ```nginx
82
+ server {
83
+ listen 80;
84
+ server_name engram.mi-empresa.com;
85
+ return 301 https://$host$request_uri;
86
+ }
87
+
88
+ server {
89
+ listen 443 ssl http2;
90
+ server_name engram.mi-empresa.com;
91
+
92
+ ssl_certificate /etc/letsencrypt/live/engram.mi-empresa.com/fullchain.pem;
93
+ ssl_certificate_key /etc/letsencrypt/live/engram.mi-empresa.com/privkey.pem;
94
+ ssl_protocols TLSv1.2 TLSv1.3;
95
+ ssl_ciphers HIGH:!aNULL:!MD5;
96
+
97
+ location / {
98
+ proxy_pass http://127.0.0.1:18080;
99
+ proxy_set_header Host $host;
100
+ proxy_set_header X-Real-IP $remote_addr;
101
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
102
+ proxy_set_header X-Forwarded-Proto $scheme;
103
+ }
104
+ }
105
+ ```
106
+
107
+ Certbot para el certificado:
108
+
109
+ ```bash
110
+ sudo certbot --nginx -d engram.mi-empresa.com
111
+ ```
112
+
113
+ ### 3.3 systemd para auto-start
114
+
115
+ ```ini
116
+ # /etc/systemd/system/engram-cloud.service
117
+ [Unit]
118
+ Description=Engram Cloud (Docker Compose)
119
+ After=docker.service
120
+ Requires=docker.service
121
+
122
+ [Service]
123
+ Type=oneshot
124
+ RemainAfterExit=yes
125
+ WorkingDirectory=/opt/engram-cloud
126
+ ExecStart=/usr/bin/docker compose up -d
127
+ ExecStop=/usr/bin/docker compose down
128
+ TimeoutStartSec=0
129
+
130
+ [Install]
131
+ WantedBy=multi-user.target
132
+ ```
133
+
134
+ ```bash
135
+ sudo systemctl enable --now engram-cloud
136
+ ```
137
+
138
+ ## 4. Verificar
139
+
140
+ ```bash
141
+ # En el VPS:
142
+ docker compose ps # ambos servicios "healthy"
143
+ curl http://127.0.0.1:18080/health # debería responder OK
144
+
145
+ # Desde fuera:
146
+ curl https://engram.mi-empresa.com/health
147
+ ```
148
+
149
+ ## 5. Conectar los devs
150
+
151
+ Cada dev en su repo:
152
+
153
+ ```bash
154
+ ozali init
155
+ # → "¿Habilitar Engram Cloud?" → sí
156
+ # → URL: https://engram.mi-empresa.com
157
+ # → Token: <el ENGRAM_CLOUD_TOKEN del .env>
158
+ # → autosync activo
159
+ ```
160
+
161
+ Si el servidor requiere upgrade de esquema:
162
+
163
+ ```bash
164
+ ozali cloud upgrade # doctor → repair → bootstrap
165
+ ```
166
+
167
+ ## Backups
168
+
169
+ ```bash
170
+ # Dump de Postgres (cron diario):
171
+ docker exec engram-cloud-postgres-1 pg_dump -U engram engram | gzip > /backups/engram-$(date +%F).sql.gz
172
+ ```
173
+
174
+ Mantén los backups fuera del VPS (S3, GCS, almacenamiento externo).
@@ -0,0 +1,137 @@
1
+ # PostgreSQL 16 vs MySQL 8.x para Engram Cloud
2
+
3
+ ← [README](../README.md)
4
+
5
+ Guía comparativa para adaptar el despliegue de Engram Cloud de PostgreSQL a MySQL 8.x.
6
+
7
+ ## Cambios necesarios en `docker-compose.yml`
8
+
9
+ ### PostgreSQL 16 (actual — ver [deploy-cloud-vps.md](deploy-cloud-vps.md))
10
+
11
+ ```yaml
12
+ services:
13
+ postgres:
14
+ image: postgres:16-alpine
15
+ restart: unless-stopped
16
+ env_file: .env
17
+ volumes:
18
+ - pgdata:/var/lib/postgresql/data
19
+ healthcheck:
20
+ test: ["CMD-SHELL", "pg_isready -U engram"]
21
+ interval: 5s
22
+ timeout: 3s
23
+ retries: 5
24
+
25
+ cloud:
26
+ image: ghcr.io/gentleman-programming/engram:latest
27
+ restart: unless-stopped
28
+ depends_on:
29
+ postgres:
30
+ condition: service_healthy
31
+ env_file: .env
32
+ ports:
33
+ - "127.0.0.1:18080:18080"
34
+
35
+ volumes:
36
+ pgdata:
37
+ ```
38
+
39
+ ### MySQL 8.x (propuesto)
40
+
41
+ ```yaml
42
+ services:
43
+ mysql:
44
+ image: mysql:8.0
45
+ restart: unless-stopped
46
+ env_file: .env
47
+ volumes:
48
+ - mysqldata:/var/lib/mysql
49
+ healthcheck:
50
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
51
+ interval: 5s
52
+ timeout: 3s
53
+ retries: 5
54
+
55
+ cloud:
56
+ image: ghcr.io/gentleman-programming/engram:latest
57
+ restart: unless-stopped
58
+ depends_on:
59
+ mysql:
60
+ condition: service_healthy
61
+ env_file: .env
62
+ ports:
63
+ - "127.0.0.1:18080:18080"
64
+
65
+ volumes:
66
+ mysqldata:
67
+ ```
68
+
69
+ ## Cambios en `.env`
70
+
71
+ | Variable | PostgreSQL | MySQL 8.x |
72
+ |---|---|---|
73
+ | DB user/pass | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` |
74
+ | Connection string | `postgres://engram:pass@postgres:5432/engram?sslmode=disable` | `engram:pass@tcp(mysql:3306)/engram?charset=utf8mb4&parseTime=True&loc=Local` |
75
+
76
+ ### `.env` para MySQL 8.x
77
+
78
+ ```bash
79
+ # MySQL
80
+ MYSQL_ROOT_PASSWORD=<genera un secreto fuerte: openssl rand -hex 32>
81
+ MYSQL_DATABASE=engram
82
+ MYSQL_USER=engram
83
+ MYSQL_PASSWORD=<genera otro secreto fuerte>
84
+
85
+ # Engram Cloud
86
+ ENGRAM_CLOUD_DATABASE_URL=engram:<PASSWORD>@tcp(mysql:3306)/engram?charset=utf8mb4&parseTime=True&loc=Local
87
+ ENGRAM_CLOUD_TOKEN=<genera otro secreto fuerte: openssl rand -hex 32>
88
+ ENGRAM_CLOUD_DASHBOARD=true
89
+ ```
90
+
91
+ ## Tabla comparativa
92
+
93
+ | Aspecto | PostgreSQL 16 | MySQL 8.x |
94
+ |---|---|---|
95
+ | Imagen Docker | `postgres:16-alpine` (~80MB) | `mysql:8.0` (~450MB) |
96
+ | Healthcheck | `pg_isready -U engram` | `mysqladmin ping -h localhost` |
97
+ | Volúmenes | `/var/lib/postgresql/data` | `/var/lib/mysql` |
98
+ | Driver Go | `lib/pq` o `pgx` | `go-sql-driver/mysql` |
99
+ | Connection string | `postgres://` | `user:pass@tcp(host:port)/db?...` |
100
+ | JSON nativo | `JSONB` (índices GIN) | `JSON` + funciones |
101
+ | Arrays nativos | Sí (`text[]`, `int[]`) | No (usa tablas relacionales) |
102
+ | Full-text search | `tsvector`/`tsquery` | `FULLTEXT` indexes |
103
+ | Concurrency | MVCC sin locks de lectura | MVCC con read locks en algunos casos |
104
+ | Backup | `pg_dump` | `mysqldump` |
105
+
106
+ ## Bloqueador: ¿Engram soporta MySQL?
107
+
108
+ Depende del código fuente de [github.com/Gentleman-Programming/engram](https://github.com/Gentleman-Programming/engram):
109
+
110
+ - **Si usa GORM**: con cambiar el driver (`postgres` → `mysql`) y el DSN, funcionaría.
111
+ - **Si usa SQL nativo con funciones Postgres** (`JSONB`, `NOW()`, `ARRAY`, `ILIKE`, `RETURNING`): requiere tocar el código de Engram.
112
+
113
+ ### Para verificar rápido
114
+
115
+ ```bash
116
+ # 1. Desplegar con MySQL (docker-compose arriba)
117
+ docker compose up -d
118
+
119
+ # 2. Ver logs del contenedor cloud:
120
+ docker logs engram-cloud-cloud-1
121
+ # Si falla con "unsupported driver" o "syntax error", no hay soporte MySQL.
122
+
123
+ # 3. Si conecta, verificar health:
124
+ curl http://127.0.0.1:18080/health
125
+ ```
126
+
127
+ ## Backup con MySQL
128
+
129
+ ```bash
130
+ # Dump de MySQL (cron diario):
131
+ docker exec engram-cloud-mysql-1 mysqldump -u engram -p<PASSWORD> engram | gzip > /backups/engram-$(date +%F).sql.gz
132
+ ```
133
+
134
+ ## Recomendación
135
+
136
+ - **Mantener PostgreSQL** si no hay requisito específico de MySQL. Es lo que Engram espera.
137
+ - **Migrar a MySQL** solo si el equipo ya tiene infra MySQL operativa (DBA, backups, monitoreo) y se confirma que Engram tiene driver MySQL compilado.