utn-mcp 0.8.2 → 0.9.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.md +29 -2
- package/build/aula-virtual/client/client.d.ts +97 -0
- package/build/aula-virtual/client/client.js +21 -0
- package/build/aula-virtual/client/credentials.d.ts +10 -0
- package/build/aula-virtual/client/link.d.ts +21 -0
- package/build/aula-virtual/client/local-session.d.ts +23 -0
- package/build/aula-virtual/client/moodle-api.d.ts +25 -0
- package/build/aula-virtual/client/moodle-api.js +28 -4
- package/build/aula-virtual/client/regionals.d.ts +47 -0
- package/build/aula-virtual/client/session.d.ts +12 -0
- package/build/aula-virtual/client/sso-login.d.ts +60 -0
- package/build/aula-virtual/client/stored-session.d.ts +32 -0
- package/build/aula-virtual/client/types.d.ts +173 -0
- package/build/aula-virtual/format/actions.d.ts +57 -0
- package/build/aula-virtual/format/activities.d.ts +86 -0
- package/build/aula-virtual/format/address.d.ts +36 -0
- package/build/aula-virtual/format/address.js +11 -0
- package/build/aula-virtual/format/fields.d.ts +11 -0
- package/build/aula-virtual/format/files.d.ts +32 -0
- package/build/aula-virtual/format/files.js +2 -2
- package/build/aula-virtual/format/navigate.d.ts +213 -0
- package/build/aula-virtual/format/study-pack.d.ts +33 -0
- package/build/aula-virtual/format/study-pack.js +199 -0
- package/build/aula-virtual/format/submission.d.ts +40 -0
- package/build/aula-virtual/login.d.ts +6 -0
- package/build/aula-virtual/tools.d.ts +4 -0
- package/build/aula-virtual/tools.js +40 -0
- package/build/autogestion/client/catalog.d.ts +20 -0
- package/build/autogestion/client/catalog.js +22 -0
- package/build/autogestion/client/connector.d.ts +61 -0
- package/build/autogestion/client/connector.js +31 -0
- package/build/autogestion/client/credentials.d.ts +5 -0
- package/build/autogestion/client/credentials.js +40 -0
- package/build/autogestion/client/install.d.ts +14 -0
- package/build/autogestion/client/install.js +72 -0
- package/build/autogestion/client/installed-systems.d.ts +7 -0
- package/build/autogestion/client/installed-systems.js +58 -0
- package/build/autogestion/client/local-session.d.ts +17 -0
- package/build/autogestion/client/local-session.js +37 -0
- package/build/autogestion/client/session.d.ts +14 -0
- package/build/autogestion/client/session.js +1 -0
- package/build/autogestion/client/types.d.ts +49 -0
- package/build/autogestion/client/types.js +1 -0
- package/build/autogestion/format/correlatividades.d.ts +4 -0
- package/build/autogestion/format/correlatividades.js +4 -0
- package/build/autogestion/format/estado-academico.d.ts +4 -0
- package/build/autogestion/format/estado-academico.js +4 -0
- package/build/autogestion/format/examenes.d.ts +3 -0
- package/build/autogestion/format/examenes.js +8 -0
- package/build/autogestion/format/notas-parciales.d.ts +3 -0
- package/build/autogestion/format/notas-parciales.js +8 -0
- package/build/autogestion/login.d.ts +11 -0
- package/build/autogestion/login.js +59 -0
- package/build/autogestion/tools.d.ts +3 -0
- package/build/autogestion/tools.js +73 -0
- package/build/http.d.ts +22 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +11 -1
- package/build/oauth/access.d.ts +18 -0
- package/build/oauth/admin.d.ts +8 -0
- package/build/oauth/admin.js +51 -10
- package/build/oauth/expiring.d.ts +25 -0
- package/build/oauth/pending-login.d.ts +20 -0
- package/build/oauth/redirects.d.ts +20 -0
- package/build/oauth/server.d.ts +22 -0
- package/build/server.d.ts +9 -0
- package/build/server.js +21 -2
- package/build/shared/documents/index.d.ts +33 -0
- package/build/shared/documents/merge.d.ts +32 -0
- package/build/shared/documents/merge.js +197 -0
- package/build/shared/documents/office.d.ts +9 -0
- package/build/shared/documents/pdf.d.ts +2 -0
- package/build/shared/documents/png.d.ts +2 -0
- package/build/shared/documents/zip.d.ts +18 -0
- package/build/shared/html.d.ts +6 -0
- package/build/shared/html.js +7 -1
- package/build/shared/http.d.ts +6 -0
- package/build/shared/input.d.ts +7 -0
- package/build/shared/load-env.d.ts +1 -0
- package/build/shared/logger.d.ts +5 -0
- package/build/shared/pages.d.ts +6 -0
- package/build/shared/paths.d.ts +10 -0
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.d.ts +77 -0
- package/build/shared/store/sqlite-store.d.ts +36 -0
- package/build/shared/uploads.d.ts +62 -0
- package/package.json +9 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mergePdfs } from "../../shared/documents/merge.js";
|
|
5
|
+
import { exportsDir } from "../../shared/paths.js";
|
|
6
|
+
import { getCourseFiles, getUserCourses } from "../client/client.js";
|
|
7
|
+
import { parseAddress } from "./address.js";
|
|
8
|
+
import { downloadUrl, MAX_BYTES } from "./files.js";
|
|
9
|
+
import { fetchFile } from "../client/moodle-api.js";
|
|
10
|
+
/**
|
|
11
|
+
* Topes de tamaño por pack: pdf-lib junta todo el resultado en memoria, así que sin tope total un
|
|
12
|
+
* curso enorme podría colgar la compu donde corre el servidor. Lo que queda afuera por un tope se
|
|
13
|
+
* lista en `omitidos` con el motivo, para pedir el resto en otro pack.
|
|
14
|
+
* Una variable de entorno con basura NO puede desactivar el tope: si no es un número válido,
|
|
15
|
+
* vuelve al default.
|
|
16
|
+
*/
|
|
17
|
+
const capFromEnv = (raw, fallback) => {
|
|
18
|
+
const n = Number(raw ?? fallback);
|
|
19
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
20
|
+
};
|
|
21
|
+
const MAX_PACK_FILES = capFromEnv(process.env.UTN_MCP_PACK_MAX_FILES, 150);
|
|
22
|
+
const MAX_PACK_BYTES = capFromEnv(process.env.UTN_MCP_PACK_MAX_MB, 300) * 1024 * 1024;
|
|
23
|
+
/** Máximo del PDF final: se estima con los tamaños de entrada para cortar antes de bajar todo. */
|
|
24
|
+
const MAX_FINAL_MB = capFromEnv(process.env.UTN_MCP_PACK_MAX_FINAL_MB, 30);
|
|
25
|
+
const MAX_FINAL_BYTES = MAX_FINAL_MB * 1024 * 1024;
|
|
26
|
+
const isPdf = (e) => e.file.mimetype === "application/pdf" || e.file.filename.toLowerCase().endsWith(".pdf");
|
|
27
|
+
/** Los caracteres que no sirven para un nombre de archivo se cambian por "-". */
|
|
28
|
+
const safeFileName = (name) => name.replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
29
|
+
/**
|
|
30
|
+
* Escribe el pack sin pisar packs anteriores: nombre con fecha-hora y, si ya existe (dos packs
|
|
31
|
+
* en el mismo segundo), un sufijo -2, -3... La escritura es atómica por nombre (bandera "wx").
|
|
32
|
+
*/
|
|
33
|
+
export async function writePackFile(dir, courseName, bytes) {
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
36
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
37
|
+
const base = join(dir, `${safeFileName(courseName)}-apuntes-${stamp}`);
|
|
38
|
+
for (let n = 1; n <= 100; n++) {
|
|
39
|
+
const candidate = n === 1 ? `${base}.pdf` : `${base}-${n}.pdf`;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(candidate, bytes, { flag: "wx" });
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e.code !== "EEXIST")
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`No se pudo guardar el pack: ya existen 100 archivos para "${base}".`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Baja PDF de un curso y los une en un solo archivo con índice, guardado en exportsDir().
|
|
53
|
+
* Con `archivos` une solo las URL pedidas (tal como las devuelve aula_open, validadas con
|
|
54
|
+
* `downloadUrl` como en aula_read_file); sin `archivos`, todos los PDF del curso. El pack sigue
|
|
55
|
+
* el orden del aula, no el de la lista, para que el índice acompañe las unidades. Los duplicados
|
|
56
|
+
* exactos se descartan al armar, porque ahí se bajan igual. Todo lo omitido queda listado en
|
|
57
|
+
* `omitidos` con el motivo; en el atajo sin `archivos`, los archivos que no son PDF solo se
|
|
58
|
+
* cuentan en `archivosNoPdf` en vez de listarse uno por uno.
|
|
59
|
+
*/
|
|
60
|
+
export async function buildStudyPack(session, rawAddress, archivos) {
|
|
61
|
+
const address = parseAddress(rawAddress);
|
|
62
|
+
if (address.level !== "course") {
|
|
63
|
+
throw new Error(`Para armar el pack pasá la dirección de un curso entero ("/curso/{courseId}", de aula_list_courses), no "${rawAddress}".`);
|
|
64
|
+
}
|
|
65
|
+
const { baseUrl } = await session.getRegional();
|
|
66
|
+
const course = (await getUserCourses(session)).find((c) => c.id === address.courseId);
|
|
67
|
+
const entries = await getCourseFiles(session, address.courseId);
|
|
68
|
+
let selected;
|
|
69
|
+
const omitted = [];
|
|
70
|
+
if (archivos) {
|
|
71
|
+
// Cada URL pedida se valida igual que en aula_read_file: solo archivos del aula del usuario.
|
|
72
|
+
const requested = new Set(archivos.map((u) => downloadUrl(baseUrl, u).href));
|
|
73
|
+
const byUrl = new Map(entries.map((e) => [downloadUrl(baseUrl, e.file.fileurl).href, e]));
|
|
74
|
+
const unknown = [...requested].filter((u) => !byUrl.has(u));
|
|
75
|
+
if (unknown.length) {
|
|
76
|
+
throw new Error(`Estas URL no son de archivos del curso (salen de aula_open sobre "${rawAddress}"):\n` +
|
|
77
|
+
unknown.map((u) => `- ${u}`).join("\n"));
|
|
78
|
+
}
|
|
79
|
+
// El orden lo manda el aula, no la lista: el índice sigue las unidades.
|
|
80
|
+
selected = entries.filter((e) => requested.has(downloadUrl(baseUrl, e.file.fileurl).href));
|
|
81
|
+
for (const e of selected.filter((x) => !isPdf(x))) {
|
|
82
|
+
omitted.push({ nombre: e.file.filename, seccion: e.section, motivo: "no es un PDF" });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
selected = entries;
|
|
87
|
+
}
|
|
88
|
+
const pdfs = selected.filter(isPdf);
|
|
89
|
+
const archivosNoPdf = archivos ? 0 : selected.length - pdfs.length;
|
|
90
|
+
if (!pdfs.length) {
|
|
91
|
+
throw new Error(archivos
|
|
92
|
+
? "Ninguna de las URL pedidas es un PDF: la tool une únicamente PDF."
|
|
93
|
+
: "El curso no tiene ningún PDF en sus secciones visibles.");
|
|
94
|
+
}
|
|
95
|
+
const capMotivo = `se alcanzó el tope del pack (${MAX_PACK_FILES} archivos / ${MAX_PACK_BYTES / 1024 / 1024} MB)`;
|
|
96
|
+
const inputs = [];
|
|
97
|
+
const kept = [];
|
|
98
|
+
const seen = new Map(); // hash -> nombre del primero con ese contenido
|
|
99
|
+
let totalBytes = 0;
|
|
100
|
+
let finalLleno = false;
|
|
101
|
+
for (const entry of pdfs) {
|
|
102
|
+
const name = entry.file.filename;
|
|
103
|
+
if (finalLleno) {
|
|
104
|
+
omitted.push({
|
|
105
|
+
nombre: name,
|
|
106
|
+
seccion: entry.section,
|
|
107
|
+
motivo: `el PDF final llegó al tope de ${MAX_FINAL_MB} MB (estimado con los tamaños de entrada)`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (inputs.length >= MAX_PACK_FILES || totalBytes + (entry.file.filesize ?? 0) > MAX_PACK_BYTES) {
|
|
112
|
+
omitted.push({ nombre: name, seccion: entry.section, motivo: capMotivo });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let bytes;
|
|
116
|
+
try {
|
|
117
|
+
bytes = (await fetchFile(session, downloadUrl(baseUrl, entry.file.fileurl), MAX_BYTES)).bytes;
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
omitted.push({ nombre: name, seccion: entry.section, motivo: e.message });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
124
|
+
const original = seen.get(hash);
|
|
125
|
+
if (original) {
|
|
126
|
+
omitted.push({ nombre: name, seccion: entry.section, motivo: `es idéntico a "${original}"` });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// El tamaño real se conoce recién ahora (Moodle a veces no lo anuncia): tope post-descarga.
|
|
130
|
+
if (totalBytes + bytes.length > MAX_PACK_BYTES) {
|
|
131
|
+
omitted.push({ nombre: name, seccion: entry.section, motivo: capMotivo });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Corte temprano del tope del PDF final, estimado con los tamaños de entrada: lo que ya
|
|
135
|
+
// no puede entrar no se baja de más ni tira abajo el pack entero al final.
|
|
136
|
+
if (totalBytes + bytes.length > MAX_FINAL_BYTES) {
|
|
137
|
+
finalLleno = true;
|
|
138
|
+
omitted.push({
|
|
139
|
+
nombre: name,
|
|
140
|
+
seccion: entry.section,
|
|
141
|
+
motivo: `el PDF final llegó al tope de ${MAX_FINAL_MB} MB (estimado con los tamaños de entrada)`,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
seen.set(hash, name);
|
|
146
|
+
inputs.push({ title: name, bytes });
|
|
147
|
+
kept.push(entry);
|
|
148
|
+
totalBytes += bytes.length;
|
|
149
|
+
}
|
|
150
|
+
// En el índice va el módulo, que se lee mejor que el archivo; si el módulo aporta más de un
|
|
151
|
+
// archivo, también el archivo, para que las entradas no queden repetidas.
|
|
152
|
+
const perModule = new Map();
|
|
153
|
+
const moduleKey = (e) => `${e.section}\n${e.module}`;
|
|
154
|
+
for (const e of kept)
|
|
155
|
+
perModule.set(moduleKey(e), (perModule.get(moduleKey(e)) ?? 0) + 1);
|
|
156
|
+
kept.forEach((e, i) => {
|
|
157
|
+
inputs[i].title = perModule.get(moduleKey(e)) > 1 ? `${e.module}: ${e.file.filename}` : e.module;
|
|
158
|
+
});
|
|
159
|
+
const courseName = course?.shortname ?? `curso-${address.courseId}`;
|
|
160
|
+
const dir = exportsDir();
|
|
161
|
+
// La estimación puede quedar corta (el índice y los enlaces agregan páginas): si el unido
|
|
162
|
+
// supera el tope igual, salen los últimos materiales hasta que entre, en vez de tirar todo
|
|
163
|
+
// el trabajo de descarga.
|
|
164
|
+
let pack = await mergePdfs(inputs, `Apuntes de ${course?.fullname ?? courseName}`);
|
|
165
|
+
while (pack.bytes.length > MAX_FINAL_BYTES && inputs.length > 1) {
|
|
166
|
+
inputs.pop();
|
|
167
|
+
const entry = kept.pop();
|
|
168
|
+
omitted.push({
|
|
169
|
+
nombre: entry.file.filename,
|
|
170
|
+
seccion: entry.section,
|
|
171
|
+
motivo: `el PDF final superó el tope de ${MAX_FINAL_MB} MB al unir`,
|
|
172
|
+
});
|
|
173
|
+
pack = await mergePdfs(inputs, `Apuntes de ${course?.fullname ?? courseName}`);
|
|
174
|
+
}
|
|
175
|
+
if (pack.bytes.length > MAX_FINAL_BYTES) {
|
|
176
|
+
const mb = Math.round((pack.bytes.length / 1024 / 1024) * 10) / 10;
|
|
177
|
+
throw new Error(`El PDF final pesa ${mb} MB, más del máximo (${MAX_FINAL_MB} MB): no se guardó. ` +
|
|
178
|
+
"Pedí el pack con `archivos`, eligiendo solo los PDF que necesitás.");
|
|
179
|
+
}
|
|
180
|
+
for (const s of pack.skipped) {
|
|
181
|
+
omitted.push({ nombre: kept[s.index].file.filename, seccion: kept[s.index].section, motivo: s.reason });
|
|
182
|
+
}
|
|
183
|
+
await mkdir(dir, { recursive: true });
|
|
184
|
+
const path = await writePackFile(dir, courseName, pack.bytes);
|
|
185
|
+
return {
|
|
186
|
+
archivo: path,
|
|
187
|
+
curso: course?.fullname ?? courseName,
|
|
188
|
+
paginas: pack.pages,
|
|
189
|
+
materiales: pack.merged.map((m) => ({
|
|
190
|
+
nombre: kept[m.index].file.filename,
|
|
191
|
+
seccion: kept[m.index].section,
|
|
192
|
+
modulo: kept[m.index].module,
|
|
193
|
+
paginas: m.pages,
|
|
194
|
+
empiezaEn: m.startPage,
|
|
195
|
+
})),
|
|
196
|
+
omitidos: omitted.length ? omitted : undefined,
|
|
197
|
+
archivosNoPdf: archivosNoPdf || undefined,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type FileSource, type UploadInbox } from "../../shared/uploads.js";
|
|
2
|
+
import type { AulaSession } from "../client/session.js";
|
|
3
|
+
export interface SubmissionRequest {
|
|
4
|
+
/** La lista final de archivos: rutas locales (solo STDIO) o la `url` de uno que ya está en la entrega. */
|
|
5
|
+
files?: string[];
|
|
6
|
+
/** Id de un link de subida (solo HTTP): sus archivos se suman a la lista. */
|
|
7
|
+
upload?: string;
|
|
8
|
+
/** Texto plano del modelo; reemplaza el texto en línea. */
|
|
9
|
+
text?: string;
|
|
10
|
+
/** Confirmar el envío si la tarea usa borradores. */
|
|
11
|
+
send: boolean;
|
|
12
|
+
acceptStatement: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Abre un link para recibir los archivos de una entrega en modo HTTP, con los límites de la tarea. */
|
|
15
|
+
export declare function createUploadLink(session: AulaSession, rawAddress: string, inbox: UploadInbox, owner: string): Promise<{
|
|
16
|
+
subida: string;
|
|
17
|
+
url: string;
|
|
18
|
+
vence: string;
|
|
19
|
+
acepta: {
|
|
20
|
+
maximo: number | undefined;
|
|
21
|
+
tamanioMaximo: number;
|
|
22
|
+
extensiones: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
conTerminal: string;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Deja la entrega con los archivos y el texto pedidos, y la envía si hace falta. Lo que no se pasa
|
|
28
|
+
* se conserva: Moodle reemplaza cada plugin con lo que recibe, así que se le reenvía lo que ya tenía.
|
|
29
|
+
*/
|
|
30
|
+
export declare function submitAssignment(session: AulaSession, rawAddress: string, request: SubmissionRequest, source: FileSource): Promise<{
|
|
31
|
+
direccion: string;
|
|
32
|
+
estado: string;
|
|
33
|
+
aviso: string | undefined;
|
|
34
|
+
archivos: {
|
|
35
|
+
nombre: string;
|
|
36
|
+
tamanio: number;
|
|
37
|
+
url: string;
|
|
38
|
+
}[] | undefined;
|
|
39
|
+
texto: string | undefined;
|
|
40
|
+
}>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `utn-mcp login`: pide la regional, usuario y contraseña una vez, los guarda en el keychain
|
|
3
|
+
* y obtiene el primer token. Después el servidor renueva solo.
|
|
4
|
+
* Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runLogin(http: boolean): Promise<void>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { FileSource } from "../shared/uploads.js";
|
|
3
|
+
import type { AulaSession } from "./client/session.js";
|
|
4
|
+
export declare function registerAulaVirtualTools(server: McpServer, session: AulaSession, files: FileSource): void;
|
|
@@ -3,6 +3,7 @@ import { getSiteInfo } from "./client/client.js";
|
|
|
3
3
|
import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToForum, sendMessage, } from "./format/actions.js";
|
|
4
4
|
import { ROOT } from "./format/address.js";
|
|
5
5
|
import { readAulaFile } from "./format/files.js";
|
|
6
|
+
import { buildStudyPack } from "./format/study-pack.js";
|
|
6
7
|
import { open } from "./format/navigate.js";
|
|
7
8
|
import { createUploadLink, submitAssignment } from "./format/submission.js";
|
|
8
9
|
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
@@ -125,6 +126,45 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
125
126
|
return failure(e);
|
|
126
127
|
}
|
|
127
128
|
});
|
|
129
|
+
// El pack se guarda en el disco: solo en STDIO, donde el servidor corre en la compu del usuario.
|
|
130
|
+
if (files.local) {
|
|
131
|
+
server.registerTool("aula_study_pack", {
|
|
132
|
+
description: "Une PDF del aula en un solo compilado de estudio con índice clickeable (cada " +
|
|
133
|
+
"entrada abre la página donde empieza ese material, y cada página tiene arriba " +
|
|
134
|
+
"un enlace para volver), guardado en la compu del usuario. Une ÚNICAMENTE PDF; " +
|
|
135
|
+
"si una URL pedida no es PDF, queda en `omitidos` con el motivo.\n\n" +
|
|
136
|
+
"Conviene llamar antes a aula_open del curso: devuelve cada módulo con sus " +
|
|
137
|
+
"archivos (nombre, url, sección y tipo) y permite elegir los apuntes sin bajar " +
|
|
138
|
+
"nada. Es un compilado íntegro, no un resumen.\n\n" +
|
|
139
|
+
"Hay topes por memoria: 150 archivos, 300 MB de descarga total y 30 MB el PDF " +
|
|
140
|
+
"final (estimado con los tamaños de entrada, para no bajar archivos de más). Lo " +
|
|
141
|
+
"que no entra queda en `omitidos` con el motivo: se pide el resto en otro pack. " +
|
|
142
|
+
"La respuesta trae la ruta en `archivo` y `materiales` con sección, módulo, " +
|
|
143
|
+
"páginas y página de inicio de cada uno.\n\n" +
|
|
144
|
+
"La `direccion` del curso sale de aula_list_courses. Para leer o pasarle al " +
|
|
145
|
+
"usuario archivos que no son PDF, usá aula_open y aula_read_file.",
|
|
146
|
+
inputSchema: z.object({
|
|
147
|
+
direccion: z
|
|
148
|
+
.string()
|
|
149
|
+
.describe('Dirección del curso, de aula_list_courses. Ej: "/curso/691".'),
|
|
150
|
+
archivos: z
|
|
151
|
+
.array(z.string())
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("URL de los PDF a unir, tal como las devuelve aula_open del curso (conviene " +
|
|
154
|
+
"llamarlo antes y elegir: cada archivo trae nombre, sección y tipo). El pack " +
|
|
155
|
+
"sigue el orden del aula, no el de esta lista. Sin `archivos`, une todos los " +
|
|
156
|
+
"PDF del curso."),
|
|
157
|
+
}),
|
|
158
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
159
|
+
}, async ({ direccion, archivos }) => {
|
|
160
|
+
try {
|
|
161
|
+
return json(await buildStudyPack(session, direccion, archivos));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return failure(e);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
128
168
|
// --- Escritura: lo que sigue cambia algo en el aula, y casi todo queda a la vista de otras personas. ---
|
|
129
169
|
const CONFIRM_FIRST = "\n\nAntes de llamarla, mostrale al usuario exactamente lo que se va a hacer y esperá que confirme.";
|
|
130
170
|
server.registerTool("aula_forum_post", {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Qué se sabe del conector en una regional: `verified` = alguien entró con una cuenta real; `candidate` = falta esa prueba. */
|
|
2
|
+
export type SystemSupport = "verified" | "candidate";
|
|
3
|
+
/**
|
|
4
|
+
* Un conector que `utn-mcp login` sabe instalar: el `.tgz` de `npm pack` adjunto a un release del repo.
|
|
5
|
+
* La URL fija la versión y `integrity` el contenido exacto: cargar un paquete es ejecutar su código,
|
|
6
|
+
* así que solo se instala lo que nombra este catálogo, nunca un nombre que venga del usuario o del entorno.
|
|
7
|
+
*/
|
|
8
|
+
export interface CatalogEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
package: string;
|
|
12
|
+
version: string;
|
|
13
|
+
tarball: string;
|
|
14
|
+
/** sha512 del `.tgz` en base64, como el campo `integrity` de npm. */
|
|
15
|
+
integrity: string;
|
|
16
|
+
/** Las regionales que atiende y su soporte, para ofrecerlo en el login antes de bajar nada. */
|
|
17
|
+
regionales: Record<string, SystemSupport>;
|
|
18
|
+
}
|
|
19
|
+
export declare const CATALOG: CatalogEntry[];
|
|
20
|
+
export declare function catalogEntryForRegional(regionalId: string): CatalogEntry | null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const RELEASES = "https://github.com/nialvape/utn-mcp/releases/download";
|
|
2
|
+
/** Las mismas regionales que `ASP_REGIONALS` del paquete; las que quedaron afuera dicen por qué ahí. */
|
|
3
|
+
const SYSACAD_ASP_REGIONALS = [
|
|
4
|
+
"frbb", "frch", "frcon", "frd", "frgp", "frh", "frlr", "frmdp", "frn",
|
|
5
|
+
"frp", "frrq", "frsf", "frsfco", "frsn", "frsr", "frt", "frtdf", "frvt",
|
|
6
|
+
];
|
|
7
|
+
export const CATALOG = [
|
|
8
|
+
{
|
|
9
|
+
id: "sysacad-asp",
|
|
10
|
+
name: "Sysacad",
|
|
11
|
+
package: "utn-mcp-sysacad-asp",
|
|
12
|
+
version: "0.1.0",
|
|
13
|
+
tarball: `${RELEASES}/sysacad-asp-v0.1.0/utn-mcp-sysacad-asp-0.1.0.tgz`,
|
|
14
|
+
integrity: "sha512-dFiMVUwQJdLHmrKD6BZnUgn5EU73pzAzSBEEQyCA4P2eXOI6QeTRLnmGW//K1hmFJXl74Ax10VQrpVweY6+1Gw==",
|
|
15
|
+
// FRT anduvo con cuenta real (oct 2026) con la versión anterior, que posteaba directo a `menuAlumno.asp`;
|
|
16
|
+
// esta lee el formulario y todavía no entró nadie con ella.
|
|
17
|
+
regionales: Object.fromEntries(SYSACAD_ASP_REGIONALS.map((id) => [id, "candidate"])),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
export function catalogEntryForRegional(regionalId) {
|
|
21
|
+
return CATALOG.find((entry) => regionalId in entry.regionales) ?? null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
import { htmlToText } from "../../shared/html.js";
|
|
3
|
+
import type { Correlatividades, EstadoMateria, ExamenMateria, NotasParcialesMateria } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Un sistema de autogestión académica (Sysacad, SIU Guaraní, etc.) logueado con un usuario.
|
|
6
|
+
* Cada sistema es un paquete aparte que exporta un `AutogestionSystem`; el core lo instala desde su
|
|
7
|
+
* catálogo y le pide un conector para la regional del usuario.
|
|
8
|
+
*/
|
|
9
|
+
export interface AutogestionConnector {
|
|
10
|
+
/** Id del sistema, tal como lo declara el paquete que lo provee. */
|
|
11
|
+
readonly systemId: string;
|
|
12
|
+
/** Pide legajo/usuario y contraseña al sistema y deja la sesión lista. */
|
|
13
|
+
login(credentials: AutogestionCredentials): Promise<void>;
|
|
14
|
+
/** Materias del estado académico del alumno logueado, ya parseadas. */
|
|
15
|
+
estadoAcademico(): Promise<EstadoMateria[]>;
|
|
16
|
+
/** Correlatividades de la carrera del alumno logueado, para cursar y, si el sistema las publica, para rendir. */
|
|
17
|
+
correlatividades(): Promise<Correlatividades>;
|
|
18
|
+
/** Cursada actual, si el sistema la publica; no todos los conectores la tienen. */
|
|
19
|
+
notasParciales?(): Promise<NotasParcialesMateria[]>;
|
|
20
|
+
/** Historial de exámenes finales, si el sistema lo publica; no todos los conectores lo tienen. */
|
|
21
|
+
examenes?(): Promise<ExamenMateria[]>;
|
|
22
|
+
}
|
|
23
|
+
/** Credenciales de un sistema de autogestión. En Sysacad `username` es el legajo. */
|
|
24
|
+
export interface AutogestionCredentials {
|
|
25
|
+
username: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}
|
|
28
|
+
export declare class LoginRequiredError extends Error {
|
|
29
|
+
constructor(systemName: string);
|
|
30
|
+
}
|
|
31
|
+
export declare class InvalidCredentialsError extends Error {
|
|
32
|
+
constructor(systemName: string);
|
|
33
|
+
}
|
|
34
|
+
export declare class UnsupportedRegionalError extends Error {
|
|
35
|
+
constructor(regionalId: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Lo que el core le presta al conector en `create`. El paquete importa del core solo tipos: instalado
|
|
39
|
+
* en otra carpeta, un import en tiempo de ejecución cargaría otra copia de `utn-mcp`, con otras clases
|
|
40
|
+
* de error que el core no reconoce.
|
|
41
|
+
*/
|
|
42
|
+
export declare const connectorKit: {
|
|
43
|
+
HttpError: typeof HttpError;
|
|
44
|
+
LoginRequiredError: typeof LoginRequiredError;
|
|
45
|
+
InvalidCredentialsError: typeof InvalidCredentialsError;
|
|
46
|
+
htmlToText: typeof htmlToText;
|
|
47
|
+
};
|
|
48
|
+
export type ConnectorKit = typeof connectorKit;
|
|
49
|
+
/** Lo que exporta un paquete de conector: qué regionales atiende y cómo crear el conector de cada una. */
|
|
50
|
+
export interface AutogestionSystem {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
/** Ids de regional como en `regionals.ts` del aula (`frt`, `frd`...). */
|
|
54
|
+
regionales: string[];
|
|
55
|
+
/** Ayuda para el usuario de esa regional, si no es el legajo tal cual: "Legajo sin el 23-". */
|
|
56
|
+
usernameHint?(regionalId: string): string | undefined;
|
|
57
|
+
create(regionalId: string, kit: ConnectorKit): AutogestionConnector;
|
|
58
|
+
}
|
|
59
|
+
/** El sistema de una regional entre los conectores cargados, o null si ninguno la cubre. */
|
|
60
|
+
export declare function systemForRegional(regionalId: string, systems: AutogestionSystem[]): AutogestionSystem | null;
|
|
61
|
+
export type { CorrelatividadMateria, Correlatividades, EstadoMateria, ExamenMateria, NotasParcialesMateria } from "./types.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
import { htmlToText } from "../../shared/html.js";
|
|
3
|
+
export class LoginRequiredError extends Error {
|
|
4
|
+
constructor(systemName) {
|
|
5
|
+
super(`Falta iniciar sesión en ${systemName}: corré \`utn-mcp login autogestion\` una vez.`);
|
|
6
|
+
this.name = "LoginRequiredError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class InvalidCredentialsError extends Error {
|
|
10
|
+
constructor(systemName) {
|
|
11
|
+
super(`${systemName} rechazó el usuario o la contraseña. Revisalos con \`utn-mcp login autogestion\`.`);
|
|
12
|
+
this.name = "InvalidCredentialsError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class UnsupportedRegionalError extends Error {
|
|
16
|
+
constructor(regionalId) {
|
|
17
|
+
super(`La regional ${regionalId.toUpperCase()} todavía no tiene un conector de autogestión. ` +
|
|
18
|
+
"El aula virtual se puede seguir usando sin autogestión.");
|
|
19
|
+
this.name = "UnsupportedRegionalError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Lo que el core le presta al conector en `create`. El paquete importa del core solo tipos: instalado
|
|
24
|
+
* en otra carpeta, un import en tiempo de ejecución cargaría otra copia de `utn-mcp`, con otras clases
|
|
25
|
+
* de error que el core no reconoce.
|
|
26
|
+
*/
|
|
27
|
+
export const connectorKit = { HttpError, LoginRequiredError, InvalidCredentialsError, htmlToText };
|
|
28
|
+
/** El sistema de una regional entre los conectores cargados, o null si ninguno la cubre. */
|
|
29
|
+
export function systemForRegional(regionalId, systems) {
|
|
30
|
+
return systems.find((system) => system.regionales.includes(regionalId)) ?? null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AutogestionCredentials } from "./connector.js";
|
|
2
|
+
/** Primero <SISTEMA>_USER/<SISTEMA>_PASS (ej. SYSACAD_ASP_USER); si no están, el keychain. */
|
|
3
|
+
export declare function loadAutogestionCredentials(systemId: string): Promise<AutogestionCredentials | null>;
|
|
4
|
+
/** Guarda en el keychain. Devuelve false si no hay keychain (ej. Linux sin Secret Service). */
|
|
5
|
+
export declare function saveAutogestionCredentials(systemId: string, creds: AutogestionCredentials): Promise<boolean>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { logger } from "../../shared/logger.js";
|
|
2
|
+
const SERVICE = "utn-mcp";
|
|
3
|
+
/** Una entrada por sistema: la credencial es por sistema, no por regional. */
|
|
4
|
+
const account = (systemId) => `autogestion-${systemId}`;
|
|
5
|
+
// Import dinámico: si el binario nativo o el Secret Service (Linux) no están, seguimos con env vars.
|
|
6
|
+
async function entry(systemId) {
|
|
7
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
8
|
+
return new Entry(SERVICE, account(systemId));
|
|
9
|
+
}
|
|
10
|
+
function envNames(systemId) {
|
|
11
|
+
const prefix = systemId.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
12
|
+
return [`${prefix}_USER`, `${prefix}_PASS`];
|
|
13
|
+
}
|
|
14
|
+
/** Primero <SISTEMA>_USER/<SISTEMA>_PASS (ej. SYSACAD_ASP_USER); si no están, el keychain. */
|
|
15
|
+
export async function loadAutogestionCredentials(systemId) {
|
|
16
|
+
const [userEnv, passEnv] = envNames(systemId);
|
|
17
|
+
const username = process.env[userEnv];
|
|
18
|
+
const password = process.env[passEnv];
|
|
19
|
+
if (username && password)
|
|
20
|
+
return { username, password };
|
|
21
|
+
try {
|
|
22
|
+
const raw = (await entry(systemId)).getPassword();
|
|
23
|
+
return raw ? JSON.parse(raw) : null;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
logger.warn("Keychain no disponible:", e.message);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Guarda en el keychain. Devuelve false si no hay keychain (ej. Linux sin Secret Service). */
|
|
31
|
+
export async function saveAutogestionCredentials(systemId, creds) {
|
|
32
|
+
try {
|
|
33
|
+
(await entry(systemId)).setPassword(JSON.stringify(creds));
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
logger.warn("No se pudo guardar en el keychain:", e.message);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CatalogEntry } from "./catalog.js";
|
|
2
|
+
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
3
|
+
export declare const connectorsDir: () => string;
|
|
4
|
+
export declare const connectorDir: (entry: CatalogEntry) => string;
|
|
5
|
+
/** El package.json del conector instalado, o null si no está. */
|
|
6
|
+
export declare function installedManifest(entry: CatalogEntry): Promise<{
|
|
7
|
+
version?: string;
|
|
8
|
+
main?: string;
|
|
9
|
+
} | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
12
|
+
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installConnector(entry: CatalogEntry): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { HttpError } from "../../shared/http.js";
|
|
6
|
+
import { configDir } from "../../shared/paths.js";
|
|
7
|
+
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
8
|
+
export const connectorsDir = () => join(configDir(), "connectors");
|
|
9
|
+
export const connectorDir = (entry) => join(connectorsDir(), entry.package);
|
|
10
|
+
/** El package.json del conector instalado, o null si no está. */
|
|
11
|
+
export async function installedManifest(entry) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(join(connectorDir(entry), "package.json"), "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Bloques del formato tar: encabezado y datos van de a 512 bytes. */
|
|
20
|
+
const TAR_BLOCK = 512;
|
|
21
|
+
/**
|
|
22
|
+
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
23
|
+
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
24
|
+
*/
|
|
25
|
+
export async function installConnector(entry) {
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetch(entry.tarball, { signal: AbortSignal.timeout(60_000) });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
throw new HttpError(`No se pudo bajar el conector de ${entry.name}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
throw new HttpError(`HTTP ${res.status} al bajar el conector de ${entry.name} (${entry.tarball}).`, res.status);
|
|
35
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
36
|
+
const integrity = `sha512-${createHash("sha512").update(tgz).digest("base64")}`;
|
|
37
|
+
if (integrity !== entry.integrity) {
|
|
38
|
+
throw new Error(`El conector de ${entry.name} que se bajó no es el que espera esta versión de utn-mcp: no se instala.`);
|
|
39
|
+
}
|
|
40
|
+
const target = connectorDir(entry);
|
|
41
|
+
const staging = `${target}.tmp-${process.pid}`;
|
|
42
|
+
await rm(staging, { recursive: true, force: true });
|
|
43
|
+
await extractPackage(tgz, staging);
|
|
44
|
+
await rm(target, { recursive: true, force: true });
|
|
45
|
+
await rename(staging, target);
|
|
46
|
+
}
|
|
47
|
+
/** Extrae los archivos de `package/` de un tarball de `npm pack`; cualquier ruta que salga de ahí se rechaza. */
|
|
48
|
+
async function extractPackage(tgz, dest) {
|
|
49
|
+
const tar = gunzipSync(tgz);
|
|
50
|
+
const text = (start, length) => tar.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
|
|
51
|
+
for (let offset = 0; offset + TAR_BLOCK <= tar.length;) {
|
|
52
|
+
if (tar.subarray(offset, offset + TAR_BLOCK).every((b) => b === 0))
|
|
53
|
+
break;
|
|
54
|
+
const name = text(offset, 100);
|
|
55
|
+
const prefix = text(offset + 345, 155);
|
|
56
|
+
const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
|
|
57
|
+
// "0" o NUL es un archivo común; los demás (carpetas, encabezados pax) no traen nada que escribir.
|
|
58
|
+
const type = String.fromCharCode(tar[offset + 156]);
|
|
59
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
60
|
+
const data = offset + TAR_BLOCK;
|
|
61
|
+
if (type === "0" || type === "\0") {
|
|
62
|
+
const relative = path.replace(/^package\//, "");
|
|
63
|
+
if (!path.startsWith("package/") || isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) {
|
|
64
|
+
throw new Error(`El conector trae una ruta inválida (${path}): no se instala.`);
|
|
65
|
+
}
|
|
66
|
+
const file = join(dest, relative);
|
|
67
|
+
await mkdir(dirname(file), { recursive: true });
|
|
68
|
+
await writeFile(file, tar.subarray(data, data + size));
|
|
69
|
+
}
|
|
70
|
+
offset = data + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AutogestionSystem } from "./connector.js";
|
|
2
|
+
/**
|
|
3
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
4
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
5
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadInstalledSystems(): Promise<AutogestionSystem[]>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { logger } from "../../shared/logger.js";
|
|
5
|
+
import { CATALOG } from "./catalog.js";
|
|
6
|
+
import { connectorDir, installedManifest } from "./install.js";
|
|
7
|
+
/**
|
|
8
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
9
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
10
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadInstalledSystems() {
|
|
13
|
+
const systems = [];
|
|
14
|
+
for (const entry of CATALOG) {
|
|
15
|
+
const system = await loadFromCatalog(entry);
|
|
16
|
+
if (system)
|
|
17
|
+
systems.push(system);
|
|
18
|
+
}
|
|
19
|
+
const manual = process.env.UTN_MCP_CONNECTORS?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
20
|
+
const require = createRequire(join(process.cwd(), "package.json"));
|
|
21
|
+
for (const name of new Set(manual)) {
|
|
22
|
+
const system = validSystem(await import(pathToFileURL(require.resolve(name)).href), name);
|
|
23
|
+
if (systems.some((existing) => existing.id === system.id || existing.regionales.some((id) => system.regionales.includes(id)))) {
|
|
24
|
+
throw new Error(`El conector ${name} repite un sistema o una regional que ya cubre otro conector.`);
|
|
25
|
+
}
|
|
26
|
+
systems.push(system);
|
|
27
|
+
}
|
|
28
|
+
return systems;
|
|
29
|
+
}
|
|
30
|
+
/** El conector del catálogo si está instalado y es la versión que espera este core; si no, null. */
|
|
31
|
+
async function loadFromCatalog(entry) {
|
|
32
|
+
const manifest = await installedManifest(entry);
|
|
33
|
+
if (!manifest)
|
|
34
|
+
return null;
|
|
35
|
+
if (manifest.version !== entry.version) {
|
|
36
|
+
logger.warn(`El conector de ${entry.name} instalado es la versión ${manifest.version} y esta versión de utn-mcp usa la ` +
|
|
37
|
+
`${entry.version}: corré \`utn-mcp login autogestion\` para actualizarlo.`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const system = validSystem(await import(pathToFileURL(join(connectorDir(entry), manifest.main ?? "index.js")).href), entry.package);
|
|
41
|
+
if (system.id !== entry.id)
|
|
42
|
+
throw new Error(`El paquete ${entry.package} no es el conector ${entry.id}.`);
|
|
43
|
+
return system;
|
|
44
|
+
}
|
|
45
|
+
function validSystem(module, source) {
|
|
46
|
+
const system = module.default;
|
|
47
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
48
|
+
if (!system ||
|
|
49
|
+
!nonEmpty(system.id) ||
|
|
50
|
+
!nonEmpty(system.name) ||
|
|
51
|
+
!Array.isArray(system.regionales) ||
|
|
52
|
+
system.regionales.length === 0 ||
|
|
53
|
+
!system.regionales.every(nonEmpty) ||
|
|
54
|
+
typeof system.create !== "function") {
|
|
55
|
+
throw new Error(`El paquete ${source} no exporta un conector de autogestión válido.`);
|
|
56
|
+
}
|
|
57
|
+
return system;
|
|
58
|
+
}
|