diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2d57ba7..488cd95 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,7 @@ jobs:
- run: npm run demo:internet
- run: npm run demo:publicar
- run: npm run demo:proxy-rutas
+ - run: npm run demo:remix
- run: npm run demo:documento
- run: npm run demo:doc-sync
- run: npm run demo:comentarios
diff --git a/package.json b/package.json
index 075aa49..e1737c3 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"demo:agentes-se-ven": "npm run demo:agentes-se-ven -w server",
"demo:imagenes": "npm run demo:imagenes -w server",
"demo:proxy-rutas": "npm run demo:proxy-rutas -w server",
+ "demo:remix": "npm run demo:remix -w server",
"demo:providers": "npm run demo:providers -w server",
"dev:test": "npm run dev:test -w server",
"demo:presupuesto": "npm run demo:presupuesto -w server",
diff --git a/server/package.json b/server/package.json
index 203a52d..8d20e73 100644
--- a/server/package.json
+++ b/server/package.json
@@ -26,6 +26,7 @@
"demo:turno-cortado": "tsx src/demos/turno-cortado.ts",
"demo:imagenes": "tsx src/demos/imagenes.ts",
"demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts",
+ "demo:remix": "tsx src/demos/remix.ts",
"demo:exportar": "tsx src/demos/exportar.ts",
"uso": "tsx --no-warnings src/demos/uso.ts",
"demo:variables": "tsx src/demos/variables.ts",
diff --git a/server/src/demos/remix.ts b/server/src/demos/remix.ts
new file mode 100644
index 0000000..42a7103
--- /dev/null
+++ b/server/src/demos/remix.ts
@@ -0,0 +1,116 @@
+import { existsSync } from "node:fs";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+/**
+ * Demo: el remix de una sala.
+ * Uso: npm run demo:remix
+ *
+ * Un remix es una sala nueva con una copia del proyecto, ligada al original.
+ * Lo que se revisa es lo que importa de una copia: que traiga el código, que NO
+ * traiga los secretos (cualquiera con el link puede hacer un remix), que quede
+ * ligada para "Volver al original", y que cambiarla no toque el original.
+ *
+ * Sin Docker ni red: salas de verdad, con una base y workspaces temporales.
+ */
+
+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("=== Remix de una sala ===\n");
+ const raiz = await mkdtemp(join(tmpdir(), "multi-remix-"));
+ // Antes de importar: la base se abre con lo que diga MULTI_DB, y sin
+ // contenedor, para que la sala no intente levantar uno.
+ process.env.MULTI_DB = join(raiz, "multi.db");
+ process.env.MULTI_SIN_AISLAMIENTO = "1";
+ const { createRoom, createRemix, NadaQueRemixear } = await import("../rooms.js");
+ const { commitAll } = await import("../engine/git.js");
+ const { getStorage } = await import("../storage/index.js");
+ const storage = await getStorage();
+ const creadas: string[] = [];
+
+ try {
+ console.log("1. El remix trae el código guardado");
+ const original = await createRoom();
+ creadas.push(original.workspace.dir);
+ const dir = original.workspace.dir;
+ await mkdir(join(dir, "src"), { recursive: true });
+ await mkdir(join(dir, "node_modules", "algo"), { recursive: true });
+ await writeFile(join(dir, "index.html"), "
Box
\n");
+ await writeFile(join(dir, "src", "juego.js"), "export const golpe = 10;\n");
+ await writeFile(join(dir, ".env"), "SECRETO=no-se-copia\n");
+ await writeFile(join(dir, "node_modules", "algo", "index.js"), "// pesado\n");
+ await writeFile(join(dir, "datos.db"), "binario de prueba");
+ await commitAll(dir, { message: "el juego", author: "agente-1" });
+ // Algo escrito después del último guardado no entra: el remix es de lo guardado.
+ await writeFile(join(dir, "sin-guardar.txt"), "a medias\n");
+
+ const remix = await createRemix(original);
+ creadas.push(remix.workspace.dir);
+ const rdir = remix.workspace.dir;
+ check("es otra sala, con otro workspace", remix.id !== original.id && rdir !== dir);
+ check("trae los archivos del proyecto", existsSync(join(rdir, "index.html")) && existsSync(join(rdir, "src", "juego.js")));
+ check("con el mismo contenido", (await readFile(join(rdir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n");
+
+ console.log("\n2. Lo que no se copia");
+ check("no trae el .env (los secretos no se regalan)", !existsSync(join(rdir, ".env")));
+ check("ni node_modules", !existsSync(join(rdir, "node_modules")));
+ check("ni la base del proyecto", !existsSync(join(rdir, "datos.db")));
+ check("ni lo que no estaba guardado", !existsSync(join(rdir, "sin-guardar.txt")));
+
+ console.log("\n3. Queda ligado a su original");
+ check("sabe de qué es remix", remix.origenId === original.id);
+ check("y se llama como el original con (remix)", remix.nombre === `${original.id} (remix)`, String(remix.nombre));
+ const guardada = await storage.getRoom(remix.id);
+ check("el vínculo se guarda en la base", guardada?.origenId === original.id, JSON.stringify(guardada));
+ check("y el nombre también", guardada?.nombre === `${original.id} (remix)`);
+ const otro = await createRemix(original);
+ creadas.push(otro.workspace.dir);
+ check("el segundo se numera para distinguirlo", otro.nombre === `${original.id} (remix 2)`, String(otro.nombre));
+ const lista = await storage.remixesDe(original.id);
+ check("el original lista sus remixes", lista.length === 2 && lista.some((r) => r.id === remix.id) && lista.some((r) => r.id === otro.id), JSON.stringify(lista));
+ check("una sala normal no es remix de nada", (await storage.getRoom(original.id))?.origenId == null);
+
+ console.log("\n4. Cambiar el remix no toca el original");
+ await writeFile(join(rdir, "src", "juego.js"), "export const golpe = 99;\n");
+ await commitAll(rdir, { message: "más fuerte", author: "agente-1" });
+ check("el original sigue igual", (await readFile(join(dir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n");
+ const { stdout } = await import("node:child_process").then(({ execFileSync }) => ({
+ stdout: execFileSync("git", ["log", "--format=%s"], { cwd: rdir, encoding: "utf8" }),
+ }));
+ check("el remix tiene su propio historial", stdout.trim().split("\n").join("|") === `más fuerte|Remix de ${original.id}`, stdout);
+
+ console.log("\n5. Una sala sin nada guardado no se puede remixear");
+ const vacia = await createRoom();
+ creadas.push(vacia.workspace.dir);
+ let error = "";
+ try {
+ await createRemix(vacia);
+ } catch (err) {
+ error = err instanceof NadaQueRemixear ? err.message : `otro error: ${String(err)}`;
+ }
+ check("lo dice claro", error.includes("no tiene nada guardado"), error);
+ } finally {
+ for (const d of creadas) await rm(d, { recursive: true, force: true });
+ await rm(raiz, { recursive: true, force: true });
+ }
+
+ console.log(`\n${pass} pasaron, ${fail} fallaron`);
+ process.exit(fail > 0 ? 1 : 0);
+}
+
+main().catch((err) => {
+ console.error("demo falló:", err);
+ process.exit(1);
+});
diff --git a/server/src/index.ts b/server/src/index.ts
index 585d73d..fbd9075 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -10,6 +10,8 @@ import { existsSync } from "node:fs";
import {
createRoom,
+ createRemix,
+ NadaQueRemixear,
getRoom,
allRooms,
addMember,
@@ -533,6 +535,32 @@ fastify.post("/rooms", async () => {
return { id: room.id };
});
+/**
+ * Hacer un remix: una copia del proyecto en una sala nueva, ligada a esta.
+ *
+ * Responde el id y la Sala navega ahí. A la sala original se le avisa, para que
+ * su lista de remixes se actualice sin recargar, y queda una línea en su chat.
+ * Bajo /rooms, como el export, para que el proxy del preview no se lo quede.
+ */
+fastify.post<{ Params: { id: string }; Body: { autor?: unknown } | undefined }>(
+ "/rooms/:id/remix",
+ async (req, reply) => {
+ const origen = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
+ if (!origen) return reply.code(404).send({ error: "sala no encontrada" });
+ try {
+ const remix = await createRemix(origen);
+ const autor = typeof req.body?.autor === "string" ? req.body.autor.trim().slice(0, 40) : "";
+ io.to(origen.id).emit("room:remixes", { remixes: await (await getStorage()).remixesDe(origen.id) });
+ systemMsg(origen, autor ? `${autor} hizo un remix de este proyecto` : "alguien hizo un remix de este proyecto");
+ return { id: remix.id };
+ } catch (err) {
+ if (err instanceof NadaQueRemixear) return reply.code(409).send({ error: err.message });
+ console.error(`[sala ${origen.id}] no se pudo hacer el remix:`, err);
+ return reply.code(500).send({ error: "no se pudo hacer el remix" });
+ }
+ },
+);
+
// La línea de tiempo de una sala (alimenta el scrubber).
fastify.get<{ Params: { id: string } }>("/rooms/:id/history", async (req, reply) => {
const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
@@ -1746,6 +1774,10 @@ io.on("connection", (socket) => {
// Dónde está publicada, si lo está: es un dato de la sala, no una noticia
// de hace rato. Quien entra mañana tiene que poder abrirla igual.
urlPublicada: room.urlPublicada ?? null,
+ // De qué sala es remix, y los remixes de esta: para "Volver al original"
+ // y la lista para brincar entre versiones.
+ origen: await origenDe(room),
+ remixes: await (await getStorage()).remixesDe(room.id),
nombrePublicado: room.nombrePublicado ?? null,
agents: room.agents.list(),
actividad: Object.fromEntries(ultimaAccion.get(room.id) ?? []),
@@ -2588,6 +2620,16 @@ async function say(
}
}
+/** La sala de la que esta es remix, con su nombre, o null. */
+async function origenDe(room: Room): Promise<{ id: string; nombre: string | null } | null> {
+ if (!room.origenId) return null;
+ const vivo = getRoom(room.origenId);
+ if (vivo) return { id: vivo.id, nombre: vivo.nombre ?? null };
+ const guardado = await (await getStorage()).getRoom(room.origenId);
+ // El original pudo haberse borrado: entonces el remix ya no tiene a dónde volver.
+ return guardado ? { id: guardado.id, nombre: guardado.nombre ?? null } : null;
+}
+
function systemMsg(room: Room, text: string, color = "#a9abd0"): void {
void say(room, { from: "sistema", color, role: "system", text });
}
diff --git a/server/src/rooms.ts b/server/src/rooms.ts
index a7c2ee4..4c7394f 100644
--- a/server/src/rooms.ts
+++ b/server/src/rooms.ts
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace, homeDir, type Workspace } from "./engine/workspace.js";
+import { extractTo, commitAll, hayCommits } from "./engine/git.js";
import { startPreview, detectLaunch, type Preview } from "./engine/preview.js";
import {
isDockerAvailable,
@@ -93,6 +94,8 @@ export interface Room {
* elegir (esa se llama como su id).
*/
nombrePublicado?: string | null;
+ /** La sala de la que esta es remix (ver createRemix). Null si no lo es. */
+ origenId?: string | null;
/**
* Si en esta sala hay que mencionar al agente para despertarlo.
*
@@ -285,6 +288,7 @@ async function despertarSala(id: string): Promise {
nombre: stored.nombre ?? null,
urlPublicada: stored.urlPublicada ?? null,
nombrePublicado: stored.nombrePublicado ?? null,
+ origenId: stored.origenId ?? null,
// Las salas anteriores a esta columna vuelven en multi, que es como se
// comportaron siempre: nada cambia debajo de quien ya las usaba.
modo: stored.modo ?? "multi",
@@ -874,6 +878,72 @@ export async function renameRoom(room: Room, crudo: unknown): Promise {
+ if (!(await hayCommits(origen.workspace.dir))) {
+ throw new NadaQueRemixear("esta sala todavía no tiene nada guardado que copiar");
+ }
+ const storage = await getStorage();
+ let id = genId();
+ while (rooms.has(id) || (await storage.getRoom(id))) id = genId();
+
+ const workspace = await createWorkspace(id, { clean: true });
+ await extractTo(origen.workspace.dir, "HEAD", workspace.dir);
+ const deQuien = origen.nombre ?? origen.id;
+ await commitAll(workspace.dir, { message: `Remix de ${deQuien}`, author: "Multti" });
+
+ // Numerados para que se distingan en la lista: "(remix)", "(remix 2)"…
+ const cuantos = (await storage.remixesDe(origen.id)).length;
+ const sufijo = cuantos === 0 ? " (remix)" : ` (remix ${cuantos + 1})`;
+
+ const room: Room = {
+ id,
+ nombre: `${deQuien.slice(0, MAX_NOMBRE_SALA - sufijo.length)}${sufijo}`,
+ origenId: origen.id,
+ modo: "solo",
+ workspace,
+ preview: null,
+ members: new Map(),
+ histories: new Map(),
+ selections: new Map(),
+ agents: new AgentRegistry(),
+ coordinator: new RunCoordinator(),
+ vaciaDesde: Date.now(),
+ };
+ rooms.set(id, room);
+
+ const now = Date.now();
+ await storage.createRoom({
+ id,
+ workspaceDir: workspace.dir,
+ createdAt: now,
+ lastActiveAt: now,
+ modo: room.modo,
+ origenId: origen.id,
+ });
+ await storage.renameRoom(id, room.nombre ?? null);
+
+ void bootPreview(room);
+ return room;
+}
+
/**
* Cambia si en esta sala hay que mencionar al agente.
*
diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts
index ff9ca63..283e9fe 100644
--- a/server/src/storage/sqlite.ts
+++ b/server/src/storage/sqlite.ts
@@ -7,6 +7,7 @@ import type {
ConexionSupabase,
ModoDeSala,
SalaDeUsuario,
+ RemixDeSala,
Storage,
StoredMessage,
StoredRoom,
@@ -47,6 +48,8 @@ export class SqliteStorage implements Storage {
-- Con qué nombre se publicó (nombre.pages.dev). Null en las salas que
-- publicaron antes de esta columna: su proyecto se llama como su id.
nombre_publicado TEXT,
+ -- La sala de la que es remix. Null en las salas normales.
+ origen_id TEXT,
-- Si hay que mencionar al agente. Null es "multi": las salas de antes
-- de esta columna siguen comportándose igual que siempre.
modo TEXT
@@ -153,6 +156,13 @@ export class SqliteStorage implements Storage {
// ya la tiene
}
+ // De qué sala es remix, para el "Volver al original" y la lista de remixes.
+ try {
+ this.db.exec(`ALTER TABLE rooms ADD COLUMN origen_id TEXT`);
+ } catch {
+ // ya la tiene
+ }
+
// El nombre que la gente le pone a la sala. Nullable a propósito: sin él la
// interfaz cae al id, que es lo que se ha visto siempre.
try {
@@ -228,10 +238,21 @@ export class SqliteStorage implements Storage {
async createRoom(room: StoredRoom): Promise {
this.db
.prepare(
- `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo)
- VALUES (?, ?, ?, ?, ?)`,
+ `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo, origen_id)
+ VALUES (?, ?, ?, ?, ?, ?)`,
)
- .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null);
+ .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null, room.origenId ?? null);
+ }
+
+ async remixesDe(id: string): Promise {
+ const filas = this.db
+ .prepare(`SELECT id, nombre, created_at FROM rooms WHERE origen_id = ? ORDER BY created_at DESC`)
+ .all(id) as Record[];
+ return filas.map((r) => ({
+ id: String(r.id),
+ nombre: r.nombre == null ? null : String(r.nombre),
+ createdAt: Number(r.created_at),
+ }));
}
async getRoom(id: string): Promise {
@@ -644,6 +665,7 @@ function toRoom(r: Record): StoredRoom {
nombre: r.nombre == null ? null : String(r.nombre),
urlPublicada: r.url_publicada == null ? null : String(r.url_publicada),
nombrePublicado: r.nombre_publicado == null ? null : String(r.nombre_publicado),
+ origenId: r.origen_id == null ? null : String(r.origen_id),
// Solo "solo" cuenta como tal: null y cualquier cosa rara caen a multi, que
// es el comportamiento que ya tenían todas las salas.
modo: r.modo === "solo" ? "solo" : "multi",
diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts
index acf2f65..ccbf747 100644
--- a/server/src/storage/types.ts
+++ b/server/src/storage/types.ts
@@ -29,6 +29,8 @@ export interface StoredRoom {
* antes de que se pudiera elegir: su proyecto se llama como su id.
*/
nombrePublicado?: string | null;
+ /** La sala de la que esta es remix (ver createRemix en rooms.ts). Null si no lo es. */
+ origenId?: string | null;
/**
* Si en esta sala hay que mencionar al agente para despertarlo.
*
@@ -132,6 +134,13 @@ export interface ConexionSupabase {
}
/** Una sala que alguien con cuenta visitó, para que le siga entre dispositivos. */
+/** Un remix de una sala, para la lista de la Sala. */
+export interface RemixDeSala {
+ id: string;
+ nombre: string | null;
+ createdAt: number;
+}
+
export interface SalaDeUsuario {
roomId: string;
visitadaEn: number;
@@ -189,6 +198,8 @@ export interface Storage {
* segunda en publicar le borraría la app a la primera.
*/
nombreEnUso(nombre: string, salaId: string): Promise;
+ /** Los remixes de una sala, del más nuevo al más viejo. */
+ remixesDe(id: string): Promise;
/** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */
renameRoom(id: string, nombre: string | null): Promise;
/** Si en esta sala hay que mencionar al agente para despertarlo. */
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2f90791..bc1c257 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -29,6 +29,7 @@ import { Historial } from "./Historial.js";
import { loadStoredCredencial } from "./KeyPanel.js";
import { EnvPanel, type EstadoSupabase } from "./EnvPanel.js";
import { PublicarPanel } from "./PublicarPanel.js";
+import { VersionesSala, type OrigenDeSala, type RemixDeSala } from "./Remix.js";
import { useTextos, type Textos } from "./i18n.js";
import { MenuSalas } from "./MenuSalas.js";
import { recordarSala, olvidarSala, recordarNombre, guardarSalas, siguienteLlave } from "./historial-salas.js";
@@ -646,6 +647,10 @@ function Sala({
* de la sala, no un mensaje de hace rato.
*/
const [urlPublicada, setUrlPublicada] = useState(null);
+ /** De qué sala es remix esta (para "Volver al original"), y los remixes de esta. */
+ const [origen, setOrigen] = useState(null);
+ const [remixes, setRemixes] = useState([]);
+ const [haciendoRemix, setHaciendoRemix] = useState(false);
/** Por qué no se aceptó el nombre que se escribió para publicar. */
const [errorDeNombre, setErrorDeNombre] = useState(null);
/**
@@ -1041,6 +1046,8 @@ function Sala({
recordarNombre(roomId, p.nombre ?? null);
setPublicando(p.publicando ?? null);
setUrlPublicada(p.urlPublicada ?? null);
+ setOrigen(p.origen ?? null);
+ setRemixes(p.remixes ?? []);
if (p.previewUrl) setPreviewReady(true);
if (p.agents) setAgents(p.agents);
// Lo que se pintó en vivo antes de este `joined` puede haber caducado: en
@@ -1176,6 +1183,8 @@ function Sala({
setUrlPublicada(url);
});
socket.on("deploy:fallo", () => setPublicando(null));
+ // Alguien hizo un remix de esta sala: la lista se actualiza sin recargar.
+ socket.on("room:remixes", ({ remixes }: { remixes: RemixDeSala[] }) => setRemixes(remixes));
socket.on("preview:ready", ({ relanzada }: { relanzada?: boolean }) => {
setNovedadEnPreview(true);
if (relanzada) setRelanzadas((n) => n + 1);
@@ -1728,6 +1737,32 @@ function Sala({
}
};
+ /**
+ * Hacer un remix: una copia del proyecto para experimentar sin tocar el
+ * original. El server arma la sala y aquí solo se navega a ella.
+ */
+ const hacerRemix = async () => {
+ if (!roomId || haciendoRemix) return;
+ setHaciendoRemix(true);
+ try {
+ const r = await fetch(`${SERVER_URL}/rooms/${roomId}/remix`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ autor: yo?.name ?? "" }),
+ });
+ const d = (await r.json().catch(() => null)) as { id?: string; error?: string } | null;
+ if (!r.ok || !d?.id) {
+ alert(d?.error ?? t.remixFallo);
+ return;
+ }
+ window.location.hash = `#/sala/${d.id}`;
+ } catch {
+ alert(t.remixFallo);
+ } finally {
+ setHaciendoRemix(false);
+ }
+ };
+
/**
* Crear una sala en blanco. El `+` de la cabecera.
*
@@ -1901,6 +1936,8 @@ function Sala({
+ {roomId && }
+
{/* Los agentes de la sala, como jugadores visibles */}
@@ -2427,6 +2464,16 @@ function Sala({
versionVariables={versionVariables}
/>
)}
+ {/* Una copia para experimentar sin tocar el original. Sin nada
+ guardado no hay qué copiar. */}
+
{/* Sin sala no hay link que compartir: copiaría la URL pelada. */}