diff --git a/server/src/demos/publicar.ts b/server/src/demos/publicar.ts index 0ea6b59..fdf96d9 100644 --- a/server/src/demos/publicar.ts +++ b/server/src/demos/publicar.ts @@ -2,7 +2,7 @@ import { writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { createWorkspace } from "../engine/workspace.js"; import { detectBuild, buscarSalida } from "../engine/preview.js"; -import { credencialDeDeploy } from "../engine/publicar.js"; +import { credencialDeDeploy, motivoDeFallo } from "../engine/publicar.js"; /** * Demo: qué se compila y qué se sube al publicar la app de una sala. @@ -84,6 +84,30 @@ async function main() { check("con las dos ya sirve", credencialDeDeploy() !== null); process.env = antes; + console.log("\n8. Cuando falla, se dice POR QUÉ"); + { + /** + * El bug que cubre: wrangler cierra su salida con una barra de guiones y la + * ruta de su log, así que quedarse con la última línea daba un mensaje que + * decía "────────────────────" y nada más. Pasó en el primer deploy real y + * dejó sin ver el error, que era que el proyecto no existía todavía. + */ + const salidaDeWrangler = [ + " ⛅️ wrangler 4.123.0", + "────────────────────", + "", + '✘ [ERROR] The Pages project "una-sala" does not exist.', + "", + " Maybe you intended to deploy a Worker project instead?", + "", + '🪵 Logs were written to "/work/.multi-home/.config/.wrangler/logs/x.log"', + ].join("\n"); + + const dicho = motivoDeFallo(salidaDeWrangler); + check("saca el error de verdad", dicho.includes("does not exist"), dicho); + check("y no la barra decorativa", !dicho.includes("─"), dicho); + } + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); process.exit(fail > 0 ? 1 : 0); } diff --git a/server/src/engine/publicar.ts b/server/src/engine/publicar.ts index 82349bc..97177da 100644 --- a/server/src/engine/publicar.ts +++ b/server/src/engine/publicar.ts @@ -29,6 +29,19 @@ export interface Credencial { export class NoSePudoPublicar extends Error {} +/** + * La credencial como variables del comando. + * + * Por aquí y no interpolada en el comando: así no queda a la vista en la lista + * de procesos de adentro del contenedor. + */ +function variablesDe(cred: Credencial): Record { + return { + CLOUDFLARE_API_TOKEN: cred.token, + CLOUDFLARE_ACCOUNT_ID: cred.accountId, + }; +} + /** * Compila el proyecto y lo sube. Devuelve la URL pública. * @@ -65,7 +78,7 @@ export async function publicarSala( throw new NoSePudoPublicar( compilado.timedOut ? "el proyecto tardó demasiado en compilar" - : `el proyecto no compila: ${ultimaLinea(compilado.stderr || compilado.stdout)}`, + : `el proyecto no compila: ${motivoDeFallo(compilado.stderr || compilado.stdout)}`, ); } @@ -89,18 +102,31 @@ export async function publicarSala( const proyecto = workspace.roomId.replace(/[^a-z0-9-]/gi, "").toLowerCase().slice(0, 58); onEtapa?.("subiendo"); + + /** + * Crear el proyecto antes de subir, porque `deploy` no lo crea solo: la + * primera publicación de una sala fallaba con "The Pages project does not + * exist". + * + * Se intenta siempre y se ignora el resultado: si ya existe, el comando falla + * y da igual — lo que importa es que exista cuando `deploy` llegue. Preguntar + * primero si está sería una llamada más para el mismo desenlace. + */ + await runner + .exec( + `${cd}npx --yes wrangler@latest pages project create ${JSON.stringify(proyecto)} ` + + `--production-branch=main`, + { timeoutMs: 120_000, maxOutput: 2000, env: variablesDe(cred) }, + ) + .catch(() => null); + const subido = await runner.exec( `${cd}npx --yes wrangler@latest pages deploy ${JSON.stringify(salida)} ` + `--project-name=${JSON.stringify(proyecto)} --commit-dirty=true`, { timeoutMs: 600_000, maxOutput: 8000, - // Por aquí y no interpolado en el comando: así no queda a la vista en la - // lista de procesos de adentro. - env: { - CLOUDFLARE_API_TOKEN: cred.token, - CLOUDFLARE_ACCOUNT_ID: cred.accountId, - }, + env: variablesDe(cred), }, ); @@ -126,10 +152,28 @@ function sacarUrl(salida: string): string | null { return m ? m[0] : null; } -/** Lo último que dijo un comando que falló, que es donde suele estar el motivo. */ -function ultimaLinea(texto: string): string { - const lineas = texto.trim().split("\n").filter(Boolean); - return (lineas[lineas.length - 1] ?? "sin detalle").slice(0, 150); +/** + * El motivo de un comando que falló. + * + * Se busca la última línea CON CONTENIDO, saltándose las decorativas: wrangler + * cierra su salida con una barra de guiones y una nota de dónde dejó el log, así + * que quedarse con la última literal daba un mensaje que solo decía + * "────────────────────". Pasó de verdad, y dejó sin ver el error real. + */ +export function motivoDeFallo(texto: string): string { + const lineas = texto + .trim() + .split("\n") + .map((l) => l.trim()) + // Fuera lo que no dice nada: separadores, banderas de versión, la ruta del + // log, y los códigos de color que wrangler mete en cada línea. + .map((l) => l.replace(/\[[0-9;]*m/g, "")) + .filter((l) => l && !/^[─━=*-]+$/.test(l) && !/^[🪵⛅]/u.test(l)); + + // Si hay una línea marcada como error, esa es la buena: suele estar antes de + // la explicación larga, no al final. + const error = lineas.find((l) => /^(✘|✖|x)?\s*\[?ERROR\]?/i.test(l)); + return (error ?? lineas[lineas.length - 1] ?? "sin detalle").slice(0, 200); } /** @@ -153,7 +197,7 @@ function explicarWrangler(texto: string): string { if (/ENOTFOUND|ETIMEDOUT|network|fetch failed/i.test(texto)) { return "no se pudo hablar con el servicio de publicación. Vuelve a intentar"; } - return `no se pudo publicar — ${ultimaLinea(texto)}`; + return `no se pudo publicar — ${motivoDeFallo(texto)}`; } /**