blackboard-upc 1.0.8 → 1.0.10
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/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/src/auth/login.ts +63 -4
- package/src/auth/session.ts +12 -1
- package/src/commands/login.ts +38 -13
- package/src/index.ts +25 -9
- package/src/ui/theme.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.0.10] — 2026-04-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **TTL real del SSO en `whoami`, `status` y `login`** — ahora se muestran las dos ventanas: la del token de Blackboard (~3h, se auto-renueva) y la del SSO de Microsoft (~90 días, la ventana real hasta que hay que re-loguearse). Ejemplo:
|
|
11
|
+
```
|
|
12
|
+
SSO Microsoft: 89 días · Blackboard: 173 min
|
|
13
|
+
se auto-renueva hasta que el SSO expire o hagas logout
|
|
14
|
+
```
|
|
15
|
+
- `getSsoExpiry()` helper en `src/auth/login.ts` — lee el expiry de `ESTSAUTHPERSISTENT` (la cookie de Microsoft que controla la persistencia del SSO) desde la sesión guardada, sin llamadas de red
|
|
16
|
+
- `formatSessionLifetime()` helper en `src/ui/theme.ts` — centraliza el formato de las dos líneas (resumen + nota) y maneja el caso donde el SSO es session-only
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `blackboard login` ya no muestra "expira en 3h" (engañoso, porque se auto-renueva); muestra la misma info que `whoami`/`status`
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## [1.0.9] — 2026-04-24
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **`whoami` y `status` mostraban `unknown`** — el extractor leía `userData.userName`, campo que la API de UPC no devuelve. Ahora se arma desde `name.given + name.family` con fallback a `studentId`
|
|
27
|
+
- **`logout` no permitía cambiar de cuenta** — solo borraba `session.json` pero no el browser profile de Playwright (`~/.blackboard-cli/browser-profile`), donde viven las cookies de Microsoft SSO. El siguiente `login` se auto-autenticaba con la misma cuenta. Ahora `logout` borra también el profile
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- `blackboard logout --keep-profile` — conserva las cookies SSO (útil para renovar sesión de la misma cuenta sin re-ingresar credenciales)
|
|
31
|
+
- **Self-heal de sesiones viejas** — `whoami` y `status` detectan `userName: null` en sesiones guardadas con versiones previas y rellenan el nombre llamando a `/users/me` una sola vez (sin necesidad de re-login)
|
|
32
|
+
- `resolveDisplayName()` helper en `src/auth/login.ts` — centraliza la lógica de nombre desde la respuesta de `/users/me`
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
7
36
|
## [1.0.8] — 2026-04-19
|
|
8
37
|
|
|
9
38
|
### Added
|
package/package.json
CHANGED
package/src/auth/login.ts
CHANGED
|
@@ -43,6 +43,41 @@ function extractBbRouterExpiry(cookies: Cookie[]): number {
|
|
|
43
43
|
return Date.now() + SESSION_TTL_FALLBACK_MS;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// UPC's /users/me omits `userName` at the root. The display name must be
|
|
47
|
+
// assembled from `name.given` + `name.family`, falling back to `studentId`.
|
|
48
|
+
export function resolveDisplayName(userData: any): string | undefined {
|
|
49
|
+
if (!userData) return undefined;
|
|
50
|
+
if (typeof userData.userName === 'string' && userData.userName.trim()) return userData.userName.trim();
|
|
51
|
+
const full = [userData?.name?.given, userData?.name?.family].filter(Boolean).join(' ').trim();
|
|
52
|
+
if (full) return full;
|
|
53
|
+
if (userData?.name?.preferredDisplayName && typeof userData.name.preferredDisplayName === 'string') {
|
|
54
|
+
const pdn = userData.name.preferredDisplayName.trim();
|
|
55
|
+
if (pdn && pdn !== 'GivenName' && pdn !== 'FamilyName') return pdn;
|
|
56
|
+
}
|
|
57
|
+
return userData.studentId || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Microsoft SSO persistence is controlled specifically by ESTSAUTHPERSIST
|
|
61
|
+
// (set when the user accepts "Keep me signed in"). Other cookies on
|
|
62
|
+
// login.microsoftonline.com like MUID/fpc are tracking and persist ~1 year
|
|
63
|
+
// but don't keep the user signed in — ignore them.
|
|
64
|
+
const SSO_COOKIE_NAMES = new Set(['ESTSAUTHPERSISTENT', 'ESTSAUTHLIGHT', 'ESTSAUTH']);
|
|
65
|
+
|
|
66
|
+
export function getSsoExpiry(cookies: Cookie[]): number | undefined {
|
|
67
|
+
const ssoCookies = cookies.filter(c =>
|
|
68
|
+
SSO_COOKIE_NAMES.has(c.name) &&
|
|
69
|
+
(c.domain.includes('login.microsoftonline.com') || c.domain.includes('login.live.com'))
|
|
70
|
+
);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const future = ssoCookies
|
|
73
|
+
.map(c => c.expires)
|
|
74
|
+
.filter((e): e is number => typeof e === 'number' && e > 0)
|
|
75
|
+
.map(e => e * 1000)
|
|
76
|
+
.filter(ms => ms > now);
|
|
77
|
+
if (future.length === 0) return undefined;
|
|
78
|
+
return Math.max(...future);
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
function extractXsrf(cookies: Cookie[]): string {
|
|
47
82
|
const bb = cookies.find(c => c.name === 'BbRouter');
|
|
48
83
|
if (bb) {
|
|
@@ -158,16 +193,18 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
158
193
|
if (resp.ok()) userData = await resp.json();
|
|
159
194
|
} catch {}
|
|
160
195
|
|
|
196
|
+
const displayName = resolveDisplayName(userData);
|
|
197
|
+
|
|
161
198
|
const session: Session = {
|
|
162
199
|
cookies,
|
|
163
200
|
xsrfToken: nonce,
|
|
164
201
|
userId: userData?.id,
|
|
165
|
-
userName:
|
|
202
|
+
userName: displayName,
|
|
166
203
|
expiresAt: extractBbRouterExpiry(cookies),
|
|
167
204
|
};
|
|
168
205
|
|
|
169
206
|
saveSession(session);
|
|
170
|
-
console.log(`✓ Logged in as ${
|
|
207
|
+
console.log(`✓ Logged in as ${displayName || 'unknown'}`);
|
|
171
208
|
|
|
172
209
|
return session;
|
|
173
210
|
} finally {
|
|
@@ -215,11 +252,33 @@ export async function silentRelogin(previousSession?: Session | null): Promise<S
|
|
|
215
252
|
throw new SilentLoginFailed('Redirect succeeded but session cookies are missing');
|
|
216
253
|
}
|
|
217
254
|
|
|
255
|
+
let userId = previousSession?.userId;
|
|
256
|
+
let userName = previousSession?.userName;
|
|
257
|
+
|
|
258
|
+
// Self-heal: older sessions were stored with userName=null because the
|
|
259
|
+
// old extractor looked at the wrong field. Refetch once if it's missing.
|
|
260
|
+
if (!userName || !userId) {
|
|
261
|
+
try {
|
|
262
|
+
const cookieStrForApi = cookies
|
|
263
|
+
.filter(c => c.domain.includes('aulavirtual.upc.edu.pe'))
|
|
264
|
+
.map(c => `${c.name}=${c.value}`)
|
|
265
|
+
.join('; ');
|
|
266
|
+
const resp = await page.request.get(`${BASE_URL}/learn/api/public/v1/users/me`, {
|
|
267
|
+
headers: { Accept: 'application/json', Cookie: cookieStrForApi },
|
|
268
|
+
});
|
|
269
|
+
if (resp.ok()) {
|
|
270
|
+
const me = await resp.json();
|
|
271
|
+
userId = userId ?? me?.id;
|
|
272
|
+
userName = userName ?? resolveDisplayName(me);
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
|
|
218
277
|
const session: Session = {
|
|
219
278
|
cookies,
|
|
220
279
|
xsrfToken: extractXsrf(cookies),
|
|
221
|
-
userId
|
|
222
|
-
userName
|
|
280
|
+
userId,
|
|
281
|
+
userName,
|
|
223
282
|
expiresAt: extractBbRouterExpiry(cookies),
|
|
224
283
|
};
|
|
225
284
|
|
package/src/auth/session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Session } from '../types/index.js';
|
|
|
5
5
|
|
|
6
6
|
const SESSION_DIR = path.join(os.homedir(), '.blackboard-cli');
|
|
7
7
|
const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
|
|
8
|
+
const PROFILE_DIR = path.join(SESSION_DIR, 'browser-profile');
|
|
8
9
|
|
|
9
10
|
export function saveSession(session: Session): void {
|
|
10
11
|
if (!fs.existsSync(SESSION_DIR)) {
|
|
@@ -27,10 +28,20 @@ export function loadSession(): Session | null {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function clearSession(): void {
|
|
31
|
+
export function clearSession(opts: { keepProfile?: boolean } = {}): void {
|
|
31
32
|
try {
|
|
32
33
|
if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
|
|
33
34
|
} catch {}
|
|
35
|
+
if (!opts.keepProfile) clearBrowserProfile();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The browser profile holds Microsoft SSO cookies. Without clearing it,
|
|
39
|
+
// the next `login` silently re-authenticates with the same account and
|
|
40
|
+
// switching users becomes impossible.
|
|
41
|
+
export function clearBrowserProfile(): void {
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
|
44
|
+
} catch {}
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export function isSessionValid(session: Session | null): boolean {
|
package/src/commands/login.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { Command } from 'commander';
|
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { login } from '../auth/login.js';
|
|
6
|
-
import { loadSession, loadOrRefreshSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
7
|
-
import {
|
|
5
|
+
import { login, resolveDisplayName, getSsoExpiry } from '../auth/login.js';
|
|
6
|
+
import { loadSession, loadOrRefreshSession, saveSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
7
|
+
import { createClient } from '../api/client.js';
|
|
8
|
+
import { getMe } from '../api/courses.js';
|
|
9
|
+
import { ok, fail, warn, whatNext, formatSessionLifetime } from '../ui/theme.js';
|
|
8
10
|
|
|
9
11
|
export function loginCommand(program: Command) {
|
|
10
12
|
program
|
|
@@ -34,9 +36,11 @@ export function loginCommand(program: Command) {
|
|
|
34
36
|
password: opts.password,
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
console.log(ok(`Sesión guardada
|
|
39
|
+
const ssoExpiresAt = getSsoExpiry(session.cookies);
|
|
40
|
+
const { summary, note } = formatSessionLifetime(session.expiresAt, ssoExpiresAt);
|
|
41
|
+
console.log(ok(`Sesión guardada`));
|
|
42
|
+
console.log(chalk.gray(` ${summary}`));
|
|
43
|
+
console.log(chalk.gray(` ${note}`));
|
|
40
44
|
if (session.userName) console.log(chalk.gray(` Usuario: ${session.userName}`));
|
|
41
45
|
if (session.userId) console.log(chalk.gray(` ID: ${session.userId}`));
|
|
42
46
|
whatNext();
|
|
@@ -48,23 +52,44 @@ export function loginCommand(program: Command) {
|
|
|
48
52
|
|
|
49
53
|
program
|
|
50
54
|
.command('logout')
|
|
51
|
-
.description('
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
.description('Borra la sesión y las cookies SSO para poder cambiar de cuenta')
|
|
56
|
+
.option('--keep-profile', 'Conservar las cookies SSO del navegador (no permite cambiar de cuenta)')
|
|
57
|
+
.action((opts) => {
|
|
58
|
+
clearSession({ keepProfile: !!opts.keepProfile });
|
|
59
|
+
if (opts.keepProfile) {
|
|
60
|
+
console.log(chalk.green('✓ Sesión borrada') + chalk.gray(' (profile SSO conservado)'));
|
|
61
|
+
} else {
|
|
62
|
+
console.log(chalk.green('✓ Sesión y profile SSO borrados'));
|
|
63
|
+
console.log(chalk.gray(' El próximo `blackboard login` te pedirá credenciales de nuevo.'));
|
|
64
|
+
}
|
|
55
65
|
});
|
|
56
66
|
|
|
57
67
|
program
|
|
58
68
|
.command('whoami')
|
|
59
69
|
.description('Show current logged-in user')
|
|
60
70
|
.action(async () => {
|
|
61
|
-
|
|
71
|
+
let session = await loadOrRefreshSession();
|
|
62
72
|
if (!isSessionValid(session)) {
|
|
63
73
|
console.log(chalk.red('Not logged in. Run: blackboard login'));
|
|
64
74
|
process.exit(1);
|
|
65
75
|
}
|
|
76
|
+
|
|
77
|
+
// Self-heal old sessions that were stored with userName=null.
|
|
78
|
+
if (!session!.userName) {
|
|
79
|
+
try {
|
|
80
|
+
const me = await getMe(createClient(session!));
|
|
81
|
+
const name = resolveDisplayName(me);
|
|
82
|
+
if (name) {
|
|
83
|
+
session = { ...session!, userId: session!.userId ?? me?.id, userName: name };
|
|
84
|
+
saveSession(session!);
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
console.log(chalk.green(`Logged in as: ${chalk.bold(session!.userName || session!.userId || 'unknown')}`));
|
|
67
|
-
const
|
|
68
|
-
|
|
90
|
+
const ssoExpiresAt = getSsoExpiry(session!.cookies);
|
|
91
|
+
const { summary, note } = formatSessionLifetime(session!.expiresAt, ssoExpiresAt);
|
|
92
|
+
console.log(chalk.gray(summary));
|
|
93
|
+
console.log(chalk.gray(note));
|
|
69
94
|
});
|
|
70
95
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,17 +6,18 @@ import { coursesCommand } from './commands/courses.js';
|
|
|
6
6
|
import { apiDocsCommand } from './commands/api-docs.js';
|
|
7
7
|
import { downloadCommand } from './commands/download.js';
|
|
8
8
|
import { assignmentsCommand } from './commands/assignments.js';
|
|
9
|
-
import { loadSession, loadOrRefreshSession, isSessionValid } from './auth/session.js';
|
|
9
|
+
import { loadSession, loadOrRefreshSession, saveSession, isSessionValid } from './auth/session.js';
|
|
10
10
|
import { createClient } from './api/client.js';
|
|
11
11
|
import { getMe, getSystemVersion } from './api/courses.js';
|
|
12
|
-
import {
|
|
12
|
+
import { resolveDisplayName, getSsoExpiry } from './auth/login.js';
|
|
13
|
+
import { BANNER, ok, fail, hint, formatSessionLifetime } from './ui/theme.js';
|
|
13
14
|
|
|
14
15
|
const program = new Command();
|
|
15
16
|
|
|
16
17
|
program
|
|
17
18
|
.name('blackboard')
|
|
18
19
|
.description('CLI no oficial para UPC Aula Virtual (Blackboard Learn)')
|
|
19
|
-
.version('1.0.
|
|
20
|
+
.version('1.0.10')
|
|
20
21
|
.addHelpText('beforeAll', BANNER);
|
|
21
22
|
|
|
22
23
|
// Auth commands
|
|
@@ -31,17 +32,31 @@ program
|
|
|
31
32
|
.description('Estado de sesión y versión del servidor')
|
|
32
33
|
.option('--json', 'Output raw JSON')
|
|
33
34
|
.action(async (opts) => {
|
|
34
|
-
|
|
35
|
+
let session = await loadOrRefreshSession();
|
|
35
36
|
const valid = isSessionValid(session);
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const client = createClient(session ?? { cookies: [], xsrfToken: '', expiresAt: 0 });
|
|
39
|
+
const sysVersion = await getSystemVersion(client).catch(() => null);
|
|
40
|
+
|
|
41
|
+
// Self-heal old sessions with userName=null.
|
|
42
|
+
if (valid && !session!.userName) {
|
|
43
|
+
try {
|
|
44
|
+
const me = await getMe(client);
|
|
45
|
+
const name = resolveDisplayName(me);
|
|
46
|
+
if (name) {
|
|
47
|
+
session = { ...session!, userId: session!.userId ?? me?.id, userName: name };
|
|
48
|
+
saveSession(session!);
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ssoExpiresAt = valid ? getSsoExpiry(session!.cookies) : undefined;
|
|
40
54
|
|
|
41
55
|
const result = {
|
|
42
56
|
loggedIn: valid,
|
|
43
57
|
user: valid ? { id: session!.userId, name: session!.userName } : null,
|
|
44
58
|
sessionExpiresAt: valid ? new Date(session!.expiresAt).toISOString() : null,
|
|
59
|
+
ssoExpiresAt: ssoExpiresAt ? new Date(ssoExpiresAt).toISOString() : null,
|
|
45
60
|
server: sysVersion?.learn ?? null,
|
|
46
61
|
};
|
|
47
62
|
|
|
@@ -50,9 +65,10 @@ program
|
|
|
50
65
|
const v = sysVersion?.learn;
|
|
51
66
|
console.log(`\n Servidor: ${chalk.cyan(`Blackboard Learn ${v?.major}.${v?.minor}.${v?.patch} (${v?.build})`)}`);
|
|
52
67
|
if (valid) {
|
|
53
|
-
const
|
|
68
|
+
const { summary, note } = formatSessionLifetime(session!.expiresAt, ssoExpiresAt);
|
|
54
69
|
console.log(` Sesión: ${ok(`autenticado como ${chalk.bold(session!.userName || session!.userId || 'unknown')}`)}`);
|
|
55
|
-
console.log(` ${chalk.gray(
|
|
70
|
+
console.log(` ${chalk.gray(summary)}`);
|
|
71
|
+
console.log(` ${chalk.gray(note)}`);
|
|
56
72
|
} else {
|
|
57
73
|
console.log(` Sesión: ${fail('no autenticado')} — ejecuta: ${hint('blackboard login')}`);
|
|
58
74
|
}
|
package/src/ui/theme.ts
CHANGED
|
@@ -21,6 +21,32 @@ ${upcRed(' ██████ ███████ ██ ██ ███
|
|
|
21
21
|
${chalk.dim('CLI no oficial para UPC Aula Virtual · Blackboard Learn')}
|
|
22
22
|
`;
|
|
23
23
|
|
|
24
|
+
export function formatSessionLifetime(bbExpiresAt: number, ssoExpiresAt?: number): {
|
|
25
|
+
summary: string;
|
|
26
|
+
note: string;
|
|
27
|
+
} {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const bbMin = Math.max(0, Math.round((bbExpiresAt - now) / 60_000));
|
|
30
|
+
|
|
31
|
+
if (!ssoExpiresAt || ssoExpiresAt <= now) {
|
|
32
|
+
return {
|
|
33
|
+
summary: `Blackboard: ${bbMin} min`,
|
|
34
|
+
note: 'SSO sin "mantener sesión" — deberás reloguearte cuando expire',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ssoMs = ssoExpiresAt - now;
|
|
39
|
+
const ssoDays = Math.floor(ssoMs / 86_400_000);
|
|
40
|
+
const ssoStr = ssoDays >= 1
|
|
41
|
+
? `${ssoDays} día${ssoDays === 1 ? '' : 's'}`
|
|
42
|
+
: `${Math.round(ssoMs / 3_600_000)} h`;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
summary: `SSO Microsoft: ${ssoStr} · Blackboard: ${bbMin} min`,
|
|
46
|
+
note: 'se auto-renueva hasta que el SSO expire o hagas logout',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
export function whatNext() {
|
|
25
51
|
console.log(`
|
|
26
52
|
${chalk.bold('¿Qué puedo hacer ahora?')}
|