utn-mcp 0.7.0 → 0.8.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 +199 -180
- package/build/aula-virtual/client/client.js +2 -2
- package/build/aula-virtual/client/link.js +17 -11
- package/build/aula-virtual/client/local-session.js +21 -13
- package/build/aula-virtual/client/moodle-api.js +6 -5
- package/build/aula-virtual/client/regionals.js +103 -0
- package/build/aula-virtual/client/sso-login.js +123 -24
- package/build/aula-virtual/client/stored-session.js +24 -20
- package/build/aula-virtual/format/actions.js +1 -2
- package/build/aula-virtual/format/files.js +30 -11
- package/build/aula-virtual/login.js +24 -10
- package/build/aula-virtual/tools.js +11 -5
- package/build/http.js +57 -9
- package/build/index.js +12 -13
- package/build/oauth/access.js +3 -3
- package/build/oauth/admin.js +75 -13
- package/build/oauth/redirects.js +60 -0
- package/build/oauth/server.js +106 -37
- package/build/server.js +1 -1
- package/build/shared/input.js +60 -0
- package/build/shared/load-env.js +5 -0
- package/build/shared/pages.js +42 -37
- package/build/shared/paths.js +1 -1
- package/build/shared/store/sqlite-store.js +111 -40
- package/build/shared/uploads.js +9 -1
- package/package.json +58 -58
- package/build/aula-virtual/client/constants.js +0 -2
package/build/oauth/server.js
CHANGED
|
@@ -2,10 +2,12 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import express from "express";
|
|
3
3
|
import { buildOAuthProtectedResourceMetadata, checkResourceAllowed, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
|
|
4
4
|
import { linkAulaAccount } from "../aula-virtual/client/link.js";
|
|
5
|
+
import { availableRegionals, defaultRegional } from "../aula-virtual/client/regionals.js";
|
|
5
6
|
import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
|
|
6
7
|
import { logger } from "../shared/logger.js";
|
|
7
8
|
import { escapeHtml, page } from "../shared/pages.js";
|
|
8
9
|
import { Expiring, RateLimiter } from "./expiring.js";
|
|
10
|
+
import { describeRedirect, redirectTrust } from "./redirects.js";
|
|
9
11
|
/** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
|
|
10
12
|
const ACCESS_TTL = 60 * 60;
|
|
11
13
|
/** Duración del refresh token, en segundos. Rota en cada uso. */
|
|
@@ -17,14 +19,15 @@ const PENDING_TTL_MS = 10 * 60_000;
|
|
|
17
19
|
/** Intentos de login por IP cada 15 minutos: que el servidor no sirva para probar contraseñas contra el SSO. */
|
|
18
20
|
const LOGIN_ATTEMPTS = 10;
|
|
19
21
|
const LOGIN_WINDOW_MS = 15 * 60_000;
|
|
20
|
-
/**
|
|
21
|
-
const
|
|
22
|
+
/** Registros de clientes por IP cada hora. */
|
|
23
|
+
const REGISTER_ATTEMPTS = 20;
|
|
24
|
+
const REGISTER_WINDOW_MS = 60 * 60_000;
|
|
22
25
|
/**
|
|
23
26
|
* Servidor de autorización OAuth 2.1 para los clientes MCP: registro dinámico, PKCE obligatorio y
|
|
24
27
|
* clientes públicos. El usuario se identifica con la UTN, y ese mismo login vincula su aula: la
|
|
25
28
|
* contraseña se usa una vez y no se guarda. Devuelve la URL de la metadata del recurso, que va en el 401.
|
|
26
29
|
*/
|
|
27
|
-
export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
30
|
+
export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }) {
|
|
28
31
|
const resource = `${issuer}/mcp`;
|
|
29
32
|
const metadata = {
|
|
30
33
|
issuer,
|
|
@@ -47,15 +50,23 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
47
50
|
const pending = new Expiring(PENDING_TTL_MS);
|
|
48
51
|
const codes = new Expiring(CODE_TTL_MS);
|
|
49
52
|
const limiter = new RateLimiter(LOGIN_ATTEMPTS, LOGIN_WINDOW_MS);
|
|
53
|
+
const registerLimiter = new RateLimiter(REGISTER_ATTEMPTS, REGISTER_WINDOW_MS);
|
|
50
54
|
const form = express.urlencoded({ extended: false, limit: "16kb" });
|
|
55
|
+
const regionals = async () => availableRegionals(await store.listVerifiedRegionals());
|
|
51
56
|
app.get("/.well-known/oauth-authorization-server", cors, (_req, res) => void res.json(metadata));
|
|
52
57
|
app.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], cors, (_req, res) => void res.json(resourceMetadata));
|
|
53
58
|
app.options(["/register", "/token", "/revoke"], cors, (_req, res) => void res.sendStatus(204));
|
|
54
59
|
app.post("/register", cors, express.json({ limit: "16kb" }), async (req, res) => {
|
|
60
|
+
if (!registerLimiter.allow(req.ip ?? "desconocida")) {
|
|
61
|
+
return void oauthError(res, 429, "too_many_requests", "Demasiados registros desde esta IP. Probá en una hora.");
|
|
62
|
+
}
|
|
55
63
|
const body = (req.body ?? {});
|
|
56
64
|
const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
|
57
|
-
if (!uris.length ||
|
|
58
|
-
|
|
65
|
+
if (!uris.length || uris.some((u) => redirectTrust(u, redirects) === "rejected")) {
|
|
66
|
+
// Queda en el log para poder habilitar el esquema de una app nueva con UTN_MCP_REDIRECT_ALLOW.
|
|
67
|
+
logger.warn(`OAuth /register rechazado: ${JSON.stringify(uris).slice(0, 300)}`);
|
|
68
|
+
return void oauthError(res, 400, "invalid_redirect_uri", "Las redirect_uri tienen que ser HTTPS, de loopback o de una app habilitada en este servidor " +
|
|
69
|
+
"(UTN_MCP_REDIRECT_ALLOW), y sin fragmento.");
|
|
59
70
|
}
|
|
60
71
|
const client = {
|
|
61
72
|
clientId: randomUUID(),
|
|
@@ -82,6 +93,10 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
82
93
|
if (!q.redirect_uri || !client.redirectUris.includes(q.redirect_uri)) {
|
|
83
94
|
return void errorPage(res, 400, "Redirección inválida", "La redirect_uri no es la que registró el cliente.");
|
|
84
95
|
}
|
|
96
|
+
const trust = redirectTrust(q.redirect_uri, redirects);
|
|
97
|
+
if (trust === "rejected") {
|
|
98
|
+
return void errorPage(res, 400, "App no habilitada", "Esta app vuelve a una dirección que este servidor no acepta.");
|
|
99
|
+
}
|
|
85
100
|
const fail = (error, description) => redirectWith(res, q.redirect_uri, { error, error_description: description, state: q.state });
|
|
86
101
|
if (q.response_type !== "code")
|
|
87
102
|
return void fail("unsupported_response_type", "Solo se acepta response_type=code.");
|
|
@@ -95,22 +110,43 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
95
110
|
clientId: client.clientId,
|
|
96
111
|
clientName: client.clientName,
|
|
97
112
|
redirectUri: q.redirect_uri,
|
|
113
|
+
unknownRedirect: trust === "unknown",
|
|
98
114
|
codeChallenge: q.code_challenge,
|
|
99
115
|
state: q.state,
|
|
100
116
|
resource: q.resource,
|
|
101
117
|
});
|
|
102
|
-
res.type("html").send(loginPage({
|
|
118
|
+
res.type("html").send(loginPage({
|
|
119
|
+
action: "/authorize",
|
|
120
|
+
regionals: await regionals(),
|
|
121
|
+
pendingId: id,
|
|
122
|
+
clientName: client.clientName,
|
|
123
|
+
redirectUri: q.redirect_uri,
|
|
124
|
+
unknownRedirect: trust === "unknown",
|
|
125
|
+
}));
|
|
103
126
|
});
|
|
104
127
|
app.post("/authorize", sameOrigin, form, async (req, res) => {
|
|
105
128
|
const id = String(req.body?.pedido ?? "");
|
|
106
129
|
const request = pending.get(id);
|
|
107
130
|
if (!request)
|
|
108
131
|
return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
|
|
109
|
-
const
|
|
110
|
-
const
|
|
132
|
+
const list = await regionals();
|
|
133
|
+
const retry = (error) => res.type("html").send(loginPage({
|
|
134
|
+
action: "/authorize",
|
|
135
|
+
regionals: list,
|
|
136
|
+
pendingId: id,
|
|
137
|
+
clientName: request.clientName,
|
|
138
|
+
redirectUri: request.redirectUri,
|
|
139
|
+
unknownRedirect: request.unknownRedirect,
|
|
140
|
+
regional: req.body?.regional,
|
|
141
|
+
error,
|
|
142
|
+
}));
|
|
143
|
+
if (request.unknownRedirect && req.body?.acepto !== "si") {
|
|
144
|
+
return void retry("Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
|
|
145
|
+
}
|
|
146
|
+
const account = await login(req, list, retry);
|
|
111
147
|
if (!account)
|
|
112
148
|
return;
|
|
113
|
-
const decision = await access({ username: account.username, email: account.email });
|
|
149
|
+
const decision = await access({ regional: account.regional.id, username: account.username, email: account.email });
|
|
114
150
|
if (!decision.allowed)
|
|
115
151
|
return void deniedPage(res, decision.reason, decision.url);
|
|
116
152
|
pending.take(id);
|
|
@@ -120,7 +156,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
120
156
|
redirectUri: request.redirectUri,
|
|
121
157
|
codeChallenge: request.codeChallenge,
|
|
122
158
|
});
|
|
123
|
-
logger.info(`OAuth: ${account.username} autorizó a ${request.clientName ?? request.clientId}.`);
|
|
159
|
+
logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
|
|
124
160
|
redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
|
|
125
161
|
});
|
|
126
162
|
app.post("/token", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
|
|
@@ -154,7 +190,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
154
190
|
const user = await store.findUser(found.userId);
|
|
155
191
|
if (!user)
|
|
156
192
|
return void oauthError(res, 400, "invalid_grant", "El usuario ya no existe.");
|
|
157
|
-
const decision = await access({ username: user.utnUsername, email: user.email });
|
|
193
|
+
const decision = await access({ regional: user.regional, username: user.utnUsername, email: user.email });
|
|
158
194
|
if (!decision.allowed)
|
|
159
195
|
return void oauthError(res, 400, "invalid_grant", decision.reason);
|
|
160
196
|
return void res.json(await issueTokens(store, user.id, clientId));
|
|
@@ -174,16 +210,22 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
174
210
|
res.sendStatus(200);
|
|
175
211
|
});
|
|
176
212
|
// Volver a iniciar sesión cuando vence la sesión del aula: no emite tokens, solo renueva la sesión.
|
|
177
|
-
app.get("/login", (_req, res) => void res.type("html").send(loginPage({ action: "/login" })));
|
|
213
|
+
app.get("/login", async (_req, res) => void res.type("html").send(loginPage({ action: "/login", regionals: await regionals() })));
|
|
178
214
|
app.post("/login", sameOrigin, form, async (req, res) => {
|
|
179
|
-
const
|
|
215
|
+
const list = await regionals();
|
|
216
|
+
const account = await login(req, list, (error) => res.type("html").send(loginPage({
|
|
217
|
+
action: "/login",
|
|
218
|
+
regionals: list,
|
|
219
|
+
regional: req.body?.regional,
|
|
220
|
+
error,
|
|
221
|
+
})));
|
|
180
222
|
if (!account)
|
|
181
223
|
return;
|
|
182
|
-
logger.info(`OAuth: ${account.username} renovó su sesión del aula.`);
|
|
224
|
+
logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
|
|
183
225
|
res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
|
|
184
226
|
});
|
|
185
227
|
/** Entra al aula con lo que mandó el formulario; si falla, vuelve a mostrarlo con el error. */
|
|
186
|
-
async function login(req, retry) {
|
|
228
|
+
async function login(req, list, retry) {
|
|
187
229
|
if (!limiter.allow(req.ip ?? "desconocida")) {
|
|
188
230
|
retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
|
|
189
231
|
return null;
|
|
@@ -194,14 +236,28 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
|
|
|
194
236
|
retry("Completá usuario y contraseña.");
|
|
195
237
|
return null;
|
|
196
238
|
}
|
|
239
|
+
const regional = list.find((r) => r.id === String(req.body?.regional ?? ""));
|
|
240
|
+
if (!regional) {
|
|
241
|
+
retry("Elegí tu regional de la lista.");
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (regional.support === "unsupported") {
|
|
245
|
+
retry(`Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
197
248
|
try {
|
|
198
|
-
return await linkAulaAccount(store, { username, password });
|
|
249
|
+
return await linkAulaAccount(store, regional, { username, password });
|
|
199
250
|
}
|
|
200
251
|
catch (e) {
|
|
201
252
|
if (e instanceof InvalidCredentialsError)
|
|
202
253
|
retry("La UTN rechazó el usuario o la contraseña.");
|
|
254
|
+
else if (regional.support === "candidate") {
|
|
255
|
+
// Nadie la había usado acá: lo más probable es que todavía no ande, no que esté caída.
|
|
256
|
+
logger.warn(`Regional ${regional.id} sin verificar, falló el login de ${username}: ${e.message}`);
|
|
257
|
+
retry(`No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
|
|
258
|
+
}
|
|
203
259
|
else {
|
|
204
|
-
logger.error(`Login de ${username}: ${e.message}`);
|
|
260
|
+
logger.error(`Login de ${username} (${regional.id}): ${e.message}`);
|
|
205
261
|
retry("No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
|
|
206
262
|
}
|
|
207
263
|
return null;
|
|
@@ -215,27 +271,15 @@ async function issueTokens(store, userId, clientId) {
|
|
|
215
271
|
return { access_token: accessToken, token_type: "Bearer", expires_in: ACCESS_TTL, refresh_token: refreshToken };
|
|
216
272
|
}
|
|
217
273
|
const pkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
218
|
-
/** HTTPS, http solo en loopback (clientes de escritorio) o un esquema propio de una app (`cursor://`). */
|
|
219
|
-
function isAllowedRedirect(uri) {
|
|
220
|
-
if (typeof uri !== "string")
|
|
221
|
-
return false;
|
|
222
|
-
try {
|
|
223
|
-
const url = new URL(uri);
|
|
224
|
-
if (FORBIDDEN_SCHEMES.has(url.protocol) || url.hash)
|
|
225
|
-
return false;
|
|
226
|
-
if (url.protocol === "http:")
|
|
227
|
-
return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
274
|
/** Los endpoints que llaman los clientes desde el navegador (como el MCP Inspector) aceptan cualquier origen. */
|
|
235
|
-
const cors = (_req, res, next) => {
|
|
275
|
+
export const cors = (_req, res, next) => {
|
|
236
276
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
237
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
238
|
-
res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, mcp-protocol-version");
|
|
277
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
278
|
+
res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, mcp-protocol-version, mcp-session-id, last-event-id");
|
|
279
|
+
// Sin exponerlos, el navegador no deja leer el 401 que arranca el OAuth ni la sesión del transporte.
|
|
280
|
+
res.setHeader("Access-Control-Expose-Headers", "www-authenticate, mcp-session-id");
|
|
281
|
+
// Un día, en segundos: el navegador no repite el preflight en cada request.
|
|
282
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
239
283
|
next();
|
|
240
284
|
};
|
|
241
285
|
function redirectWith(res, base, params) {
|
|
@@ -256,16 +300,41 @@ function deniedPage(res, reason, url) {
|
|
|
256
300
|
res.status(403).type("html").send(page("Sin acceso", `<p>${escapeHtml(reason)}</p>${link}`));
|
|
257
301
|
}
|
|
258
302
|
function loginPage(o) {
|
|
303
|
+
const where = o.redirectUri ? `<strong>${escapeHtml(describeRedirect(o.redirectUri))}</strong>` : "";
|
|
304
|
+
const destination = where && !o.unknownRedirect ? ` Al terminar vas a volver a ${where}: si no es la app que estás conectando, no sigas.` : "";
|
|
305
|
+
const warning = o.unknownRedirect
|
|
306
|
+
? `<div class=aviso><p><strong>Esta app no es un cliente conocido.</strong> Al terminar vas a volver a ${where}, ` +
|
|
307
|
+
"y desde ahí van a poder usar tu aula en tu nombre: leer tus cursos y mensajes, entregar tareas y escribir " +
|
|
308
|
+
"mensajes. Si alguien te pasó este link y no estás conectando un agente tuyo o de alguien en quien confiás, " +
|
|
309
|
+
"no sigas: puede ser un intento de robarte la sesión.</p></div>"
|
|
310
|
+
: "";
|
|
311
|
+
const consent = o.unknownRedirect
|
|
312
|
+
? `<label class=acepto><input type="checkbox" name="acepto" value="si" required><span>Entiendo el riesgo y ` +
|
|
313
|
+
`conecto esta app bajo mi responsabilidad. Quien administra este servidor no se hace responsable de lo que ` +
|
|
314
|
+
`${where} haga con mi aula ni con mi sesión.</span></label>`
|
|
315
|
+
: "";
|
|
259
316
|
const intro = o.pendingId
|
|
260
|
-
? `<p>${escapeHtml(o.clientName ?? "Una app")} quiere usar UTN-MCP con tu Aula Virtual. Entrá con tu usuario de la UTN para conectarla
|
|
317
|
+
? `<p>${escapeHtml(o.clientName ?? "Una app")} quiere usar UTN-MCP con tu Aula Virtual. Entrá con tu usuario de la UTN para conectarla.${destination}</p>`
|
|
261
318
|
: "<p>Tu sesión del aula venció. Entrá con tu usuario de la UTN para renovarla.</p>";
|
|
262
319
|
const error = o.error ? `<p class=error>${escapeHtml(o.error)}</p>` : "";
|
|
263
320
|
const hidden = o.pendingId ? `<input type="hidden" name="pedido" value="${escapeHtml(o.pendingId)}">` : "";
|
|
321
|
+
const selected = o.regional || defaultRegional().id;
|
|
322
|
+
const option = (r) => `<option value="${escapeHtml(r.id)}"${r.id === selected ? " selected" : ""}>${escapeHtml(r.name)}</option>`;
|
|
323
|
+
const group = (label, support) => {
|
|
324
|
+
const items = o.regionals.filter((r) => r.support === support);
|
|
325
|
+
return items.length ? `<optgroup label="${escapeHtml(label)}">${items.map(option).join("")}</optgroup>` : "";
|
|
326
|
+
};
|
|
327
|
+
const options = group("Regionales", "verified") +
|
|
328
|
+
group("Agregar mi regional (primera vez acá)", "candidate") +
|
|
329
|
+
group("Todavía sin soporte", "unsupported");
|
|
264
330
|
return page("Entrar con la UTN", intro +
|
|
331
|
+
warning +
|
|
265
332
|
error +
|
|
266
333
|
`<form method="post" action="${o.action}">${hidden}` +
|
|
334
|
+
`<label>Regional<select name="regional" required>${options}</select></label>` +
|
|
267
335
|
`<label>Usuario<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
|
|
268
336
|
`<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>` +
|
|
337
|
+
consent +
|
|
269
338
|
`<button type="submit">Entrar</button></form>` +
|
|
270
339
|
"<p class=nota>La contraseña se usa una sola vez para entrar al aula y no se guarda. " +
|
|
271
340
|
'El código es abierto: <a href="https://github.com/nialvape/utn-mcp">github.com/nialvape/utn-mcp</a>.</p>', o.pendingId ? "Conectar app · Aula Virtual" : "Renovar sesión · Aula Virtual");
|
package/build/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
|
7
7
|
export function createServer(aula, files) {
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.8.0",
|
|
11
11
|
});
|
|
12
12
|
registerAulaVirtualTools(server, aula, files);
|
|
13
13
|
return server;
|
package/build/shared/input.js
CHANGED
|
@@ -9,6 +9,66 @@ export async function ask(question) {
|
|
|
9
9
|
rl.close();
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Elige una de `options` con las flechas (o su número) y Enter; devuelve el índice. Sin terminal
|
|
14
|
+
* interactiva (stdin redirigido) muestra la lista numerada y pide el número. Enter solo elige `initial`.
|
|
15
|
+
*/
|
|
16
|
+
export async function askChoice(question, options, initial = 0) {
|
|
17
|
+
if (!stdin.isTTY) {
|
|
18
|
+
const list = options.map((o, i) => ` ${i + 1}. ${o}`).join("\n");
|
|
19
|
+
const answer = await ask(`${question}\n${list}\nElegí una [${initial + 1}]: `);
|
|
20
|
+
if (!answer)
|
|
21
|
+
return initial;
|
|
22
|
+
const index = Number(answer) - 1;
|
|
23
|
+
if (!options[index])
|
|
24
|
+
throw new Error(`"${answer}" no es una opción: elegí un número del 1 al ${options.length}.`);
|
|
25
|
+
return index;
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let selected = initial;
|
|
29
|
+
// Reescribe la lista en el lugar: sube tantas líneas como opciones y borra cada una.
|
|
30
|
+
const render = (redraw) => {
|
|
31
|
+
if (redraw)
|
|
32
|
+
stderr.write(`\x1b[${options.length}A`);
|
|
33
|
+
for (const [i, o] of options.entries())
|
|
34
|
+
stderr.write(`\x1b[2K${i === selected ? "❯" : " "} ${o}\n`);
|
|
35
|
+
};
|
|
36
|
+
const onData = (chunk) => {
|
|
37
|
+
if (chunk === "\u0003") {
|
|
38
|
+
done();
|
|
39
|
+
reject(new Error("Cancelado"));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (chunk === "\r" || chunk === "\n") {
|
|
43
|
+
done();
|
|
44
|
+
stderr.write(`\x1b[${options.length + 1}A\x1b[J${question} ${options[selected]}\n`);
|
|
45
|
+
resolve(selected);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (chunk === "\x1b[A")
|
|
49
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
50
|
+
else if (chunk === "\x1b[B")
|
|
51
|
+
selected = (selected + 1) % options.length;
|
|
52
|
+
else if (options[Number(chunk) - 1])
|
|
53
|
+
selected = Number(chunk) - 1;
|
|
54
|
+
else
|
|
55
|
+
return;
|
|
56
|
+
render(true);
|
|
57
|
+
};
|
|
58
|
+
const done = () => {
|
|
59
|
+
stdin.off("data", onData);
|
|
60
|
+
stdin.setRawMode(false);
|
|
61
|
+
stdin.pause();
|
|
62
|
+
stderr.write("\x1b[?25h"); // vuelve a mostrar el cursor
|
|
63
|
+
};
|
|
64
|
+
stderr.write(`\x1b[?25l${question} (flechas y Enter)\n`);
|
|
65
|
+
render(false);
|
|
66
|
+
stdin.setRawMode(true);
|
|
67
|
+
stdin.resume();
|
|
68
|
+
stdin.setEncoding("utf8");
|
|
69
|
+
stdin.on("data", onData);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
12
72
|
export function askHidden(question) {
|
|
13
73
|
if (!stdin.isTTY)
|
|
14
74
|
return ask(question);
|
package/build/shared/pages.js
CHANGED
|
@@ -2,43 +2,48 @@
|
|
|
2
2
|
* Los colores del sitio del proyecto: papel milimetrado, tinta y un azul de acento. Oscuro salvo que
|
|
3
3
|
* el sistema pida claro. Sin fuentes externas: la página de login recibe contraseñas.
|
|
4
4
|
*/
|
|
5
|
-
const STYLE = `
|
|
6
|
-
:root{--paper:#0a0b0d;--ink:#e9e7e0;--muted:#8b8a84;--line:rgb(233 231 224/.055);--line-faint:rgb(233 231 224/.025);
|
|
7
|
-
--line-strong:rgb(233 231 224/.16);--accent:#6b93ff;--err:#f87171;--panel:rgb(20 21 24/.6);color-scheme:dark}
|
|
8
|
-
@media(prefers-color-scheme:light){:root{--paper:#f3f2ec;--ink:#121212;--muted:#6d6c66;--line:rgb(18 18 18/.07);
|
|
9
|
-
--line-faint:rgb(18 18 18/.035);--line-strong:rgb(18 18 18/.2);--accent:#1f4fd6;--err:#b91c1c;--panel:rgb(255 255 255/.55);color-scheme:light}}
|
|
10
|
-
*{box-sizing:border-box}
|
|
11
|
-
body{margin:0;min-height:100vh;color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
12
|
-
background-color:var(--paper);background-image:linear-gradient(var(--line) 1px,transparent 1px),
|
|
13
|
-
linear-gradient(90deg,var(--line) 1px,transparent 1px),linear-gradient(var(--line-faint) 1px,transparent 1px),
|
|
14
|
-
linear-gradient(90deg,var(--line-faint) 1px,transparent 1px);background-size:80px 80px,80px 80px,16px 16px,16px 16px;
|
|
15
|
-
background-position:-1px -1px}
|
|
16
|
-
.mono,label,button,.kicker,header{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
17
|
-
header{display:flex;align-items:center;gap:.5rem;height:3.5rem;padding:0 1rem;border-bottom:1px solid var(--line);
|
|
18
|
-
font-size:13px;letter-spacing:-.01em;background:color-mix(in srgb,var(--paper) 75%,transparent)}
|
|
19
|
-
header svg{height:1.5rem;width:auto}
|
|
20
|
-
main{max-width:28rem;margin:3rem auto;padding:0 1rem}
|
|
21
|
-
.card{position:relative;border:1px solid var(--line-strong);background:var(--panel);padding:1.5rem}
|
|
22
|
-
.card::before,.card::after{content:"";position:absolute;width:9px;height:9px;border-color:var(--ink);opacity:.55}
|
|
23
|
-
.card::before{top:-1px;left:-1px;border-top:1px solid;border-left:1px solid}
|
|
24
|
-
.card::after{bottom:-1px;right:-1px;border-bottom:1px solid;border-right:1px solid}
|
|
25
|
-
.kicker{margin:0 0 .5rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
|
|
26
|
-
h1{margin:0 0 1rem;font-size:1.5rem;font-weight:600;letter-spacing:-.02em;line-height:1.2}
|
|
27
|
-
p{margin:.75rem 0}
|
|
28
|
-
a{color:var(--accent);text-underline-offset:4px}
|
|
29
|
-
form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}
|
|
30
|
-
label{display:flex;flex-direction:column;gap:.4rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
|
|
31
|
-
input{font:inherit;font-size:15px;letter-spacing:normal;text-transform:none;color:var(--ink);background:transparent;
|
|
32
|
-
border:1px solid var(--line-strong);border-radius:0;padding:.65rem .75rem}
|
|
33
|
-
input:focus{outline:none;border-color:var(--accent)}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
5
|
+
const STYLE = `
|
|
6
|
+
:root{--paper:#0a0b0d;--ink:#e9e7e0;--muted:#8b8a84;--line:rgb(233 231 224/.055);--line-faint:rgb(233 231 224/.025);
|
|
7
|
+
--line-strong:rgb(233 231 224/.16);--accent:#6b93ff;--err:#f87171;--panel:rgb(20 21 24/.6);color-scheme:dark}
|
|
8
|
+
@media(prefers-color-scheme:light){:root{--paper:#f3f2ec;--ink:#121212;--muted:#6d6c66;--line:rgb(18 18 18/.07);
|
|
9
|
+
--line-faint:rgb(18 18 18/.035);--line-strong:rgb(18 18 18/.2);--accent:#1f4fd6;--err:#b91c1c;--panel:rgb(255 255 255/.55);color-scheme:light}}
|
|
10
|
+
*{box-sizing:border-box}
|
|
11
|
+
body{margin:0;min-height:100vh;color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
12
|
+
background-color:var(--paper);background-image:linear-gradient(var(--line) 1px,transparent 1px),
|
|
13
|
+
linear-gradient(90deg,var(--line) 1px,transparent 1px),linear-gradient(var(--line-faint) 1px,transparent 1px),
|
|
14
|
+
linear-gradient(90deg,var(--line-faint) 1px,transparent 1px);background-size:80px 80px,80px 80px,16px 16px,16px 16px;
|
|
15
|
+
background-position:-1px -1px}
|
|
16
|
+
.mono,label,button,.kicker,header{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
17
|
+
header{display:flex;align-items:center;gap:.5rem;height:3.5rem;padding:0 1rem;border-bottom:1px solid var(--line);
|
|
18
|
+
font-size:13px;letter-spacing:-.01em;background:color-mix(in srgb,var(--paper) 75%,transparent)}
|
|
19
|
+
header svg{height:1.5rem;width:auto}
|
|
20
|
+
main{max-width:28rem;margin:3rem auto;padding:0 1rem}
|
|
21
|
+
.card{position:relative;border:1px solid var(--line-strong);background:var(--panel);padding:1.5rem}
|
|
22
|
+
.card::before,.card::after{content:"";position:absolute;width:9px;height:9px;border-color:var(--ink);opacity:.55}
|
|
23
|
+
.card::before{top:-1px;left:-1px;border-top:1px solid;border-left:1px solid}
|
|
24
|
+
.card::after{bottom:-1px;right:-1px;border-bottom:1px solid;border-right:1px solid}
|
|
25
|
+
.kicker{margin:0 0 .5rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
|
|
26
|
+
h1{margin:0 0 1rem;font-size:1.5rem;font-weight:600;letter-spacing:-.02em;line-height:1.2}
|
|
27
|
+
p{margin:.75rem 0}
|
|
28
|
+
a{color:var(--accent);text-underline-offset:4px}
|
|
29
|
+
form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}
|
|
30
|
+
label{display:flex;flex-direction:column;gap:.4rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
|
|
31
|
+
input,select{font:inherit;font-size:15px;letter-spacing:normal;text-transform:none;color:var(--ink);background:transparent;
|
|
32
|
+
border:1px solid var(--line-strong);border-radius:0;padding:.65rem .75rem}
|
|
33
|
+
input:focus,select:focus{outline:none;border-color:var(--accent)}
|
|
34
|
+
select option{background:var(--paper);color:var(--ink)}
|
|
35
|
+
input[type=file]{padding:.5rem}
|
|
36
|
+
button{font-size:12px;text-transform:uppercase;letter-spacing:.14em;border:0;border-radius:0;padding:.8rem 1rem;
|
|
37
|
+
background:var(--ink);color:var(--paper);cursor:pointer;transition:background .15s}
|
|
38
|
+
button:hover{background:var(--accent)}
|
|
39
|
+
.nota{font-size:13px;color:var(--muted)}
|
|
40
|
+
.error{border-left:2px solid var(--err);padding-left:.75rem;color:var(--err)}
|
|
41
|
+
.aviso{border:1px solid var(--err);padding:0 1rem;margin:1rem 0}
|
|
42
|
+
label.acepto{flex-direction:row;align-items:flex-start;gap:.6rem;font:13px/1.5 ui-sans-serif,system-ui,sans-serif;
|
|
43
|
+
text-transform:none;letter-spacing:normal;color:var(--ink)}
|
|
44
|
+
input[type=checkbox]{padding:0;margin:.2rem 0 0;accent-color:var(--accent)}
|
|
45
|
+
ul{padding-left:1.2rem}
|
|
46
|
+
::selection{background:var(--accent);color:var(--paper)}
|
|
42
47
|
`.replace(/\n/g, "");
|
|
43
48
|
/** El emblema del proyecto: el de la UTN con "MCP" en lugar de "UTN". */
|
|
44
49
|
const MARK = '<svg viewBox="0 0 100 132" fill="none" aria-hidden="true"><g stroke="currentColor" stroke-width="11">' +
|
package/build/shared/paths.js
CHANGED
|
@@ -9,7 +9,7 @@ export function configDir() {
|
|
|
9
9
|
}
|
|
10
10
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "utn-mcp");
|
|
11
11
|
}
|
|
12
|
-
/** Token de Moodle
|
|
12
|
+
/** Token de Moodle del modo local. */
|
|
13
13
|
export const sessionFile = () => join(configDir(), "session.json");
|
|
14
14
|
/** Base de usuarios, tokens y sesiones del modo HTTP: UTN_MCP_DB o un SQLite en la carpeta de configuración. */
|
|
15
15
|
export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
|