blackboard-upc 1.0.8 → 1.0.9

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 CHANGED
@@ -4,6 +4,19 @@ All notable changes to `blackboard-upc` will be documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.0.9] — 2026-04-24
8
+
9
+ ### Fixed
10
+ - **`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`
11
+ - **`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
12
+
13
+ ### Added
14
+ - `blackboard logout --keep-profile` — conserva las cookies SSO (útil para renovar sesión de la misma cuenta sin re-ingresar credenciales)
15
+ - **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)
16
+ - `resolveDisplayName()` helper en `src/auth/login.ts` — centraliza la lógica de nombre desde la respuesta de `/users/me`
17
+
18
+ ---
19
+
7
20
  ## [1.0.8] — 2026-04-19
8
21
 
9
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "CLI no oficial para UPC Aula Virtual (Blackboard Learn) — acceso desde la terminal y MCP para Claude",
5
5
  "main": "run.js",
6
6
  "bin": {
package/src/auth/login.ts CHANGED
@@ -43,6 +43,20 @@ 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
+
46
60
  function extractXsrf(cookies: Cookie[]): string {
47
61
  const bb = cookies.find(c => c.name === 'BbRouter');
48
62
  if (bb) {
@@ -158,16 +172,18 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
158
172
  if (resp.ok()) userData = await resp.json();
159
173
  } catch {}
160
174
 
175
+ const displayName = resolveDisplayName(userData);
176
+
161
177
  const session: Session = {
162
178
  cookies,
163
179
  xsrfToken: nonce,
164
180
  userId: userData?.id,
165
- userName: userData?.userName,
181
+ userName: displayName,
166
182
  expiresAt: extractBbRouterExpiry(cookies),
167
183
  };
168
184
 
169
185
  saveSession(session);
170
- console.log(`✓ Logged in as ${userData?.userName || 'unknown'}`);
186
+ console.log(`✓ Logged in as ${displayName || 'unknown'}`);
171
187
 
172
188
  return session;
173
189
  } finally {
@@ -215,11 +231,33 @@ export async function silentRelogin(previousSession?: Session | null): Promise<S
215
231
  throw new SilentLoginFailed('Redirect succeeded but session cookies are missing');
216
232
  }
217
233
 
234
+ let userId = previousSession?.userId;
235
+ let userName = previousSession?.userName;
236
+
237
+ // Self-heal: older sessions were stored with userName=null because the
238
+ // old extractor looked at the wrong field. Refetch once if it's missing.
239
+ if (!userName || !userId) {
240
+ try {
241
+ const cookieStrForApi = cookies
242
+ .filter(c => c.domain.includes('aulavirtual.upc.edu.pe'))
243
+ .map(c => `${c.name}=${c.value}`)
244
+ .join('; ');
245
+ const resp = await page.request.get(`${BASE_URL}/learn/api/public/v1/users/me`, {
246
+ headers: { Accept: 'application/json', Cookie: cookieStrForApi },
247
+ });
248
+ if (resp.ok()) {
249
+ const me = await resp.json();
250
+ userId = userId ?? me?.id;
251
+ userName = userName ?? resolveDisplayName(me);
252
+ }
253
+ } catch {}
254
+ }
255
+
218
256
  const session: Session = {
219
257
  cookies,
220
258
  xsrfToken: extractXsrf(cookies),
221
- userId: previousSession?.userId,
222
- userName: previousSession?.userName,
259
+ userId,
260
+ userName,
223
261
  expiresAt: extractBbRouterExpiry(cookies),
224
262
  };
225
263
 
@@ -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 {
@@ -2,8 +2,10 @@ 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';
5
+ import { login, resolveDisplayName } 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';
7
9
  import { ok, fail, warn, whatNext } from '../ui/theme.js';
8
10
 
9
11
  export function loginCommand(program: Command) {
@@ -48,21 +50,40 @@ export function loginCommand(program: Command) {
48
50
 
49
51
  program
50
52
  .command('logout')
51
- .description('Clear the saved session')
52
- .action(() => {
53
- clearSession();
54
- console.log(chalk.green('✓ Session cleared'));
53
+ .description('Borra la sesión y las cookies SSO para poder cambiar de cuenta')
54
+ .option('--keep-profile', 'Conservar las cookies SSO del navegador (no permite cambiar de cuenta)')
55
+ .action((opts) => {
56
+ clearSession({ keepProfile: !!opts.keepProfile });
57
+ if (opts.keepProfile) {
58
+ console.log(chalk.green('✓ Sesión borrada') + chalk.gray(' (profile SSO conservado)'));
59
+ } else {
60
+ console.log(chalk.green('✓ Sesión y profile SSO borrados'));
61
+ console.log(chalk.gray(' El próximo `blackboard login` te pedirá credenciales de nuevo.'));
62
+ }
55
63
  });
56
64
 
57
65
  program
58
66
  .command('whoami')
59
67
  .description('Show current logged-in user')
60
68
  .action(async () => {
61
- const session = await loadOrRefreshSession();
69
+ let session = await loadOrRefreshSession();
62
70
  if (!isSessionValid(session)) {
63
71
  console.log(chalk.red('Not logged in. Run: blackboard login'));
64
72
  process.exit(1);
65
73
  }
74
+
75
+ // Self-heal old sessions that were stored with userName=null.
76
+ if (!session!.userName) {
77
+ try {
78
+ const me = await getMe(createClient(session!));
79
+ const name = resolveDisplayName(me);
80
+ if (name) {
81
+ session = { ...session!, userId: session!.userId ?? me?.id, userName: name };
82
+ saveSession(session!);
83
+ }
84
+ } catch {}
85
+ }
86
+
66
87
  console.log(chalk.green(`Logged in as: ${chalk.bold(session!.userName || session!.userId || 'unknown')}`));
67
88
  const remaining = Math.round((session!.expiresAt - Date.now()) / 60_000);
68
89
  console.log(chalk.gray(`Session expires in: ${remaining} minutes`));
package/src/index.ts CHANGED
@@ -6,9 +6,10 @@ 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 { resolveDisplayName } from './auth/login.js';
12
13
  import { BANNER, ok, fail, hint } from './ui/theme.js';
13
14
 
14
15
  const program = new Command();
@@ -16,7 +17,7 @@ const program = new Command();
16
17
  program
17
18
  .name('blackboard')
18
19
  .description('CLI no oficial para UPC Aula Virtual (Blackboard Learn)')
19
- .version('1.0.7')
20
+ .version('1.0.9')
20
21
  .addHelpText('beforeAll', BANNER);
21
22
 
22
23
  // Auth commands
@@ -31,12 +32,23 @@ 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
- const session = await loadOrRefreshSession();
35
+ let session = await loadOrRefreshSession();
35
36
  const valid = isSessionValid(session);
36
37
 
37
- const sysVersion = await getSystemVersion(
38
- createClient(session ?? { cookies: [], xsrfToken: '', expiresAt: 0 })
39
- ).catch(() => null);
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
+ }
40
52
 
41
53
  const result = {
42
54
  loggedIn: valid,