boosterarena-mcp 0.1.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/LEEME.md +109 -0
- package/cli.mjs +63 -0
- package/dist/nucleo.mjs +2688 -0
- package/encargos.mjs +117 -0
- package/package.json +37 -0
- package/proyectos.mjs +140 -0
- package/servidor.mjs +364 -0
- package/sesion.mjs +245 -0
- package/subidas.mjs +84 -0
package/sesion.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// LA PUERTA — cómo un agente en una TERMINAL entra a la cuenta del maestro.
|
|
3
|
+
//
|
|
4
|
+
// El editor entra con el login del navegador; un agente no tiene navegador. Así que se hace
|
|
5
|
+
// lo mismo que hace Claude Code: el CLI abre el navegador, el maestro entra como entra
|
|
6
|
+
// siempre, y el navegador devuelve la llave a un servidor minúsculo que el CLI dejó
|
|
7
|
+
// escuchando en su propia máquina.
|
|
8
|
+
//
|
|
9
|
+
// POR QUÉ ASÍ Y NO CON UNA CLAVE DE API
|
|
10
|
+
//
|
|
11
|
+
// · No hay que hospedar nada. El MCP corre en la máquina del maestro; lo que necesita
|
|
12
|
+
// permiso (guardar el proyecto, subir un archivo, quitar un fondo) va a las mismas
|
|
13
|
+
// funciones de `/api` que ya usa el navegador, con el mismo token. Cero infraestructura
|
|
14
|
+
// nueva, y por tanto cero costo — que importa, porque el MCP va a ser gratis.
|
|
15
|
+
// · El candado del plan YA existe ahí. `functions/api/quitar-fondo.ts` y `openai.ts`
|
|
16
|
+
// comprueban el plan y descuentan cuota contra el JWT. No hay un segundo sistema de
|
|
17
|
+
// permisos que mantener ni que se pueda desincronizar del primero.
|
|
18
|
+
// · Revocar es cerrar la sesión en Supabase. Si alguien pierde el portátil, se corta ahí.
|
|
19
|
+
//
|
|
20
|
+
// ⚠️ PKCE, NO EL FLUJO IMPLÍCITO. El código que vuelve al loopback no sirve de nada sin el
|
|
21
|
+
// `verifier` que solo tiene este proceso, así que otro programa de la misma máquina que
|
|
22
|
+
// espiara el puerto no se llevaría nada. Es lo que manda la RFC 8252 para apps nativas.
|
|
23
|
+
//
|
|
24
|
+
// ⚠️ Y NO SE USA CORREO+CONTRASEÑA AUNQUE SEA LO QUE MÁS USAN (5 de 8 cuentas, medido el
|
|
25
|
+
// 2026-09-07). Un CLI que pide la contraseña enseña a la gente a teclearla en sitios raros,
|
|
26
|
+
// y además tendría que tocarla. El enlace por correo llega a la MISMA cuenta y no expone
|
|
27
|
+
// nada: es el camino que usan `gh` y compañía por la misma razón.
|
|
28
|
+
//
|
|
29
|
+
// node mcp/sesion.mjs entrar # con Google
|
|
30
|
+
// node mcp/sesion.mjs entrar --correo tu@correo.com
|
|
31
|
+
// node mcp/sesion.mjs quien # a nombre de quién estoy
|
|
32
|
+
// node mcp/sesion.mjs salir # borra la credencial de esta máquina
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
import { createServer } from 'node:http';
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { randomBytes } from 'node:crypto';
|
|
37
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { pathToFileURL } from 'node:url';
|
|
41
|
+
import { createClient } from '@supabase/supabase-js';
|
|
42
|
+
|
|
43
|
+
// Los mismos valores públicos que el navegador. La `publishable` no es un secreto: acaba en el
|
|
44
|
+
// bundle de cualquier app de Supabase, y lo que protege los datos es el RLS.
|
|
45
|
+
const SUPABASE_URL = 'https://kjoadzhtgbrngpppbysh.supabase.co';
|
|
46
|
+
const SUPABASE_KEY = 'sb_publishable_Zkvuw-xVLuns3msI14xH0g_YG5SYo7G';
|
|
47
|
+
|
|
48
|
+
const CARPETA = join(homedir(), '.booster');
|
|
49
|
+
const CREDENCIAL = join(CARPETA, 'credenciales.json');
|
|
50
|
+
|
|
51
|
+
// ── Almacén en memoria ───────────────────────────────────────────────────────
|
|
52
|
+
// `supabase-js` guarda el `code_verifier` de PKCE en el almacén mientras dura el viaje al
|
|
53
|
+
// navegador. En Node no hay `localStorage`, y aquí no hace falta que sobreviva al proceso: el
|
|
54
|
+
// mismo que abre el navegador es el que canjea el código.
|
|
55
|
+
const memoria = new Map();
|
|
56
|
+
const almacen = {
|
|
57
|
+
getItem: (k) => memoria.get(k) ?? null,
|
|
58
|
+
setItem: (k, v) => void memoria.set(k, v),
|
|
59
|
+
removeItem: (k) => void memoria.delete(k),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function cliente() {
|
|
63
|
+
return createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
64
|
+
auth: {
|
|
65
|
+
flowType: 'pkce', // ← lo que hace que el código del loopback no valga solo
|
|
66
|
+
storage: almacen,
|
|
67
|
+
persistSession: true,
|
|
68
|
+
autoRefreshToken: false, // el refresco lo lleva el MCP, no este comando suelto
|
|
69
|
+
detectSessionInUrl: false,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── La credencial en disco ───────────────────────────────────────────────────
|
|
75
|
+
// Se guarda el `refresh_token`, no el de acceso: el de acceso caduca en una hora y se renueva
|
|
76
|
+
// solo. Este archivo ES una llave, así que va a 600 (solo su dueño), igual que `~/.claude`.
|
|
77
|
+
function guardar(sesion) {
|
|
78
|
+
if (!existsSync(CARPETA)) mkdirSync(CARPETA, { recursive: true, mode: 0o700 });
|
|
79
|
+
const datos = {
|
|
80
|
+
refresh_token: sesion.refresh_token,
|
|
81
|
+
correo: sesion.user?.email ?? null,
|
|
82
|
+
guardado: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
writeFileSync(CREDENCIAL, JSON.stringify(datos, null, 2), { mode: 0o600 });
|
|
85
|
+
try { chmodSync(CREDENCIAL, 0o600); } catch { /* Windows no siempre deja; el aviso va abajo */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function leerCredencial() {
|
|
89
|
+
if (!existsSync(CREDENCIAL)) return null;
|
|
90
|
+
try { return JSON.parse(readFileSync(CREDENCIAL, 'utf8')); } catch { return null; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Devuelve un token de acceso FRESCO, o null si no hay sesión. Es lo que usará el MCP. */
|
|
94
|
+
export async function tokenDeAcceso() {
|
|
95
|
+
const cred = leerCredencial();
|
|
96
|
+
if (!cred?.refresh_token) return null;
|
|
97
|
+
const sb = cliente();
|
|
98
|
+
const { data, error } = await sb.auth.refreshSession({ refresh_token: cred.refresh_token });
|
|
99
|
+
if (error || !data.session) return null;
|
|
100
|
+
guardar(data.session); // el refresh token ROTA: si no se reescribe, la próxima vez falla
|
|
101
|
+
return data.session.access_token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Abrir el navegador ───────────────────────────────────────────────────────
|
|
105
|
+
function abrirNavegador(url) {
|
|
106
|
+
const p = process.platform;
|
|
107
|
+
const [cmd, args] = p === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
108
|
+
: p === 'darwin' ? ['open', [url]]
|
|
109
|
+
: ['xdg-open', [url]];
|
|
110
|
+
try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); return true; } catch { return false; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function entrar(correo) {
|
|
114
|
+
const estado = randomBytes(16).toString('hex');
|
|
115
|
+
// El servidor primero: hay que saber el puerto ANTES de pedirle a Supabase el enlace, porque
|
|
116
|
+
// el puerto va dentro del `redirectTo`.
|
|
117
|
+
const s = createServer(() => {});
|
|
118
|
+
await new Promise((r) => s.listen(0, '127.0.0.1', r));
|
|
119
|
+
const puerto = s.address().port;
|
|
120
|
+
s.close();
|
|
121
|
+
const volverA = `http://127.0.0.1:${puerto}/callback`;
|
|
122
|
+
|
|
123
|
+
const sb = cliente();
|
|
124
|
+
let url;
|
|
125
|
+
if (correo) {
|
|
126
|
+
const { error } = await sb.auth.signInWithOtp({ email: correo, options: { emailRedirectTo: `${volverA}?state=${estado}` } });
|
|
127
|
+
if (error) throw new Error(`No se pudo mandar el enlace: ${error.message}`);
|
|
128
|
+
console.log(`\n 📧 Te mandé un enlace a ${correo}.`);
|
|
129
|
+
console.log(' Ábrelo en este mismo equipo y volverás aquí solo.');
|
|
130
|
+
// Mientras Booster no tenga servidor de correo propio, esto sale con la cara de Supabase.
|
|
131
|
+
// Medido el 2026-09-07: remitente `noreply@mail.app.supabase.io`, en inglés, y un tope de
|
|
132
|
+
// DOS por hora en todo el proyecto. Decirlo por delante evita que acabe en spam y que el
|
|
133
|
+
// maestro escriba diciendo que no le llegó nada.
|
|
134
|
+
console.log('\n ⚠️ De momento el enlace llega firmado por «Supabase» y en inglés, porque');
|
|
135
|
+
console.log(' Booster todavía no tiene servidor de correo propio. Míralo en spam si no');
|
|
136
|
+
console.log(' aparece. Y solo salen 2 por hora: si llevas prisa, entra con Google.\n');
|
|
137
|
+
} else {
|
|
138
|
+
const { data, error } = await sb.auth.signInWithOAuth({
|
|
139
|
+
provider: 'google',
|
|
140
|
+
options: { redirectTo: `${volverA}?state=${estado}`, skipBrowserRedirect: true },
|
|
141
|
+
});
|
|
142
|
+
if (error) throw new Error(`No se pudo preparar el acceso: ${error.message}`);
|
|
143
|
+
url = data.url;
|
|
144
|
+
console.log('\n Abriendo el navegador para que entres…');
|
|
145
|
+
console.log(` Si no se abre solo, pega esto:\n\n ${url}\n`);
|
|
146
|
+
abrirNavegador(url);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const codigo = await escuchar(puerto, estado);
|
|
150
|
+
const { data, error } = await sb.auth.exchangeCodeForSession(codigo);
|
|
151
|
+
if (error) throw new Error(`El código no valía: ${error.message}`);
|
|
152
|
+
guardar(data.session);
|
|
153
|
+
console.log(`\n ✓ Dentro como ${data.session.user.email}`);
|
|
154
|
+
console.log(` La credencial queda en ${CREDENCIAL} (solo tuya, permisos 600).`);
|
|
155
|
+
if (process.platform === 'win32') {
|
|
156
|
+
console.log(' ⚠️ En Windows los permisos de archivo no funcionan igual: cualquier programa');
|
|
157
|
+
console.log(' que corra con tu usuario puede leerla. Es la misma regla que ~/.claude.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Levanta el loopback en un puerto ya elegido y espera el `?code=`. */
|
|
162
|
+
function escuchar(puerto, estado) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const s = createServer((req, res) => {
|
|
165
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
166
|
+
if (u.pathname !== '/callback') { res.writeHead(404).end(); return; }
|
|
167
|
+
const pagina = (titulo, cuerpo, ok) => {
|
|
168
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
169
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${titulo}</title>`
|
|
170
|
+
+ '<body style="font:16px/1.5 system-ui;background:#0b1220;color:#e2e8f0;display:grid;'
|
|
171
|
+
+ 'place-items:center;height:100vh;margin:0;text-align:center">'
|
|
172
|
+
+ `<div><div style="font-size:44px">${ok ? '✓' : '✕'}</div>`
|
|
173
|
+
+ `<h1 style="font-size:20px;margin:.4em 0">${titulo}</h1>`
|
|
174
|
+
+ `<p style="color:#94a3b8">${cuerpo}</p></div>`);
|
|
175
|
+
};
|
|
176
|
+
if (u.searchParams.get('state') !== estado) {
|
|
177
|
+
pagina('Esa autorización no era para aquí', 'Vuelve a la terminal e inténtalo otra vez.', false);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const err = u.searchParams.get('error_description') || u.searchParams.get('error');
|
|
181
|
+
if (err) { pagina('No se pudo entrar', err, false); s.close(); reject(new Error(err)); return; }
|
|
182
|
+
const codigo = u.searchParams.get('code');
|
|
183
|
+
if (!codigo) { pagina('Falta el código', 'Vuelve a la terminal e inténtalo otra vez.', false); return; }
|
|
184
|
+
pagina('Listo', 'Ya puedes cerrar esta pestaña y volver a la terminal.', true);
|
|
185
|
+
s.close();
|
|
186
|
+
resolve(codigo);
|
|
187
|
+
});
|
|
188
|
+
s.on('error', reject);
|
|
189
|
+
s.listen(puerto, '127.0.0.1');
|
|
190
|
+
// Si nadie vuelve en cinco minutos, no dejamos el proceso colgado para siempre.
|
|
191
|
+
setTimeout(() => { s.close(); reject(new Error('Nadie volvió del navegador en 5 minutos.')); }, 5 * 60_000).unref();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function quien() {
|
|
196
|
+
const cred = leerCredencial();
|
|
197
|
+
if (!cred) { console.log(` No hay sesión en este equipo. Usa: ${YO} entrar`); return; }
|
|
198
|
+
const token = await tokenDeAcceso();
|
|
199
|
+
if (!token) { console.log(` La sesión de ${cred.correo ?? 'este equipo'} ya no vale. Vuelve a entrar.`); return; }
|
|
200
|
+
const r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
201
|
+
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
|
|
202
|
+
});
|
|
203
|
+
const u = await r.json();
|
|
204
|
+
console.log(` ✓ ${u.email}`);
|
|
205
|
+
console.log(` id: ${u.id}`);
|
|
206
|
+
console.log(` credencial guardada el ${cred.guardado}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function salir() {
|
|
210
|
+
if (existsSync(CREDENCIAL)) { rmSync(CREDENCIAL); console.log(' Credencial borrada de este equipo.'); }
|
|
211
|
+
else console.log(' No había ninguna credencial que borrar.');
|
|
212
|
+
console.log(' (La sesión sigue viva en Supabase: para cortarla de verdad, ciérrala desde el sitio.)');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Entrada ──────────────────────────────────────────────────────────────────
|
|
216
|
+
// La ayuda repite el comando TAL Y COMO lo invocaron. Escribir `node mcp/sesion.mjs` a secas
|
|
217
|
+
// mandaba a la gente a una ruta relativa que solo vale desde la carpeta del proyecto: Antonio se
|
|
218
|
+
// lo comió en la primera prueba (2026-09-07) y perdió un intento con un MODULE_NOT_FOUND.
|
|
219
|
+
const YO = `node ${process.argv[1] ?? 'mcp/sesion.mjs'}`;
|
|
220
|
+
const [, , orden, ...resto] = process.argv;
|
|
221
|
+
const correo = resto.includes('--correo') ? resto[resto.indexOf('--correo') + 1] : null;
|
|
222
|
+
|
|
223
|
+
// ⚠️ Solo cuando ESTE archivo es el que se ejecutó. Sin este candado, cualquiera que importara
|
|
224
|
+
// `tokenDeAcceso()` se comía además el menú de ayuda por pantalla — que es justo lo que hizo
|
|
225
|
+
// `proyectos.mjs` la primera vez que se probó.
|
|
226
|
+
// `pathToFileURL` y no una plantilla a mano: en Windows la URL lleva TRES barras
|
|
227
|
+
// (`file:///C:/...`), y compararla con dos hacía que el candado no abriera nunca — el comando
|
|
228
|
+
// se quedaba mudo, sin error. Que es la peor manera de fallar.
|
|
229
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
230
|
+
try {
|
|
231
|
+
if (orden === 'entrar') await entrar(correo);
|
|
232
|
+
else if (orden === 'quien') await quien();
|
|
233
|
+
else if (orden === 'salir') salir();
|
|
234
|
+
else {
|
|
235
|
+
console.log('\n La puerta del MCP de Booster\n');
|
|
236
|
+
console.log(` ${YO} entrar entrar con Google (recomendado)`);
|
|
237
|
+
console.log(` ${YO} entrar --correo tu@correo entrar con un enlace por correo`);
|
|
238
|
+
console.log(` ${YO} quien a nombre de quién estoy`);
|
|
239
|
+
console.log(` ${YO} salir borrar la credencial de aquí\n`);
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(`\n ✕ ${e.message}\n`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
}
|
package/subidas.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// SUBIR ARCHIVOS DESDE LA TERMINAL.
|
|
3
|
+
//
|
|
4
|
+
// Es la pieza que le faltaba a la fábrica de Planificatron. Ellos generan FUERA de Booster
|
|
5
|
+
// —las imágenes con Flux en su propia gráfica, la voz con edge-tts— y el resultado entra por
|
|
6
|
+
// aquí. Booster no genera ninguna de las dos cosas (se retiró por costo, y el audio nunca se
|
|
7
|
+
// generó) y esto no lo cambia: la fábrica no le añade capacidades a Booster, le añade un
|
|
8
|
+
// PROVEEDOR DE ARCHIVOS. Un MP3 de un TTS y un MP3 que subió un maestro son el mismo elemento
|
|
9
|
+
// con una URL de R2, y el documento no puede notar la diferencia.
|
|
10
|
+
//
|
|
11
|
+
// Pasa por la MISMA función que usa el navegador (`functions/api/upload.ts`), con el token del
|
|
12
|
+
// maestro: valida el JWT, sanea la extensión y escribe a R2 por binding. Nada nuevo que
|
|
13
|
+
// mantener, y las mismas reglas para los dos caminos.
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ EL NOMBRE DEL ARCHIVO EN R2 ES EL HASH DE SU CONTENIDO. Subir dos veces lo mismo devuelve
|
|
16
|
+
// la MISMA URL y ocupa una sola vez. Para una fábrica que repite la misma ilustración en
|
|
17
|
+
// cientos de clases, eso no es un detalle.
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
20
|
+
import { extname, basename } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const SUBIDA = 'https://boosterarena.com/api/upload';
|
|
23
|
+
|
|
24
|
+
/** Lo que el servidor acepta (ver `EXT_OK` en `functions/api/upload.ts`). */
|
|
25
|
+
const TIPOS = {
|
|
26
|
+
webp: 'image/webp', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
27
|
+
gif: 'image/gif', svg: 'image/svg+xml',
|
|
28
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', aac: 'audio/aac',
|
|
29
|
+
json: 'application/json', lottie: 'application/json',
|
|
30
|
+
};
|
|
31
|
+
const IMAGENES = ['webp', 'png', 'jpg', 'jpeg', 'gif', 'svg'];
|
|
32
|
+
const AUDIOS = ['mp3', 'wav', 'm4a', 'ogg', 'aac'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* El tope del guardián del peso de las arenas: un PNG/JPG de más de 300 KB es, casi siempre,
|
|
36
|
+
* uno que nadie pasó por `aligerar.mjs`. Aquí no se rechaza —el archivo es del maestro— pero se
|
|
37
|
+
* DICE, porque esto se le descarga al alumno y un agente no tiene ojos para notarlo.
|
|
38
|
+
*/
|
|
39
|
+
const AVISO_PESO = 300 * 1024;
|
|
40
|
+
|
|
41
|
+
export async function subirArchivo(token, ruta, familia) {
|
|
42
|
+
let info;
|
|
43
|
+
try {
|
|
44
|
+
info = await stat(ruta);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`No encuentro el archivo "${ruta}". Dame la ruta completa, y recuerda que `
|
|
47
|
+
+ 'tiene que estar en ESTA computadora (la que corre el servidor).');
|
|
48
|
+
}
|
|
49
|
+
if (!info.isFile()) throw new Error(`"${ruta}" no es un archivo.`);
|
|
50
|
+
|
|
51
|
+
const ext = extname(ruta).slice(1).toLowerCase();
|
|
52
|
+
const tipo = TIPOS[ext];
|
|
53
|
+
const permitidas = familia === 'imagen' ? IMAGENES : AUDIOS;
|
|
54
|
+
if (!tipo || !permitidas.includes(ext)) {
|
|
55
|
+
throw new Error(`"${ext || 'sin extensión'}" no vale para ${familia}. Se aceptan: ${permitidas.join(', ')}.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cuerpo = await readFile(ruta);
|
|
59
|
+
const r = await fetch(SUBIDA, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': tipo, 'X-Ext': ext },
|
|
62
|
+
body: cuerpo,
|
|
63
|
+
});
|
|
64
|
+
if (!r.ok) {
|
|
65
|
+
let detalle = String(r.status);
|
|
66
|
+
try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
|
|
67
|
+
if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
|
|
68
|
+
throw new Error(`No se pudo subir: ${detalle}`);
|
|
69
|
+
}
|
|
70
|
+
const { url } = await r.json();
|
|
71
|
+
|
|
72
|
+
const kb = Math.round(info.size / 1024);
|
|
73
|
+
const pesado = familia === 'imagen' && ['png', 'jpg', 'jpeg'].includes(ext) && info.size > AVISO_PESO;
|
|
74
|
+
return {
|
|
75
|
+
url,
|
|
76
|
+
nombre: basename(ruta),
|
|
77
|
+
kb,
|
|
78
|
+
aviso: pesado
|
|
79
|
+
? `⚠️ Pesa ${kb} KB y es .${ext}. Esto se le descarga al ALUMNO cuando abra el slide: `
|
|
80
|
+
+ 'conviértelo a WebP antes de subirlo y bajará a la décima parte. Se subió igual, pero '
|
|
81
|
+
+ 'si vas a repetirlo en muchas clases, arréglalo ahora.'
|
|
82
|
+
: '',
|
|
83
|
+
};
|
|
84
|
+
}
|