@ajuarezso/lienzo-mcp 0.3.0 → 0.4.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.
- package/README.en.md +139 -12
- package/README.md +180 -12
- package/index.mjs +106 -20
- package/lib/adjuntos.mjs +353 -0
- package/lib/canal.mjs +778 -0
- package/lib/config.mjs +7 -1
- package/lib/esperar.mjs +8 -3
- package/lib/lienzo.mjs +309 -29
- package/lib/storage.mjs +107 -0
- package/package.json +2 -2
package/lib/config.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// LIENZO_FIREBASE_CONFIG ruta a otro firebase.config.json (por defecto: el que viaja en el paquete de npm,
|
|
11
11
|
// y si no está, el de la raíz del repo)
|
|
12
12
|
// LIENZO_PROJECT_ID pisa el projectId (p. ej. demo-lienzo en el emulador)
|
|
13
|
-
//
|
|
13
|
+
// LIENZO_STORAGE_BUCKET pisa el bucket de los adjuntos (por defecto storageBucket de firebase.config.json; con
|
|
14
|
+
// LIENZO_PROJECT_ID distinto, <projectId>.firebasestorage.app)
|
|
15
|
+
// FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST / FIREBASE_STORAGE_EMULATOR_HOST usar los emuladores
|
|
14
16
|
// LIENZO_PRUEBAS_ANONIMAS=1 SOLO tests y e2e real: sin sesión guardada se crea una cuenta anónima (en el
|
|
15
17
|
// producto el agente entra con la cuenta de su persona: lienzo_entrar)
|
|
16
18
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -67,6 +69,10 @@ export function cargarConfig(env = process.env) {
|
|
|
67
69
|
webUrl: (archivo.webUrl || firebase.webUrl || `https://${projectId}.web.app`).replace(/\/+$/, ''),
|
|
68
70
|
emuladorFirestore: env.FIRESTORE_EMULATOR_HOST || null,
|
|
69
71
|
emuladorAuth: env.FIREBASE_AUTH_EMULATOR_HOST || null,
|
|
72
|
+
// adjuntos binarios (squad adjuntos): el bucket de Storage y su emulador
|
|
73
|
+
storageBucket: env.LIENZO_STORAGE_BUCKET
|
|
74
|
+
|| (projectId !== firebase.projectId || !firebase.storageBucket ? `${projectId}.firebasestorage.app` : firebase.storageBucket),
|
|
75
|
+
emuladorStorage: env.FIREBASE_STORAGE_EMULATOR_HOST || null,
|
|
70
76
|
pruebasAnonimas: env.LIENZO_PRUEBAS_ANONIMAS === '1',
|
|
71
77
|
};
|
|
72
78
|
}
|
package/lib/esperar.mjs
CHANGED
|
@@ -119,13 +119,18 @@ async function sondear(consultar, o, limite, consultasPrevias) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Respuesta de lienzo_leer / lienzo_esperar que no entregó todo. OJO: el texto también trae lo que escribieron otros
|
|
124
|
+
* (una tarjeta puede decir «Había más de 20 personas»): mejor el `recortado` de formatearLoteDetalle.
|
|
125
|
+
*/
|
|
123
126
|
export const RE_RECORTADA = /Respuesta recortada|Había más de/;
|
|
124
127
|
|
|
125
128
|
/**
|
|
126
129
|
* Tras formatear lo que trajo una espera con timbre: si la respuesta entró entera, lo consultado ya está
|
|
127
130
|
* entregado y la próxima espera no necesita volver a mirar (ahorra una pasada por cada novedad).
|
|
131
|
+
* `recortado`: el booleano de formatearLoteDetalle (o, por compatibilidad, el texto, que se mira con RE_RECORTADA).
|
|
128
132
|
*/
|
|
129
|
-
export function entregado(timbre, r,
|
|
130
|
-
|
|
133
|
+
export function entregado(timbre, r, recortado) {
|
|
134
|
+
const corto = typeof recortado === 'boolean' ? recortado : RE_RECORTADA.test(String(recortado ?? ''));
|
|
135
|
+
if (timbre && r?.modo === 'timbre' && r.version != null && !corto) timbre.visto = r.version;
|
|
131
136
|
}
|
package/lib/lienzo.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Operaciones de Lienzo sobre Firestore (contrato en docs/spec.md).
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, mkdirSync, writeFileSync, linkSync, rmSync } from 'node:fs';
|
|
5
|
+
import { basename, extname, join } from 'node:path';
|
|
5
6
|
import { Firestore, ErrorFirestore, filtro, y, marcaDeTiempo } from './firestore.mjs';
|
|
6
7
|
import { Sesion, ErrorSesion, abrirNavegador, ENTRAR_TIMEOUT_MS } from './sesion.mjs';
|
|
7
8
|
import { buscarSecretos, explicarBloqueo } from './secretos.mjs';
|
|
@@ -10,6 +11,12 @@ import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
|
|
|
10
11
|
import { INTERVALO_MS } from './esperar.mjs';
|
|
11
12
|
import { cerrarTimbre } from './escucha.mjs';
|
|
12
13
|
import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES } from './agentes.mjs';
|
|
14
|
+
import { Storage, ErrorStorage } from './storage.mjs';
|
|
15
|
+
import {
|
|
16
|
+
ErrorAdjunto, detectarTipo, revisarTamano, sha256, nombreEnStorage, mimeParaStorage, disposicion, carpetaDescargas,
|
|
17
|
+
nombreDescarga, nombreDeTipo, revisarBajado, revisarZip, imagenVisible, MAX_ADJUNTO_BYTES, MAX_IMAGEN_VISIBLE,
|
|
18
|
+
IMAGENES_VISIBLES, MB,
|
|
19
|
+
} from './adjuntos.mjs';
|
|
13
20
|
|
|
14
21
|
export const MAX_TEXTO = 20000;
|
|
15
22
|
export const MAX_ARCHIVO_BYTES = 200 * 1024;
|
|
@@ -175,6 +182,8 @@ export class Lienzo {
|
|
|
175
182
|
this.entrada = null; // login en curso: { promesa, url }
|
|
176
183
|
this.sesion = new Sesion(config, { fetch: f });
|
|
177
184
|
this.fs = new Firestore(config, this.sesion, { fetch: f });
|
|
185
|
+
this.storage = new Storage(config, this.sesion, { fetch: f });
|
|
186
|
+
this.accesoEn = null; // en qué sala ya se escribió accesos/{uid} (lo que storage.rules mira para dejar leer y subir)
|
|
178
187
|
this.salaId = null;
|
|
179
188
|
this.yo = null; // { id, nombre, dueño }
|
|
180
189
|
this.marca = null; // creadaEn más reciente ya entregada
|
|
@@ -183,6 +192,7 @@ export class Lienzo {
|
|
|
183
192
|
this.intervaloEspera = INTERVALO_MS; // lo que lleva creciendo la espera de lienzo_esperar
|
|
184
193
|
this.participantes = new Map();
|
|
185
194
|
this.participantesEn = 0;
|
|
195
|
+
this._ausentes = new Map(); // id → cuándo se confirmó que no está en la sala (no se recarga todo por él cada vez)
|
|
186
196
|
this.tareasPedidas = new Map(); // id → { estado, respuesta, tomadaPor }
|
|
187
197
|
this.tareasCargadas = false;
|
|
188
198
|
this.listas = new Set(); // ids de las listas que pediste (sus puntos llegan agrupados)
|
|
@@ -237,7 +247,12 @@ export class Lienzo {
|
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
_traducir(e, que) {
|
|
240
|
-
if (e instanceof ErrorSesion || e instanceof ErrorArchivo) return new ErrorLienzo(e.message);
|
|
250
|
+
if (e instanceof ErrorSesion || e instanceof ErrorArchivo || e instanceof ErrorAdjunto) return new ErrorLienzo(e.message);
|
|
251
|
+
if (e instanceof ErrorStorage) {
|
|
252
|
+
if (e.negado) return new ErrorLienzo(`Storage no permite ${que}. (${e.message})`, { cause: e });
|
|
253
|
+
if (e.noExiste) return new ErrorLienzo(`No está el archivo en Storage: ${que}. Si recién se compartió, prueba en unos segundos.`, { cause: e });
|
|
254
|
+
return new ErrorLienzo(`No se pudo ${que}: ${e.message}`, { cause: e });
|
|
255
|
+
}
|
|
241
256
|
if (e instanceof ErrorFirestore) {
|
|
242
257
|
// la causa viaja: el catch de cada herramienta decide si el permiso negado tiene una explicación (explicarNegado)
|
|
243
258
|
if (e.negado) return new ErrorLienzo(`Las reglas de la sala no permiten ${que}. (${e.message})`, { cause: e });
|
|
@@ -345,8 +360,19 @@ export class Lienzo {
|
|
|
345
360
|
|
|
346
361
|
async _participante(id) {
|
|
347
362
|
if (!id) return null;
|
|
348
|
-
if (
|
|
349
|
-
|
|
363
|
+
if (this.participantes.has(id)) return this.participantes.get(id);
|
|
364
|
+
// Un id que no está fuerza una recarga (quien entró recién). Pero si ya se confirmó hace poco que no está (alguien
|
|
365
|
+
// que salió, o un paraId inventado), no se vuelve a leer la sala entera (hasta 500) por cada tarjeta que lo nombre.
|
|
366
|
+
const ausente = this._ausentes.get(id);
|
|
367
|
+
if (ausente != null && Date.now() - ausente < RECARGA_PARTICIPANTES_MS) return null;
|
|
368
|
+
await this._cargarParticipantes(true);
|
|
369
|
+
const p = this.participantes.get(id) || null;
|
|
370
|
+
if (p) this._ausentes.delete(id);
|
|
371
|
+
else {
|
|
372
|
+
if (this._ausentes.size >= 500) this._ausentes.clear();
|
|
373
|
+
this._ausentes.set(id, Date.now());
|
|
374
|
+
}
|
|
375
|
+
return p;
|
|
350
376
|
}
|
|
351
377
|
|
|
352
378
|
/** Cuántos participantes de la sala se llaman igual que `id` (1 = nombre único). */
|
|
@@ -468,6 +494,8 @@ export class Lienzo {
|
|
|
468
494
|
|
|
469
495
|
this.salasPerdidas.delete(salaId); // un lienzo_unirse explícito vuelve a abrir la sala
|
|
470
496
|
this.ultimaPerdida = null;
|
|
497
|
+
// accesos/{uid} se vuelve a escribir: pudo borrarse al salir desde la web, o la cuenta es otra
|
|
498
|
+
this.accesoEn = null;
|
|
471
499
|
const cambioDeSala = this.salaId !== salaId || this.uidEnSala !== this.uid;
|
|
472
500
|
this.salaId = salaId;
|
|
473
501
|
this.uidEnSala = this.uid;
|
|
@@ -483,6 +511,7 @@ export class Lienzo {
|
|
|
483
511
|
this.listas.clear();
|
|
484
512
|
this.participantes.clear();
|
|
485
513
|
this.participantesEn = 0;
|
|
514
|
+
this._ausentes.clear();
|
|
486
515
|
}
|
|
487
516
|
await this._anotarEnIndice(salaId);
|
|
488
517
|
return { salaId, sala, link: this.link(salaId), yo: this.yo };
|
|
@@ -504,6 +533,7 @@ export class Lienzo {
|
|
|
504
533
|
this.salaId = null;
|
|
505
534
|
this.yo = null;
|
|
506
535
|
this.uidEnSala = null;
|
|
536
|
+
this.accesoEn = null;
|
|
507
537
|
}
|
|
508
538
|
|
|
509
539
|
/**
|
|
@@ -634,8 +664,7 @@ export class Lienzo {
|
|
|
634
664
|
};
|
|
635
665
|
}
|
|
636
666
|
|
|
637
|
-
async _crearTarjeta(datos) {
|
|
638
|
-
const id = nuevoId();
|
|
667
|
+
async _crearTarjeta(datos, id = nuevoId()) {
|
|
639
668
|
try {
|
|
640
669
|
await this.fs.escribir(`salas/${this.salaId}/tarjetas/${id}`, this._tarjetaCompleta(datos), { crear: true, horaServidor: ['creadaEn'] });
|
|
641
670
|
} catch (e) {
|
|
@@ -664,10 +693,16 @@ export class Lienzo {
|
|
|
664
693
|
return { id, paraId: para, link: this.linkTarjeta(id) };
|
|
665
694
|
}
|
|
666
695
|
|
|
696
|
+
/**
|
|
697
|
+
* lienzo_compartir_archivo. Texto UTF-8 de hasta 200 KB: en la tarjeta, como siempre (lo leen también los puentes
|
|
698
|
+
* 0.2/0.3). Lo demás —imágenes, PDF, zip, audio, video, y texto de más de 200 KB— va a Storage
|
|
699
|
+
* (salas/{sala}/adjuntos/{tarjeta}/{nombre}) y la tarjeta guarda solo { nombre, mime, bytes, ruta, sha256 }. El tipo
|
|
700
|
+
* sale de los magic bytes, no del nombre. Orden: subir → crear la tarjeta; si la tarjeta falla, se borra lo subido.
|
|
701
|
+
*/
|
|
667
702
|
async compartirArchivo({ ruta, contenido, nombre, texto, paraId = null }) {
|
|
668
703
|
await this._asegurarSala();
|
|
669
704
|
if (!ruta && contenido == null) throw new ErrorLienzo('Pasa la ruta del archivo o su contenido.');
|
|
670
|
-
let
|
|
705
|
+
let buf;
|
|
671
706
|
if (ruta) {
|
|
672
707
|
let real;
|
|
673
708
|
let st;
|
|
@@ -676,33 +711,240 @@ export class Lienzo {
|
|
|
676
711
|
} catch (e) {
|
|
677
712
|
throw this._traducir(e, 'compartir el archivo');
|
|
678
713
|
}
|
|
679
|
-
if (st.size >
|
|
680
|
-
|
|
681
|
-
if (buf.includes(0)) throw new ErrorLienzo('Solo se comparten archivos de texto (este parece binario).');
|
|
682
|
-
try {
|
|
683
|
-
contenido = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
684
|
-
} catch {
|
|
685
|
-
throw new ErrorLienzo('Solo se comparten archivos de texto en UTF-8.');
|
|
686
|
-
}
|
|
714
|
+
if (st.size > MAX_ADJUNTO_BYTES) throw new ErrorLienzo(`El archivo pesa ${(st.size / 1024 / 1024).toFixed(1)} MB; el tope es 25 MB (10 MB una imagen).`);
|
|
715
|
+
buf = new Uint8Array(readFileSync(real));
|
|
687
716
|
nombre = nombre || basename(real);
|
|
688
|
-
bytes = buf.length;
|
|
689
717
|
} else {
|
|
690
718
|
contenido = String(contenido);
|
|
691
|
-
bytes = Buffer.byteLength(contenido, 'utf8');
|
|
692
719
|
if (contenido.includes('\u0000')) throw new ErrorLienzo('Solo se comparte texto.');
|
|
720
|
+
buf = new Uint8Array(Buffer.from(contenido, 'utf8'));
|
|
693
721
|
}
|
|
694
722
|
nombre = String(nombre || '').trim();
|
|
695
723
|
if (!nombre) throw new ErrorLienzo('Falta el nombre del archivo.');
|
|
696
724
|
if (nombre.length > 200) throw new ErrorLienzo('El nombre del archivo admite hasta 200 caracteres.');
|
|
697
725
|
try { revisarNombre(limpiarLinea(nombre, 200)); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
726
|
+
|
|
727
|
+
let tipo;
|
|
728
|
+
try { tipo = detectarTipo(buf, nombre); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
|
|
729
|
+
const bytes = buf.length;
|
|
730
|
+
const enTarjeta = tipo.texto != null && bytes <= MAX_ARCHIVO_BYTES;
|
|
731
|
+
// Sin texto, la tarjeta dice qué es; un adjunto de Storage dice además dónde se ve (una web o un puente de antes de
|
|
732
|
+
// 0.4.0 lo mostrarían como un archivo vacío).
|
|
733
|
+
texto = texto
|
|
734
|
+
? this._validarTexto(texto)
|
|
735
|
+
: enTarjeta ? `Archivo: ${nombre}` : `Archivo: ${nombre} (${nombreDeTipo(tipo.mime)}, ${pesoLegible(bytes)}). Se ve en la web; un agente lo baja con lienzo_descargar (puente 0.4.0 o posterior).`;
|
|
736
|
+
// cero secretos: el texto del archivo (también SVG y JSON), el comentario y el nombre
|
|
737
|
+
this._vigilarSecretos([['el archivo', tipo.texto], ['el texto', texto], ['el nombre del archivo', nombre]]);
|
|
738
|
+
// un zip no se abre, pero su índice sí se lee: .env, llaves o un .git/ adentro no salen
|
|
739
|
+
if (tipo.mime === 'application/zip') {
|
|
740
|
+
try { revisarZip(buf); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
|
|
741
|
+
}
|
|
701
742
|
const para = await this._resolverParticipante(paraId);
|
|
702
|
-
|
|
703
|
-
|
|
743
|
+
|
|
744
|
+
// texto chico (también un SVG, como en 0.3: la tarjeta lo muestra como código, nunca lo dibuja): en la tarjeta
|
|
745
|
+
if (enTarjeta) {
|
|
746
|
+
const mime = MIME[extname(nombre).toLowerCase()] || 'text/plain';
|
|
747
|
+
const id = await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, contenido: tipo.texto } });
|
|
748
|
+
await this.latido();
|
|
749
|
+
return { id, nombre, bytes, mime, link: this.linkTarjeta(id) };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const mime = mimeParaStorage(tipo.mime);
|
|
753
|
+
try { revisarTamano(bytes, mime); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
|
|
754
|
+
const id = nuevoId();
|
|
755
|
+
const objeto = nombreEnStorage(nombre);
|
|
756
|
+
const rutaObjeto = `salas/${this.salaId}/adjuntos/${id}/${objeto}`;
|
|
757
|
+
const hash = sha256(buf);
|
|
758
|
+
await this._asegurarAcceso();
|
|
759
|
+
const subir = () => this.storage.subir(rutaObjeto, buf, {
|
|
760
|
+
contentType: mime, contentDisposition: disposicion(objeto), metadatos: { autorId: this.participanteId, sha256: hash },
|
|
761
|
+
});
|
|
762
|
+
try {
|
|
763
|
+
await subir().catch(async (e) => {
|
|
764
|
+
// tu acceso pudo borrarse (saliste desde la web, o te uniste de nuevo): se reescribe una vez
|
|
765
|
+
if (e instanceof ErrorStorage && e.negado) { await this._asegurarAcceso({ forzar: true }); return subir(); }
|
|
766
|
+
throw e;
|
|
767
|
+
});
|
|
768
|
+
} catch (e) {
|
|
769
|
+
// un corte o un timeout pudo dejar el objeto guardado igual: sin tarjeta no sirve (si no está, da 404 y listo)
|
|
770
|
+
if (!(e instanceof ErrorStorage && e.negado)) await this.storage.borrar(rutaObjeto).catch(() => {});
|
|
771
|
+
throw this._traducir(e, 'subir el archivo');
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, ruta: rutaObjeto, sha256: hash } }, id);
|
|
775
|
+
} catch (e) {
|
|
776
|
+
// ¿La tarjeta se creó y solo se perdió la respuesta (timeout, corte)? Entonces no se borra el archivo: quedaría
|
|
777
|
+
// una tarjeta que apunta a un 404 para siempre (un adjunto de Storage no se puede cambiar). Solo un rechazo
|
|
778
|
+
// definitivo de las reglas o del contenido (4xx) asegura que no existe.
|
|
779
|
+
const f = e instanceof ErrorFirestore ? e : e?.cause instanceof ErrorFirestore ? e.cause : null;
|
|
780
|
+
// 409 (ya existe: un reintento de un commit que sí se aplicó), 408 y 429 no prueban nada: se mira la tarjeta
|
|
781
|
+
const definitivo = Boolean(f) && f.status >= 400 && f.status < 500 && ![408, 409, 429].includes(f.status);
|
|
782
|
+
const existe = definitivo ? false : await this.fs.leer(`salas/${this.salaId}/tarjetas/${id}`).then(Boolean, () => null);
|
|
783
|
+
if (existe === true) {
|
|
784
|
+
await this.latido();
|
|
785
|
+
return { id, nombre, bytes, mime, sha256: hash, enStorage: true, link: this.linkTarjeta(id) };
|
|
786
|
+
}
|
|
787
|
+
// sin tarjeta, el archivo no sirve: se limpia (si ni siquiera se pudo leer, lo barre scripts/purgar-huerfanos.mjs)
|
|
788
|
+
if (existe === false) await this.storage.borrar(rutaObjeto).catch(() => {});
|
|
789
|
+
throw e;
|
|
790
|
+
}
|
|
791
|
+
await this.latido();
|
|
792
|
+
return { id, nombre, bytes, mime, sha256: hash, enStorage: true, link: this.linkTarjeta(id) };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* accesos/{uid} de la sala: lo que storage.rules mira (con la sala) para dejar subir y bajar adjuntos. Se escribe
|
|
797
|
+
* una vez por sala y proceso; las reglas solo lo dejan escribir a un miembro sin expulsar.
|
|
798
|
+
*/
|
|
799
|
+
async _asegurarAcceso({ forzar = false } = {}) {
|
|
800
|
+
if (!forzar && this.accesoEn === this.salaId) return;
|
|
801
|
+
try {
|
|
802
|
+
await this.fs.escribir(`salas/${this.salaId}/accesos/${this.uid}`, {}, { horaServidor: ['en'] });
|
|
803
|
+
} catch (e) {
|
|
804
|
+
throw this._traducir(e, 'abrir los adjuntos de la sala');
|
|
805
|
+
}
|
|
806
|
+
this.accesoEn = this.salaId;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Baja el adjunto de Storage de una tarjeta y comprueba tamaño, sha256 y que los bytes sean del tipo que dice la
|
|
811
|
+
* tarjeta (el sha256 lo eligió el autor: prueba que llegó entero, no que sea lo que dice). Devuelve los bytes.
|
|
812
|
+
* Los errores nombran la tarjeta por su id, nunca por el nombre que eligió otro participante.
|
|
813
|
+
*/
|
|
814
|
+
async _bajarAdjunto(t) {
|
|
815
|
+
const a = t.archivo;
|
|
816
|
+
const cual = `el archivo de la tarjeta ${limpiarLinea(t.id, 64)}`;
|
|
817
|
+
await this._asegurarAcceso();
|
|
818
|
+
const bajar = () => this.storage.bajar(a.ruta, { maxBytes: Number(a.bytes) || 0 });
|
|
819
|
+
let buf;
|
|
820
|
+
try {
|
|
821
|
+
buf = await bajar().catch(async (e) => {
|
|
822
|
+
// si tu acceso se borró (saliste desde la web y volviste), se reescribe una vez
|
|
823
|
+
if (e instanceof ErrorStorage && e.negado) { await this._asegurarAcceso({ forzar: true }); return bajar(); }
|
|
824
|
+
throw e;
|
|
825
|
+
});
|
|
826
|
+
} catch (e) {
|
|
827
|
+
throw this._traducir(e, `bajar ${cual}`);
|
|
828
|
+
}
|
|
829
|
+
if (buf.length !== Number(a.bytes)) {
|
|
830
|
+
throw new ErrorLienzo(`${cual} pesa ${buf.length} bytes y la tarjeta dice ${a.bytes}: no se guardó.`);
|
|
831
|
+
}
|
|
832
|
+
if (sha256(buf) !== a.sha256) {
|
|
833
|
+
throw new ErrorLienzo(`El sha256 de ${cual} no coincide con el de la tarjeta: no se guardó (pudo cambiar en el camino).`);
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
revisarBajado(buf, String(a.mime));
|
|
837
|
+
} catch (e) {
|
|
838
|
+
throw new ErrorLienzo(`Revisé ${cual}: ${e.message}`);
|
|
839
|
+
}
|
|
840
|
+
return buf;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async _tarjetaConArchivo(id) {
|
|
844
|
+
await this._asegurarSala();
|
|
845
|
+
id = String(id ?? '').trim();
|
|
846
|
+
if (!ID_TARJETA.test(id)) throw new ErrorLienzo('id tiene que ser el id de una tarjeta (letras, números, - y _, hasta 64).');
|
|
847
|
+
const t = await this.fs.leer(`salas/${this.salaId}/tarjetas/${id}`).catch((e) => { throw this._traducir(e, 'leer esa tarjeta'); });
|
|
848
|
+
if (!t) throw new ErrorLienzo(`No existe la tarjeta ${id}.`);
|
|
849
|
+
if (!t.archivo) throw new ErrorLienzo(`La tarjeta ${id} no tiene archivo.`);
|
|
850
|
+
return t;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* lienzo_descargar: baja el archivo de una tarjeta a <LIENZO_HOME>/descargas/<sala>/ (y a ningún otro lado),
|
|
855
|
+
* verifica tamaño, sha256 y tipo real, y lo guarda sin permiso de ejecución, con la extensión de su tipo real y (en
|
|
856
|
+
* macOS) en cuarentena, para que Gatekeeper pregunte antes de abrirlo. Nunca lo abre ni lo ejecuta.
|
|
857
|
+
* Devuelve { ruta, nombre, mime, bytes, sha256, verificado, imagen?, imagenOmitida?, autorId, id }.
|
|
858
|
+
*/
|
|
859
|
+
async descargar({ id, destino } = {}) {
|
|
860
|
+
const t = await this._tarjetaConArchivo(id);
|
|
704
861
|
await this.latido();
|
|
705
|
-
|
|
862
|
+
const a = t.archivo;
|
|
863
|
+
const enStorage = Boolean(a.ruta);
|
|
864
|
+
let buf;
|
|
865
|
+
if (enStorage) buf = await this._bajarAdjunto(t);
|
|
866
|
+
else buf = new Uint8Array(Buffer.from(String(a.contenido ?? ''), 'utf8'));
|
|
867
|
+
// el texto de la tarjeta se guarda como texto: su extensión solo se conserva si nada la ejecuta con doble clic
|
|
868
|
+
const mime = enStorage ? String(a.mime) : 'text/plain';
|
|
869
|
+
let nombreArchivo;
|
|
870
|
+
try { nombreArchivo = nombreDescarga({ id: t.id, nombre: a.nombre, destino, mime }); } catch (e) { throw this._traducir(e, 'descargar'); }
|
|
871
|
+
const carpeta = carpetaDescargas(this.config.home, this.salaId);
|
|
872
|
+
mkdirSync(carpeta, { recursive: true, mode: 0o700 });
|
|
873
|
+
const parcial = join(carpeta, `.${randomBytes(6).toString('hex')}.parcial`);
|
|
874
|
+
let final;
|
|
875
|
+
try {
|
|
876
|
+
writeFileSync(parcial, buf, { mode: 0o600, flag: 'wx' }); // sin permiso de ejecución, nunca
|
|
877
|
+
// no pisa: «-2», «-3»… link() falla si el nombre ya existe, así dos descargas a la vez no se pisan
|
|
878
|
+
const ext = extname(nombreArchivo);
|
|
879
|
+
const base = nombreArchivo.slice(0, nombreArchivo.length - ext.length);
|
|
880
|
+
for (let n = 1; !final; n++) {
|
|
881
|
+
const intento = join(carpeta, n === 1 ? nombreArchivo : `${base}-${n}${ext}`);
|
|
882
|
+
try {
|
|
883
|
+
linkSync(parcial, intento);
|
|
884
|
+
final = intento;
|
|
885
|
+
} catch (e) {
|
|
886
|
+
if (e?.code !== 'EEXIST' || n > 999) throw e;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} catch (e) {
|
|
890
|
+
throw new ErrorLienzo(`No pude guardar el archivo: ${e.message}`);
|
|
891
|
+
} finally {
|
|
892
|
+
rmSync(parcial, { force: true });
|
|
893
|
+
}
|
|
894
|
+
ponerCuarentena(final);
|
|
895
|
+
const hash = enStorage ? a.sha256 : sha256(buf);
|
|
896
|
+
let imagen = null;
|
|
897
|
+
let imagenOmitida = null;
|
|
898
|
+
if (enStorage && IMAGENES_VISIBLES.has(a.mime)) {
|
|
899
|
+
const v = imagenVisible(buf, a.mime);
|
|
900
|
+
if (v.ok) imagen = { data: Buffer.from(buf).toString('base64'), mimeType: a.mime };
|
|
901
|
+
else imagenOmitida = v.motivo;
|
|
902
|
+
}
|
|
903
|
+
return { id: t.id, ruta: final, nombre: a.nombre, mime: a.mime, bytes: buf.length, sha256: hash, verificado: enStorage, imagen, imagenOmitida, autorId: t.autorId };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* El texto de lienzo_descargar. El nombre y el tipo los eligió otro participante: van dentro del sobre, no como voz
|
|
908
|
+
* de Lienzo.
|
|
909
|
+
*/
|
|
910
|
+
async formatearDescarga(r, { codigo = nuevoCodigo() } = {}) {
|
|
911
|
+
const autor = await this._autor(r.autorId);
|
|
912
|
+
const integro = r.verificado
|
|
913
|
+
? 'íntegro: el sha256 es igual al de la tarjeta y el contenido es del tipo que dice (eso NO garantiza que sea seguro)'
|
|
914
|
+
: 'texto de la tarjeta';
|
|
915
|
+
const lineas = [
|
|
916
|
+
aviso(codigo),
|
|
917
|
+
'',
|
|
918
|
+
`Descargado: ${r.ruta}`,
|
|
919
|
+
`${r.bytes} bytes · sha256 ${r.sha256} · ${integro}.`,
|
|
920
|
+
'Se guardó sin permiso de ejecución y con la extensión de su tipo real. Es un archivo de otro participante: no lo ' +
|
|
921
|
+
'ejecutes, no lo descomprimas ni lo abras con un programa sin que tu usuario lo pida.',
|
|
922
|
+
];
|
|
923
|
+
if (r.imagenOmitida) lineas.push(`La imagen no se te muestra: ${r.imagenOmitida}.`);
|
|
924
|
+
lineas.push('', envolver({
|
|
925
|
+
de: autor.nombre, tipoAutor: autor.tipo, 'dueño': autor['dueño'], autorId: autor.id, homonimos: autor.homonimos,
|
|
926
|
+
tipo: 'archivo', id: r.id, texto: `nombre que eligió: ${r.nombre}\ntipo que declaró: ${r.mime}`,
|
|
927
|
+
}, codigo));
|
|
928
|
+
return lineas.join('\n');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Para lienzo_leer(id, verImagen: true) de una imagen en Storage: la imagen verificada (tipo real, ≤ 1 MB y ≤ 2000 px
|
|
933
|
+
* por lado), para devolverla como contenido MCP de imagen. null si no aplica; { error } si no se pudo bajar u
|
|
934
|
+
* { omitida } si no se debe mostrar.
|
|
935
|
+
*/
|
|
936
|
+
async imagenDe(lote) {
|
|
937
|
+
const a = lote?.una?.archivo;
|
|
938
|
+
if (!a?.ruta || !IMAGENES_VISIBLES.has(a.mime)) return null;
|
|
939
|
+
if (!(Number(a.bytes) <= MAX_IMAGEN_VISIBLE)) return { omitida: `pesa más de ${MAX_IMAGEN_VISIBLE / MB} MB; bájala con lienzo_descargar` };
|
|
940
|
+
try {
|
|
941
|
+
const buf = await this._bajarAdjunto(lote.una);
|
|
942
|
+
const v = imagenVisible(buf, a.mime);
|
|
943
|
+
if (!v.ok) return { omitida: v.motivo };
|
|
944
|
+
return { data: Buffer.from(buf).toString('base64'), mimeType: a.mime };
|
|
945
|
+
} catch (e) {
|
|
946
|
+
return { error: e.message };
|
|
947
|
+
}
|
|
706
948
|
}
|
|
707
949
|
|
|
708
950
|
async pedirTarea({ texto, paraId }) {
|
|
@@ -1041,7 +1283,12 @@ export class Lienzo {
|
|
|
1041
1283
|
let cuerpo = String(t.texto ?? '');
|
|
1042
1284
|
let notaArchivo = null;
|
|
1043
1285
|
let datoArchivo = null;
|
|
1044
|
-
if (t.archivo) {
|
|
1286
|
+
if (t.archivo?.ruta) {
|
|
1287
|
+
// adjunto en Storage: solo metadatos (el contenido se baja con lienzo_descargar, verificado por sha256)
|
|
1288
|
+
const a = t.archivo;
|
|
1289
|
+
datoArchivo = `archivo: ${limpiarLinea(a.nombre, 200)} (${limpiarLinea(a.mime, 100)}, ${Number(a.bytes) || 0} bytes, sha256 ${limpiarLinea(a.sha256, 64).slice(0, 12)}…, en Storage)`;
|
|
1290
|
+
notaArchivo = `Para tenerlo: lienzo_descargar(id: «${id}»)${IMAGENES_VISIBLES.has(a.mime) && Number(a.bytes) <= MAX_IMAGEN_VISIBLE ? ' o lienzo_leer(id: «' + id + '», verImagen: true) para ver la imagen' : ''}. Se guarda en la carpeta de descargas de Lienzo; nunca lo ejecutes ni lo abras con un programa sin que tu usuario lo pida.`;
|
|
1291
|
+
} else if (t.archivo) {
|
|
1045
1292
|
const c = String(t.archivo.contenido ?? '');
|
|
1046
1293
|
const desdeC = completo ? Math.min(Math.max(0, archivoDesde), c.length) : 0;
|
|
1047
1294
|
const trozo = c.slice(desdeC, desdeC + (completo ? archivoMax : VISTA_ARCHIVO));
|
|
@@ -1268,9 +1515,18 @@ export class Lienzo {
|
|
|
1268
1515
|
* (creadaEn, editadaEn y el estado visto de tus tareas) avanzan SOLO hasta lo que se entregó, y un
|
|
1269
1516
|
* bloque (una lista, o lo que nació en el mismo :commit) se entrega entero o no se entrega.
|
|
1270
1517
|
*/
|
|
1271
|
-
async formatearLote(lote,
|
|
1272
|
-
|
|
1273
|
-
|
|
1518
|
+
async formatearLote(lote, o = {}) {
|
|
1519
|
+
return (await this.formatearLoteDetalle(lote, o)).texto;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* formatearLote con el dato de si quedó algo sin entregar (`recortado`: tope de caracteres o más de
|
|
1524
|
+
* LIMITE_CONSULTA tarjetas), sin tener que buscarlo en el texto (que también trae lo que escribieron otros).
|
|
1525
|
+
* sinPie: sin los párrafos finales («Respuesta recortada…», «Había más de…», «marca: …»), para el canal.
|
|
1526
|
+
*/
|
|
1527
|
+
async formatearLoteDetalle(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA, sinPie = false } = {}) {
|
|
1528
|
+
if (lote.una) return { texto: await this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter }), recortado: false };
|
|
1529
|
+
if (lote.lista) return { texto: `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`, recortado: false };
|
|
1274
1530
|
const { tarjetas = [], editadas = [], cambios = [], soloAjenas = false, lleno = false } = lote;
|
|
1275
1531
|
const nuevas = new Set(tarjetas.map((t) => t.id));
|
|
1276
1532
|
const items = [
|
|
@@ -1310,6 +1566,8 @@ export class Lienzo {
|
|
|
1310
1566
|
if (bloques.editadas.length) partes.push(`Tarjetas editadas (${piezas.editadas}):`, ...bloques.editadas);
|
|
1311
1567
|
if (bloques.tarjetas.length) partes.push(`Tarjetas (${piezas.tarjetas}):`, ...bloques.tarjetas);
|
|
1312
1568
|
if (!entregados && !cortado) partes.push('No hay tarjetas nuevas.');
|
|
1569
|
+
const recortado = cortado || Boolean(lleno);
|
|
1570
|
+
if (sinPie) return { texto: partes.join('\n\n'), recortado };
|
|
1313
1571
|
if (cortado) {
|
|
1314
1572
|
const total = quedan.cambios + quedan.editadas + quedan.tarjetas;
|
|
1315
1573
|
const desglose = [
|
|
@@ -1324,6 +1582,28 @@ export class Lienzo {
|
|
|
1324
1582
|
}
|
|
1325
1583
|
const marca = soloAjenas ? this.marca : (ultimaCreada ?? lote.desde ?? this.marca);
|
|
1326
1584
|
partes.push(`marca: ${marca ?? '(sin marca)'} — pásala como «desde» para pedir solo lo posterior.`);
|
|
1327
|
-
return partes.join('\n\n');
|
|
1585
|
+
return { texto: partes.join('\n\n'), recortado };
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** «1,2 MB», «340 KB», «900 bytes»: para el texto por defecto de una tarjeta con adjunto. */
|
|
1590
|
+
function pesoLegible(n) {
|
|
1591
|
+
if (n >= MB) return `${(n / MB).toLocaleString('es-PE', { maximumFractionDigits: 1 })} MB`;
|
|
1592
|
+
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
|
1593
|
+
return `${n} bytes`;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* macOS: el atributo de cuarentena hace que Gatekeeper pregunte antes de abrir lo descargado (un .terminal o un .app
|
|
1598
|
+
* de otro participante no corre con un doble clic). Node no lo pone solo. En otros sistemas, nada.
|
|
1599
|
+
*/
|
|
1600
|
+
export function ponerCuarentena(ruta, { plataforma = process.platform } = {}) {
|
|
1601
|
+
if (plataforma !== 'darwin') return false;
|
|
1602
|
+
try {
|
|
1603
|
+
const segundos = Math.floor(Date.now() / 1000).toString(16);
|
|
1604
|
+
execFileSync('/usr/bin/xattr', ['-w', 'com.apple.quarantine', `0081;${segundos};Lienzo;`, ruta], { stdio: 'ignore', timeout: 5000 });
|
|
1605
|
+
return true;
|
|
1606
|
+
} catch {
|
|
1607
|
+
return false;
|
|
1328
1608
|
}
|
|
1329
1609
|
}
|
package/lib/storage.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Cliente REST mínimo de Firebase Storage, con el idToken de la cuenta conectada (la de la persona), igual que
|
|
2
|
+
// firestore.mjs: las reglas (storage.rules) se aplican como en la web y el puente no tiene privilegios.
|
|
3
|
+
// subir POST {base}/o?name=<ruta> multipart: metadatos JSON + bytes (contentType, contentDisposition, autorId)
|
|
4
|
+
// bajar GET {base}/o/<ruta>?alt=media
|
|
5
|
+
// borrar DELETE {base}/o/<ruta>
|
|
6
|
+
// Nunca pide ni usa la URL con token de descarga (esa se salta las reglas).
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export const TIMEOUT_STORAGE_MS = 120 * 1000; // 25 MB por una conexión lenta
|
|
10
|
+
|
|
11
|
+
export class ErrorStorage extends Error {
|
|
12
|
+
constructor(status, cuerpo) {
|
|
13
|
+
super(cuerpo?.error?.message || `HTTP ${status}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
get negado() { return this.status === 403 || this.status === 401; }
|
|
17
|
+
get noExiste() { return this.status === 404; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Storage {
|
|
21
|
+
/** @param {{ storageBucket: string, emuladorStorage?: string|null }} config */
|
|
22
|
+
constructor(config, sesion, { fetch: f = globalThis.fetch } = {}) {
|
|
23
|
+
this.sesion = sesion;
|
|
24
|
+
this.fetch = f;
|
|
25
|
+
this.bucket = config.storageBucket;
|
|
26
|
+
const raiz = config.emuladorStorage ? `http://${config.emuladorStorage}` : 'https://firebasestorage.googleapis.com';
|
|
27
|
+
this.base = `${raiz}/v0/b/${encodeURIComponent(this.bucket ?? '')}/o`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async _pedir(metodo, url, { cuerpo, cabeceras = {}, reintento = true, signal } = {}) {
|
|
31
|
+
if (!this.bucket) throw new ErrorStorage(0, { error: { message: 'falta storageBucket en firebase.config.json' } });
|
|
32
|
+
const token = await this.sesion.token();
|
|
33
|
+
const senales = [AbortSignal.timeout(TIMEOUT_STORAGE_MS), signal].filter(Boolean);
|
|
34
|
+
let r;
|
|
35
|
+
try {
|
|
36
|
+
r = await this.fetch(url, {
|
|
37
|
+
method: metodo,
|
|
38
|
+
headers: { Authorization: `Firebase ${token}`, ...cabeceras },
|
|
39
|
+
body: cuerpo,
|
|
40
|
+
signal: senales.length > 1 ? AbortSignal.any(senales) : senales[0],
|
|
41
|
+
});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e?.name === 'TimeoutError') throw new Error(`Storage no respondió en ${TIMEOUT_STORAGE_MS / 1000} s`);
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
if (r.status === 401 && reintento) {
|
|
47
|
+
await this.sesion.renovar();
|
|
48
|
+
return this._pedir(metodo, url, { cuerpo, cabeceras, reintento: false, signal });
|
|
49
|
+
}
|
|
50
|
+
return r;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static async _error(r) {
|
|
54
|
+
const d = await r.json().catch(() => ({}));
|
|
55
|
+
return new ErrorStorage(r.status, d);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sube `bytes` a `ruta` en UNA petición (multipart): los metadatos que miran las reglas viajan con el archivo.
|
|
60
|
+
* @param {string} ruta salas/{salaId}/adjuntos/{tarjetaId}/{nombre}
|
|
61
|
+
* @param {Uint8Array} bytes
|
|
62
|
+
* @param {{ contentType: string, contentDisposition: string, metadatos: Record<string,string> }} m
|
|
63
|
+
*/
|
|
64
|
+
async subir(ruta, bytes, { contentType, contentDisposition, metadatos }) {
|
|
65
|
+
const limite = `lienzo-${randomBytes(12).toString('hex')}`;
|
|
66
|
+
const meta = JSON.stringify({ name: ruta, contentType, contentDisposition, metadata: metadatos });
|
|
67
|
+
const cuerpo = Buffer.concat([
|
|
68
|
+
Buffer.from(`--${limite}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n${meta}\r\n--${limite}\r\nContent-Type: ${contentType}\r\n\r\n`),
|
|
69
|
+
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
|
|
70
|
+
Buffer.from(`\r\n--${limite}--`),
|
|
71
|
+
]);
|
|
72
|
+
const r = await this._pedir('POST', `${this.base}?name=${encodeURIComponent(ruta)}`, {
|
|
73
|
+
cuerpo,
|
|
74
|
+
cabeceras: { 'Content-Type': `multipart/related; boundary=${limite}`, 'X-Goog-Upload-Protocol': 'multipart' },
|
|
75
|
+
});
|
|
76
|
+
if (!r.ok) throw await Storage._error(r);
|
|
77
|
+
return r.json().catch(() => ({}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Baja el objeto entero (con un tope: no se aceptan más bytes que `maxBytes`). Devuelve un Uint8Array.
|
|
82
|
+
*/
|
|
83
|
+
async bajar(ruta, { maxBytes, signal } = {}) {
|
|
84
|
+
const r = await this._pedir('GET', `${this.base}/${encodeURIComponent(ruta)}?alt=media`, { signal });
|
|
85
|
+
if (!r.ok) throw await Storage._error(r);
|
|
86
|
+
const largo = Number(r.headers.get('content-length'));
|
|
87
|
+
if (maxBytes != null && Number.isFinite(largo) && largo > maxBytes) {
|
|
88
|
+
await r.body?.cancel?.().catch(() => {});
|
|
89
|
+
throw new ErrorStorage(413, { error: { message: `el archivo pesa ${largo} bytes y la tarjeta dice ${maxBytes}` } });
|
|
90
|
+
}
|
|
91
|
+
const partes = [];
|
|
92
|
+
let total = 0;
|
|
93
|
+
for await (const trozo of r.body ?? []) {
|
|
94
|
+
total += trozo.byteLength;
|
|
95
|
+
if (maxBytes != null && total > maxBytes) {
|
|
96
|
+
throw new ErrorStorage(413, { error: { message: `el archivo pesa más de lo que dice la tarjeta (${maxBytes} bytes)` } });
|
|
97
|
+
}
|
|
98
|
+
partes.push(trozo);
|
|
99
|
+
}
|
|
100
|
+
return new Uint8Array(Buffer.concat(partes));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async borrar(ruta) {
|
|
104
|
+
const r = await this._pedir('DELETE', `${this.base}/${encodeURIComponent(ruta)}`);
|
|
105
|
+
if (!r.ok && r.status !== 404) throw await Storage._error(r);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ajuarezso/lienzo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Servidor MCP de Lienzo: une tu agente (Claude Code u otro cliente MCP) a una sala compartida con los agentes de otras personas. / MCP server that joins your agent to a room shared with other people's agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test test/unit/*.test.mjs",
|
|
35
|
-
"test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
|
|
35
|
+
"test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore,storage --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
|
|
36
36
|
"e2e:real": "node test/e2e-real.mjs",
|
|
37
37
|
"prepack": "node -e \"require('node:fs').copyFileSync('../firebase.config.json', 'firebase.config.json')\"",
|
|
38
38
|
"postpack": "node -e \"require('node:fs').rmSync('firebase.config.json', { force: true })\""
|