Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"demo:presupuesto": "npm run demo:presupuesto -w server",
"demo:turno-cortado": "npm run demo:turno-cortado -w server",
"demo:exportar": "npm run demo:exportar -w server",
"uso": "npm run uso -w server --"
"uso": "npm run uso -w server --",
"demo:variables": "npm run demo:variables -w server"
},
"engines": {
"node": ">=22"
Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"demo:imagenes": "tsx src/demos/imagenes.ts",
"demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts",
"demo:exportar": "tsx src/demos/exportar.ts",
"uso": "tsx --no-warnings src/demos/uso.ts"
"uso": "tsx --no-warnings src/demos/uso.ts",
"demo:variables": "tsx src/demos/variables.ts"
},
"dependencies": {
"@fastify/cors": "^11.3.0",
Expand Down
84 changes: 84 additions & 0 deletions server/src/demos/variables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../engine/workspace.js";
import { guardarVariables, leerVariables } from "../engine/env.js";

/**
* Demo: las variables del proyecto de una sala (su `.env`).
* Uso: npm run demo:variables
*
* Lo que importa comprobar aquí es que el archivo quede LEGIBLE: lo van a leer
* el proyecto (con el cargador de su stack) y el agente (con read_file). Un
* nombre con espacios o un valor con salto de línea rompen el formato, y la app
* dejaría de arrancar por una variable que alguien tecleó mal en el panel.
*
* No necesita red ni servidor: prueba el módulo que escribe el archivo.
*/

let pass = 0;
let fail = 0;
function check(name: string, ok: boolean, detail = ""): void {
if (ok) {
pass++;
console.log(` [ok] ${name}`);
} else {
fail++;
console.log(` [X] ${name} ${detail}`);
}
}

async function main() {
console.log("\n=== variables del proyecto ===\n");

const ws = await createWorkspace("demo-variables", { clean: true });

console.log("1. Una sala nueva no tiene ninguna");
check("empieza vacía", (await leerVariables(ws.dir)).length === 0);

console.log("\n2. Se guardan y se vuelven a leer");
await guardarVariables(ws.dir, [
{ nombre: "SUPABASE_URL", valor: "https://abc.supabase.co" },
{ nombre: "SUPABASE_ANON_KEY", valor: "ey.Jhb-Gci_OiJ" },
]);
const leidas = await leerVariables(ws.dir);
check("son dos", leidas.length === 2, String(leidas.length));
check("el valor sobrevive entero", leidas[0].valor === "https://abc.supabase.co", leidas[0].valor);

console.log("\n3. El archivo se ve como un .env de verdad");
const texto = await readFile(join(ws.dir, ".env"), "utf8");
check("formato NOMBRE=valor", texto.includes('SUPABASE_URL="https://abc.supabase.co"'));
check("lleva una explicación arriba", texto.startsWith("#"));

console.log("\n4. Lo que rompería el archivo se descarta");
await guardarVariables(ws.dir, [
{ nombre: "BUENA", valor: "si" },
{ nombre: "con espacios", valor: "no" },
{ nombre: "2EMPIEZA_CON_NUMERO", valor: "no" },
{ nombre: "SALTO", valor: "linea1\nlinea2" },
]);
const v2 = await leerVariables(ws.dir);
check("solo quedan las que un shell acepta", v2.length === 2, v2.map((v) => v.nombre).join(","));
check(
"el salto de línea se aplana en vez de partir la asignación",
v2.find((v) => v.nombre === "SALTO")?.valor === "linea1 linea2",
);

console.log("\n5. La lista que manda el panel es la que queda");
// Borrar es no mandarla: así el panel no necesita una operación aparte para
// quitar una variable.
await guardarVariables(ws.dir, [{ nombre: "UNICA", valor: "1" }]);
const v3 = await leerVariables(ws.dir);
check("las anteriores se fueron", v3.length === 1, v3.map((v) => v.nombre).join(","));

console.log("\n6. Y no entra al historial de la sala");
const gitignore = await readFile(join(ws.dir, ".gitignore"), "utf8");
check("el .env está ignorado", gitignore.includes(".env"));

console.log(`\n${pass} pasaron, ${fail} fallaron\n`);
process.exit(fail > 0 ? 1 : 0);
}

main().catch((err) => {
console.error("\ndemo falló:", err);
process.exit(1);
});
105 changes: 105 additions & 0 deletions server/src/engine/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";

/**
* Las variables de entorno del proyecto de una sala: su `.env`.
*
* Es lo que el proyecto necesita para conectarse a algo de fuera: la base de
* datos que trajo alguien, la key de un proveedor si la app usa IA, un token de
* pagos. Multi no las interpreta ni sabe qué significan; solo las escribe donde
* el proyecto las va a buscar.
*
* Van a un archivo y no a la base de Multi porque el proyecto las lee de ahí en
* tiempo de ejecución: cualquier stack sabe leer un `.env`, y el agente también,
* así que no hay que enseñarle nada nuevo.
*
* Son de la SALA, no de quien las puso: todos los que entren trabajan contra la
* misma base. El `.gitignore` del motor ya excluye `.env`, así que no entran a
* los commits ni viajan en el .zip.
*/

/** Solo lo que un shell acepta como nombre de variable. */
const NOMBRE_VALIDO = /^[A-Za-z_][A-Za-z0-9_]*$/;

/** Tope por valor. Una credencial larga cabe de sobra; un archivo pegado, no. */
const MAX_VALOR = 4000;

/** Cuántas caben. Más que esto y ya no es configuración, es otra cosa. */
const MAX_VARIABLES = 50;

export interface Variable {
nombre: string;
valor: string;
}

function rutaEnv(workspaceDir: string): string {
return join(workspaceDir, ".env");
}

/**
* Lee las variables del `.env` de la sala.
*
* Formato mínimo a propósito: `NOMBRE=valor`, una por línea. Se ignoran los
* comentarios y las líneas que no parezcan una asignación, para no romperse con
* un archivo que el agente haya escrito a su manera.
*/
export async function leerVariables(workspaceDir: string): Promise<Variable[]> {
const ruta = rutaEnv(workspaceDir);
if (!existsSync(ruta)) return [];

const texto = await readFile(ruta, "utf8").catch(() => "");
const vars: Variable[] = [];
for (const linea of texto.split("\n")) {
const limpia = linea.trim();
if (!limpia || limpia.startsWith("#")) continue;
const igual = limpia.indexOf("=");
if (igual <= 0) continue;
const nombre = limpia.slice(0, igual).trim();
if (!NOMBRE_VALIDO.test(nombre)) continue;
// Las comillas son del formato, no del valor: se quitan al leer y se
// vuelven a poner al escribir.
const valor = limpia.slice(igual + 1).trim().replace(/^["']|["']$/g, "");
vars.push({ nombre, valor });
}
return vars;
}

/**
* Escribe la lista completa de variables, pisando el `.env` anterior.
*
* Se escribe entero y no línea por línea porque el panel manda siempre la lista
* completa: lo que se ve ahí es lo que queda. Así borrar una variable es no
* mandarla, sin necesitar una operación aparte.
*/
export async function guardarVariables(
workspaceDir: string,
crudas: unknown,
): Promise<Variable[]> {
const lista = Array.isArray(crudas) ? crudas : [];
const vistos = new Set<string>();
const vars: Variable[] = [];

for (const v of lista) {
if (vars.length >= MAX_VARIABLES) break;
const nombre = typeof (v as Variable)?.nombre === "string" ? (v as Variable).nombre.trim() : "";
const valor = typeof (v as Variable)?.valor === "string" ? (v as Variable).valor : "";
// Un nombre inválido rompería el archivo para todas las demás, así que se
// descarta en vez de escribirlo y dejar el `.env` sin poder leerse.
if (!NOMBRE_VALIDO.test(nombre) || vistos.has(nombre)) continue;
vistos.add(nombre);
// Los saltos de línea partirían la asignación en dos y la segunda mitad
// quedaría suelta en el archivo.
vars.push({ nombre, valor: valor.replace(/[\r\n]/g, " ").slice(0, MAX_VALOR) });
}

const cabecera = [
"# Las variables del proyecto de esta sala.",
"# Las escribe Multi desde el panel de la barra de arriba; se pueden editar",
"# aquí también, pero el panel las vuelve a escribir cuando alguien lo use.",
"",
];
const cuerpo = vars.map((v) => `${v.nombre}="${v.valor}"`);
await writeFile(rutaEnv(workspaceDir), [...cabecera, ...cuerpo, ""].join("\n"), "utf8");
return vars;
}
35 changes: 35 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "./engine/adjuntos.js";
import { MAX_AGENTS_PER_ROOM, resumenDeOtros } from "./engine/agents.js";
import { fileMutation } from "./engine/file-mutation.js";
import { leerVariables, guardarVariables } from "./engine/env.js";
import { startTurn, commitTurn, failTurnConCommit } from "./engine/turns.js";
import {
commitAll,
Expand Down Expand Up @@ -313,6 +314,40 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/export/estado", async (req,
};
});

/**
* Las variables de entorno del proyecto de la sala.
*
* GET devuelve las que hay; PUT reemplaza la lista completa (lo que manda el
* panel es lo que queda, así que borrar es no mandarla).
*
* Van por HTTP y no por el chat a propósito: el chat se guarda en la BD, se le
* muestra a todos y se manda al modelo. Una credencial ahí quedaría escrita para
* siempre. Aquí solo van al `.env` del workspace, que el .gitignore ya excluye.
*
* Lo que SÍ hay que saber: el agente puede leer ese archivo, porque el proyecto
* necesita conectarse. Quien las pone las está compartiendo con la sala, y eso
* lo dice el panel.
*/
fastify.get<{ Params: { id: string } }>("/rooms/:id/env", async (req, reply) => {
const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
if (!room) return reply.code(404).send({ error: "sala no encontrada" });
return { variables: await leerVariables(room.workspace.dir) };
});

fastify.put<{ Params: { id: string }; Body: { variables?: unknown } }>(
"/rooms/:id/env",
async (req, reply) => {
const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
if (!room) return reply.code(404).send({ error: "sala no encontrada" });

const variables = await guardarVariables(room.workspace.dir, req.body?.variables);
// A la sala se le dice CUÁNTAS quedaron, nunca sus valores: el aviso es para
// que nadie se pregunte por qué el proyecto cambió de comportamiento solo.
io.to(room.id).emit("env:changed", { cuantas: variables.length });
return { variables };
},
);

/**
* Borrar una sala: su proyecto, su contenedor y su historial.
*
Expand Down
4 changes: 4 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MentionMenu } from "./MentionMenu.js";
import { Historial } from "./Historial.js";
import { BackCanvas, type Endpoint } from "./BackCanvas.js";
import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js";
import { EnvPanel } from "./EnvPanel.js";
import { useTextos } from "./i18n.js";
import { MenuSalas } from "./MenuSalas.js";
import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js";
Expand Down Expand Up @@ -892,6 +893,9 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) {
>
{zipAviso ?? t.descargarZip}
</button>
{/* Las variables son del proyecto de la sala, así que sin sala no
hay dónde escribirlas. */}
{roomId && <EnvPanel roomId={roomId} />}
{/* Sin sala no hay link que compartir: copiaría la URL pelada. */}
<button className="invitar" onClick={copyLink} disabled={!roomId}>
{t.copiarLink}
Expand Down
Loading
Loading