Sign In

campus-cli

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

campus-cli - npm Package Compare versions

Comparing version
1.2.0
to
1.3.0
+24
dist/account/client.d.ts
import type { CampusAccount } from './types.js';
export declare const CAMPUS_ACCOUNT_URL: string;
export declare function buildAuthorizeUrl(input: {
redirectUri: string;
codeChallenge: string;
state: string;
}): string;
export declare function exchangeCode(input: {
code: string;
verifier: string;
redirectUri: string;
}): Promise<{
account: CampusAccount;
accessToken: string;
refreshToken: string;
expiresIn: number;
}>;
export declare function refreshTokens(refreshToken: string): Promise<{
account: CampusAccount;
accessToken: string;
refreshToken: string;
expiresIn: number;
}>;
export declare function fetchAccount(accessToken: string): Promise<CampusAccount>;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CAMPUS_ACCOUNT_URL = void 0;
exports.buildAuthorizeUrl = buildAuthorizeUrl;
exports.exchangeCode = exchangeCode;
exports.refreshTokens = refreshTokens;
exports.fetchAccount = fetchAccount;
// The hosted "Campus account" service (Google login, OAuth2+PKCE) — same
// backend that serves the MCP connector at mcp.campuscli.com/.well-known/oauth-authorization-server.
exports.CAMPUS_ACCOUNT_URL = (process.env.CAMPUS_ACCOUNT_URL ?? 'https://mcp.campuscli.com').replace(/\/$/, '');
function buildAuthorizeUrl(input) {
const url = new URL(`${exports.CAMPUS_ACCOUNT_URL}/v1/auth/google/start`);
url.searchParams.set('redirect_uri', input.redirectUri);
url.searchParams.set('code_challenge', input.codeChallenge);
url.searchParams.set('state', input.state);
return url.toString();
}
async function exchangeCode(input) {
const res = await fetch(`${exports.CAMPUS_ACCOUNT_URL}/v1/auth/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: input.code, verifier: input.verifier, redirectUri: input.redirectUri }),
});
if (!res.ok)
throw new Error(`No se pudo completar el login (${res.status})`);
return res.json();
}
async function refreshTokens(refreshToken) {
const res = await fetch(`${exports.CAMPUS_ACCOUNT_URL}/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok)
throw new Error(`No se pudo renovar la sesión de cuenta (${res.status})`);
return res.json();
}
async function fetchAccount(accessToken) {
const res = await fetch(`${exports.CAMPUS_ACCOUNT_URL}/v1/account`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok)
throw new Error(`No se pudo obtener la cuenta (${res.status})`);
const data = await res.json();
return data.account;
}
import type { Command } from 'commander';
export declare function accountCommand(program: Command): void;
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.accountCommand = accountCommand;
const chalk_1 = __importDefault(require("chalk"));
const ora_1 = __importDefault(require("ora"));
const login_js_1 = require("./login.js");
const session_js_1 = require("./session.js");
const store_js_1 = require("./store.js");
const theme_js_1 = require("../ui/theme.js");
const login_js_2 = require("../providers/blackboard/commands/login.js");
function accountCommand(program) {
const account = program
.command('account')
.description('Cuenta Campus (Google) — identidad compartida entre las apps de Campus, separada de tu sesión de Blackboard');
account
.command('login')
.description('Inicia sesión con tu cuenta Campus (Google) en el navegador')
.action(async () => {
console.log(chalk_1.default.cyan('\nAbriendo el navegador para iniciar sesión con Google...\n'));
try {
const session = await (0, login_js_1.loginWithCampusAccount)();
(0, store_js_1.saveAccountSession)(session);
console.log((0, theme_js_1.ok)(`Sesión iniciada como ${chalk_1.default.bold(session.account.name)} (${session.account.email})`));
}
catch (err) {
console.error((0, theme_js_1.fail)(`No se pudo iniciar sesión: ${err.message}`));
process.exit(1);
}
// Once the shared Campus identity is set up, continue straight into
// the (unchanged, fully local) Blackboard SSO flow.
await (0, login_js_2.runBlackboardLogin)();
});
account
.command('whoami')
.description('Muestra la cuenta Campus activa')
.action(async () => {
const stored = (0, store_js_1.loadAccountSession)();
if (!stored) {
console.log((0, theme_js_1.fail)('No hay sesión de cuenta Campus.'));
console.log(`Ejecuta: ${(0, theme_js_1.hint)('campus account login')}`);
return;
}
const spinner = (0, ora_1.default)('Verificando sesión...').start();
const session = await (0, session_js_1.getValidAccountSession)();
if (!session) {
spinner.fail('Sesión expirada o revocada.');
console.log(`Ejecuta: ${(0, theme_js_1.hint)('campus account login')}`);
process.exit(1);
}
spinner.stop();
console.log((0, theme_js_1.ok)(`${chalk_1.default.bold(session.account.name)} (${session.account.email})`));
if (session.account.universityId)
console.log(chalk_1.default.gray(` Universidad: ${session.account.universityId}`));
});
account
.command('logout')
.description('Cierra la sesión de la cuenta Campus en este equipo')
.action(() => {
(0, store_js_1.clearAccountSession)();
console.log((0, theme_js_1.ok)('Sesión de cuenta Campus cerrada.'));
});
}
import type { CampusAccountSession } from './types.js';
export declare function loginWithCampusAccount(): Promise<CampusAccountSession>;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.loginWithCampusAccount = loginWithCampusAccount;
const node_http_1 = require("node:http");
const node_crypto_1 = require("node:crypto");
const node_child_process_1 = require("node:child_process");
const client_js_1 = require("./client.js");
const CALLBACK_TIMEOUT_MS = 5 * 60_000;
function openBrowser(url) {
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
(0, node_child_process_1.execFile)(command, [url], () => { });
}
function callbackPage(input) {
const accent = input.ok ? '#087f5b' : '#e31837';
const iconPath = input.ok
? '<path d="M20 6L9 17l-5-5" stroke="#087f5b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
: '<path d="M18 6L6 18M6 6l12 12" stroke="#e31837" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
return `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>campus-cli</title>
<style>
:root { --ink: #102a3a; --muted: #506d7f; --line: #d4e1e6; --canvas: #f4f8f8; --paper: #ffffff; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: var(--canvas); color: var(--ink);
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
}
.card {
width: min(92vw, 420px); padding: 40px 32px; border: 1px solid var(--line); border-radius: 16px;
background: var(--paper); box-shadow: 0 24px 54px rgba(16, 42, 58, .08); text-align: center;
}
.icon {
width: 56px; height: 56px; margin: 0 auto 20px; border-radius: 50%; display: grid; place-items: center;
background: ${input.ok ? '#d9f1e8' : '#fbe3e7'};
}
h1 { font-size: 19px; margin: 0 0 8px; }
p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.brand { margin-top: 28px; font-size: 12px; color: var(--muted); letter-spacing: .02em; }
.brand b { color: var(--ink); }
</style>
</head>
<body>
<div class="card">
<div class="icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none">${iconPath}</svg></div>
<h1>${input.title}</h1>
<p>${input.message}</p>
<div class="brand"><b>campus-cli</b> · puedes cerrar esta pestaña</div>
</div>
</body>
</html>`;
}
function pkcePair() {
const verifier = (0, node_crypto_1.randomBytes)(64).toString('base64url');
const challenge = (0, node_crypto_1.createHash)('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// Loopback (127.0.0.1) redirect, like `gh auth login`/`gcloud auth login`: a
// throwaway local HTTP server catches Google's redirect after Campus account
// consent, so no browser extension or custom URL scheme is needed.
async function loginWithCampusAccount() {
const { verifier, challenge } = pkcePair();
const state = (0, node_crypto_1.randomBytes)(24).toString('hex');
const { code, redirectUri } = await new Promise((resolve, reject) => {
const server = (0, node_http_1.createServer)((req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
if (url.pathname !== '/callback') {
res.writeHead(404).end();
return;
}
const receivedState = url.searchParams.get('state');
const error = url.searchParams.get('error');
const receivedCode = url.searchParams.get('code');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(error
? callbackPage({ ok: false, title: 'Login cancelado', message: 'No se completó el inicio de sesión. Puedes cerrar esta pestaña y volver a la terminal.' })
: callbackPage({ ok: true, title: '¡Listo!', message: 'Tu cuenta Campus quedó conectada. Puedes cerrar esta pestaña y volver a la terminal.' }));
clearTimeout(timeout);
server.close();
if (error)
return reject(new Error(`Login cancelado: ${error}`));
if (receivedState !== state)
return reject(new Error('El estado de OAuth no coincide (posible ataque CSRF)'));
if (!receivedCode)
return reject(new Error('Google no devolvió un código de autorización'));
resolve({ code: receivedCode, redirectUri: currentRedirectUri });
});
const timeout = setTimeout(() => {
server.close();
reject(new Error('Tiempo de espera agotado esperando el login en el navegador'));
}, CALLBACK_TIMEOUT_MS);
let currentRedirectUri = '';
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
currentRedirectUri = `http://127.0.0.1:${port}/callback`;
openBrowser((0, client_js_1.buildAuthorizeUrl)({ redirectUri: currentRedirectUri, codeChallenge: challenge, state }));
});
});
const { account, accessToken, refreshToken, expiresIn } = await (0, client_js_1.exchangeCode)({ code, verifier, redirectUri });
return {
account,
accessToken,
refreshToken,
accessTokenExpiresAt: Date.now() + expiresIn * 1000,
};
}
import type { CampusAccountSession } from './types.js';
export declare function getValidAccountSession(): Promise<CampusAccountSession | null>;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getValidAccountSession = getValidAccountSession;
const client_js_1 = require("./client.js");
const store_js_1 = require("./store.js");
const REFRESH_MARGIN_MS = 60_000;
// Auto-refreshes the access token when it's close to expiry, so other
// commands (and future Campus products) can just call this and get a
// live session without dealing with the OAuth token dance themselves.
async function getValidAccountSession() {
const session = (0, store_js_1.loadAccountSession)();
if (!session)
return null;
if (session.accessTokenExpiresAt - Date.now() > REFRESH_MARGIN_MS)
return session;
try {
const { account, accessToken, refreshToken, expiresIn } = await (0, client_js_1.refreshTokens)(session.refreshToken);
const refreshed = {
account,
accessToken,
refreshToken,
accessTokenExpiresAt: Date.now() + expiresIn * 1000,
};
(0, store_js_1.saveAccountSession)(refreshed);
return refreshed;
}
catch {
return null; // refresh token expired/revoked — caller must prompt `campus account login`
}
}
import type { CampusAccountSession } from './types.js';
export declare function saveAccountSession(session: CampusAccountSession): void;
export declare function loadAccountSession(): CampusAccountSession | null;
export declare function clearAccountSession(): void;
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveAccountSession = saveAccountSession;
exports.loadAccountSession = loadAccountSession;
exports.clearAccountSession = clearAccountSession;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const ACCOUNT_DIR = path_1.default.join(os_1.default.homedir(), '.blackboard-cli');
const ACCOUNT_FILE = path_1.default.join(ACCOUNT_DIR, 'account.json');
function saveAccountSession(session) {
if (!fs_1.default.existsSync(ACCOUNT_DIR)) {
fs_1.default.mkdirSync(ACCOUNT_DIR, { recursive: true, mode: 0o700 });
}
fs_1.default.writeFileSync(ACCOUNT_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
}
function loadAccountSession() {
try {
if (!fs_1.default.existsSync(ACCOUNT_FILE))
return null;
return JSON.parse(fs_1.default.readFileSync(ACCOUNT_FILE, 'utf-8'));
}
catch {
return null;
}
}
function clearAccountSession() {
try {
if (fs_1.default.existsSync(ACCOUNT_FILE))
fs_1.default.unlinkSync(ACCOUNT_FILE);
}
catch { }
}
export type CampusAccount = {
id: string;
name: string;
email: string;
picture?: string;
universityId?: string;
};
export type CampusAccountSession = {
account: CampusAccount;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
};
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+11
-0

@@ -7,2 +7,13 @@ # Changelog

## [1.3.0] — 2026-08-05
### Added
- `campus account login|whoami|logout` — cuenta Campus (Google), la identidad compartida entre las apps del ecosistema Campus (CLI, y las que vienen: Profe, Trámites...), separada de la sesión de Blackboard de cada universidad. El login es vía navegador con OAuth2+PKCE (redirect a `127.0.0.1`, igual que `gh auth login`/`gcloud auth login`) contra el servicio ya hospedado en `mcp.campuscli.com`.
- `campus login` ahora exige tener una cuenta Campus activa antes de iniciar el SSO de Blackboard (que sigue siendo 100% local, sin cambios): si no hay cuenta, te pide correr `campus account login` primero. `campus account login`, al terminar, encadena automáticamente el SSO de Blackboard — no hace falta correr los dos comandos por separado.
### Notes
- El SSO de Blackboard no cambió: sigue abriendo Chromium/Chrome/Edge local vía Playwright, sin pasar por ningún servidor propio. Solo la nueva cuenta Campus (Google) usa el backend hospedado.
---
## [1.2.0] — 2026-08-05

@@ -9,0 +20,0 @@

+3
-0

@@ -49,2 +49,3 @@ #!/usr/bin/env node

const assignments_js_1 = require("./providers/blackboard/commands/assignments.js");
const command_js_1 = require("./account/command.js");
const session_js_1 = require("./providers/blackboard/auth/session.js");

@@ -141,2 +142,4 @@ const client_js_1 = require("./providers/blackboard/api/client.js");

(0, assignments_js_1.assignmentsCommand)(program);
// Cuenta Campus (Google) — identidad compartida entre apps del ecosistema
(0, command_js_1.accountCommand)(program);
// API passthrough para LLMs / power users

@@ -143,0 +146,0 @@ program

import { Command } from 'commander';
export type BlackboardLoginOptions = {
headless?: boolean;
username?: string;
password?: string;
};
export declare function runBlackboardLogin(opts?: BlackboardLoginOptions): Promise<void>;
export declare function loginCommand(program: Command): void;
+48
-34

@@ -6,2 +6,3 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.runBlackboardLogin = runBlackboardLogin;
exports.loginCommand = loginCommand;

@@ -16,2 +17,42 @@ const inquirer_1 = __importDefault(require("inquirer"));

const analytics_js_1 = require("../../../analytics.js");
const session_js_2 = require("../../../account/session.js");
// Shared by `campus login` and `campus account login` (which chains into this
// right after the Campus account is set up) — same Blackboard SSO flow either way.
async function runBlackboardLogin(opts = {}) {
const existing = (0, session_js_1.loadSession)();
if ((0, session_js_1.isSessionValid)(existing)) {
console.log(chalk_1.default.yellow(`Already logged in as ${chalk_1.default.bold(existing.userName || 'unknown')}`));
const { relogin } = await inquirer_1.default.prompt([
{ type: 'confirm', name: 'relogin', message: 'Re-authenticate?', default: false },
]);
if (!relogin)
return;
}
console.log(chalk_1.default.cyan('\nOpening browser for Microsoft login...'));
console.log(chalk_1.default.gray('A browser window will open. Complete the login and it will close automatically.\n'));
(0, analytics_js_1.track)('login_started', { method: 'microsoft_sso' }, existing?.userId);
try {
const session = await (0, login_js_1.login)({
headless: opts.headless ?? false,
username: opts.username,
password: opts.password,
});
(0, analytics_js_1.track)('login_success', { method: 'microsoft_sso' }, session.userId);
const ssoExpiresAt = (0, login_js_1.getSsoExpiry)(session.cookies);
const { summary, note } = (0, theme_js_1.formatSessionLifetime)(session.expiresAt, ssoExpiresAt);
console.log((0, theme_js_1.ok)(`Sesión guardada`));
console.log(chalk_1.default.gray(` ${summary}`));
console.log(chalk_1.default.gray(` ${note}`));
if (session.userName)
console.log(chalk_1.default.gray(` Usuario: ${session.userName}`));
if (session.userId)
console.log(chalk_1.default.gray(` ID: ${session.userId}`));
(0, theme_js_1.whatNext)();
}
catch (err) {
(0, analytics_js_1.track)('login_failed', { method: 'microsoft_sso', error_type: err?.name ?? 'LoginError' });
console.error(chalk_1.default.red(`\n✗ Login failed: ${err.message}`));
process.exit(1);
}
}
function loginCommand(program) {

@@ -25,38 +66,11 @@ program

.action(async (opts) => {
// Check if already logged in
const existing = (0, session_js_1.loadSession)();
if ((0, session_js_1.isSessionValid)(existing)) {
console.log(chalk_1.default.yellow(`Already logged in as ${chalk_1.default.bold(existing.userName || 'unknown')}`));
const { relogin } = await inquirer_1.default.prompt([
{ type: 'confirm', name: 'relogin', message: 'Re-authenticate?', default: false },
]);
if (!relogin)
return;
}
console.log(chalk_1.default.cyan('\nOpening browser for Microsoft login...'));
console.log(chalk_1.default.gray('A browser window will open. Complete the login and it will close automatically.\n'));
(0, analytics_js_1.track)('login_started', { method: 'microsoft_sso' }, existing?.userId);
try {
const session = await (0, login_js_1.login)({
headless: opts.headless ?? false,
username: opts.username,
password: opts.password,
});
(0, analytics_js_1.track)('login_success', { method: 'microsoft_sso' }, session.userId);
const ssoExpiresAt = (0, login_js_1.getSsoExpiry)(session.cookies);
const { summary, note } = (0, theme_js_1.formatSessionLifetime)(session.expiresAt, ssoExpiresAt);
console.log((0, theme_js_1.ok)(`Sesión guardada`));
console.log(chalk_1.default.gray(` ${summary}`));
console.log(chalk_1.default.gray(` ${note}`));
if (session.userName)
console.log(chalk_1.default.gray(` Usuario: ${session.userName}`));
if (session.userId)
console.log(chalk_1.default.gray(` ID: ${session.userId}`));
(0, theme_js_1.whatNext)();
}
catch (err) {
(0, analytics_js_1.track)('login_failed', { method: 'microsoft_sso', error_type: err?.name ?? 'LoginError' });
console.error(chalk_1.default.red(`\n✗ Login failed: ${err.message}`));
// Blackboard SSO is gated behind the Campus account: it's the shared
// identity across Campus apps, so it comes first.
const accountSession = await (0, session_js_2.getValidAccountSession)();
if (!accountSession) {
console.log((0, theme_js_1.fail)('Primero inicia sesión con tu cuenta Campus.'));
console.log(chalk_1.default.gray(`Ejecuta: ${chalk_1.default.cyan('campus account login')}`));
process.exit(1);
}
await runBlackboardLogin(opts);
});

@@ -63,0 +77,0 @@ program

{
"name": "campus-cli",
"version": "1.2.0",
"version": "1.3.0",
"description": "CLI/MCP no oficial para el campus universitario (Blackboard, Canvas, Moodle...) — acceso desde la terminal y MCP para IA",

@@ -5,0 +5,0 @@ "main": "run.js",

@@ -47,3 +47,3 @@ # campus-cli

```bash
npx campus-cli login
npx campus-cli account login
```

@@ -55,3 +55,3 @@

npm install -g campus-cli
campus login
campus account login
```

@@ -65,3 +65,3 @@

npm install
node run.js login
node run.js account login
```

@@ -74,7 +74,9 @@

```bash
campus login
campus account login
```
Se abrirá una ventana con el login de Microsoft UPC. Inicia sesión con tu cuenta universitaria y completa MFA si aplica.
Se abre el navegador para iniciar sesión con tu cuenta Campus (Google) — es la identidad compartida entre las apps del ecosistema Campus, separada de tu sesión de Blackboard. Al terminar, encadena automáticamente el login de Microsoft UPC (Blackboard SSO, 100% local, sin pasar por ningún servidor propio). Si más adelante corres `campus login` por separado, te pedirá primero `campus account login` en caso de no tener una cuenta Campus activa.
Inicia sesión con tu cuenta universitaria y completa MFA si aplica.
Durante el login, Microsoft puede mostrar **"Stay signed in?"** con el checkbox **"Don't show this again"**. Marca ese checkbox y haz clic en **Yes** para que la sesión pueda mantenerse correctamente.

@@ -112,6 +114,14 @@

### Sesión
### Cuenta Campus
```bash
campus login # iniciar sesión con Microsoft SSO
campus account login # iniciar sesión con Google (encadena el login de Blackboard)
campus account whoami # cuenta Campus activa
campus account logout # cerrar sesión de la cuenta Campus en este equipo
```
### Sesión (Blackboard)
```bash
campus login # iniciar sesión con Microsoft SSO (pide cuenta Campus primero)
campus logout # borrar sesión local

@@ -118,0 +128,0 @@ campus whoami # usuario activo y tiempo restante