samga
Version:
CLI для samga.nis: профиль, журнал, табель с авторизацией
594 lines (546 loc) • 24.2 kB
JavaScript
import fs from 'fs'
import path from 'path'
import readline from 'readline'
import axios from 'axios'
import https from 'https'
import { fileURLToPath } from 'url'
import { spawn } from 'child_process'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const STORE = path.join(process.cwd(), '.samga.nis.json')
// Colors
const BLUE = '\x1b[38;2;37;99;235m'
const CYAN = '\x1b[38;2;56;189;248m'
const LIGHT = '\x1b[38;2;165;243;252m'
const GREEN = '\x1b[38;2;34;197;94m'
const DIM = '\x1b[2m'
const RESET = '\x1b[0m'
const agent = new https.Agent({ rejectUnauthorized: false })
const client = axios.create({ httpsAgent: agent, timeout: 30000 })
client.interceptors.request.use((config) => {
config.headers['user-agent'] = 'Dart/3.1 (dart:io)'
config.headers.cookie = 'Culture=ru-RU;'
return config
})
const LOGIN = 'https://identity.micros.nis.edu.kz/v1/Users/Authenticate'
const USER_INFO = 'https://contingent.micros.nis.edu.kz/Api/AdditionalUserInfo'
const REPORT_CARD = 'https://reportcard.micros.nis.edu.kz/v1/ReportCard/GetAllReportCardsAsync'
const GET_JOURNAL = (city) => `https://sms.${city}.nis.edu.kz/jce/Api/Api/GetSubjectsAndPeriods`
const GET_RUBRIC = (city) => `https://sms.${city}.nis.edu.kz/jce/Api/Api/GetDataBySectionAndByPeriod`
function prompt(query) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
return new Promise((resolve) => rl.question(query, (ans) => { rl.close(); resolve(ans) }))
}
function banner() {
console.clear()
const art = [
`${BLUE} ██████ ▄▄▄ ███▄ ▄███▓ ▄████ ▄▄▄ ${CYAN}v2.3${RESET}`,
`${BLUE}▒██ ▒ ▒████▄ ▓██▒▀█▀ ██▒ ██▒ ▀█▒▒████▄ `,
`${BLUE}░ ▓██▄ ▒██ ▀█▄ ▓██ ▓██░▒██░▄▄▄░▒██ ▀█▄ ${CYAN}SAMGA${RESET}`,
`${BLUE} ▒ ██▒░██▄▄▄▄██ ▒██ ▒██ ░▓█ ██▓░██▄▄▄▄██ `,
`${BLUE}▒██████▒▒ ▓█ ▓██▒▒██▒ ░██▒░▒▓███▀▒ ▓█ ▓██▒`,
`${BLUE}▒ ▒▓▒ ▒ ░ ▒▒ ▓▒█░░ ▒░ ░ ░ ░▒ ▒ ▒▒ ▓▒█░`,
`${BLUE}░ ░▒ ░ ░ ▒ ▒▒ ░░ ░ ░ ░ ░ ▒ ▒▒ ░`,
`${BLUE}░ ░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ `,
`${BLUE} ░ ░ ░ ░ ░ ░ ░${RESET}`,
].join('\n')
console.log(art)
console.log(`${DIM}CLI v2.3 • samga.top — вход, профиль, журнал, табель, настройки${RESET}\n`)
}
function footer() {
console.log(`\n${DIM}Создано qynon — нажмите K, чтобы открыть Telegram${RESET}`)
}
function openUrl(url) {
const platform = process.platform
if (platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' })
else if (platform === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' })
else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' })
}
async function ensureProfileCache(context, store) {
if (store?.profile?.fio && store?.profile?.klass && store?.profile?.school && store?.profile?.city && store?.profile?.studentId) return store
const add = await getAdditionalUserInfo(context.accessToken)
const { userInfo } = decodeJWT(context.accessToken)
const schoolGid = add?.data?.School?.Gid || null
const city = deriveCity(context.applications, schoolGid)
const fio = userInfo?.FullName || `${userInfo?.LastName || ''} ${userInfo?.FirstName || ''}`.trim()
const klass = add?.data?.Klass || userInfo?.Klass || '—'
const school = add?.data?.School?.Name?.ru || '—'
store.profile = { fio, klass, school, city, studentId: userInfo?.PersonGid }
store.city = store.profile.city
store.studentId = store.profile.studentId
saveStore(store)
return store
}
function printProfile(store) {
const p = store?.profile || {}
console.log(`${CYAN}Профиль${RESET}`)
console.log(`ФИО: ${LIGHT}${p.fio || '—'}${RESET}`)
console.log(`Класс: ${LIGHT}${p.klass || '—'}${RESET}`)
console.log(`Школа/Город: ${LIGHT}${p.school || '—'}${RESET} ${DIM}(city: ${p.city || '—'})${RESET}`)
}
async function renderHeader(context, store) {
banner()
await ensureProfileCache(context, store)
printProfile(store)
}
function inlineStatus(msg) {
process.stdout.write(`\r${msg}${' '.repeat(60)}`)
}
async function authenticate(iin, password) {
const { data } = await client.request({
method: 'post',
url: LOGIN,
data: {
action: 'v1/Users/Authenticate',
operationId: cryptoRandomId(),
username: iin,
password,
deviceInfo: 'SM-G950F',
},
})
return data // { accessToken, refreshToken, applications }
}
async function getAdditionalUserInfo(accessToken) {
const { data } = await client.request({
method: 'post',
url: USER_INFO,
headers: { Authorization: `Bearer ${accessToken}` },
data: { applicationType: 'ContingentAPI', action: 'Api/AdditionalUserInfo', operationId: cryptoRandomId() },
})
return data
}
async function getReports(accessToken, studentId) {
const { data } = await client.request({
method: 'post',
url: REPORT_CARD,
headers: { Authorization: `Bearer ${accessToken}` },
data: { action: 'v1/ReportCard/GetAllReportCardsAsync', operationId: cryptoRandomId(), studentId },
})
return data
}
async function getJournal(accessToken, city, studentId) {
const { data } = await client.request({
method: 'post',
url: GET_JOURNAL(city),
headers: { Authorization: `Bearer ${accessToken}` },
data: { action: 'Api/GetSubjectsAndPeriods', operationId: cryptoRandomId(), studentId },
})
return data
}
async function getRubric(accessToken, city, studentId, subjectId, quarter) {
const { data } = await client.request({
method: 'post',
url: GET_RUBRIC(city),
headers: { Authorization: `Bearer ${accessToken}` },
data: {
action: 'Api/GetDataBySectionAndByPeriod',
operationId: cryptoRandomId(),
studentId,
subjectId,
periodIndex: quarter,
},
})
return data
}
function cryptoRandomId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function saveStore(obj) {
fs.writeFileSync(STORE, JSON.stringify(obj, null, 2))
}
function loadStore() {
if (!fs.existsSync(STORE)) return null
try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { return null }
}
function b64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/')
const pad = str.length % 4 === 2 ? '==' : str.length % 4 === 3 ? '=' : ''
return Buffer.from(str + pad, 'base64').toString('utf8')
}
function decodeJWT(token) {
try {
const [, payload] = token.split('.')
const json = JSON.parse(b64urlDecode(payload))
if (json.UserInfo) {
try {
const ui = JSON.parse(json.UserInfo)
return { payload: json, userInfo: ui }
} catch { return { payload: json, userInfo: null } }
}
return { payload: json, userInfo: null }
} catch { return { payload: null, userInfo: null } }
}
function deriveCity(applications, schoolGid) {
if (!Array.isArray(applications)) return null
const candidate = applications.find((a) => a.type === 52 && a.organizationGid === schoolGid) || applications.find((a) => a.type === 52)
if (!candidate?.url) return null
// url: https://sms.(city).nis.edu.kz/...
const parts = candidate.url.split('.')
return parts[1] || null
}
function gradeColor(v) {
if (v == null) return RESET
if (v >= 85) return GREEN
if (v >= 65) return CYAN
return RESET
}
function quarterLabel(n) {
const roman = ['I', 'II', 'III', 'IV'][n - 1] || String(n)
return `${roman} четверть`
}
function enableRaw(onData) {
const stdin = process.stdin
stdin.setRawMode && stdin.setRawMode(true)
stdin.resume()
stdin.on('data', onData)
return () => {
stdin.off('data', onData)
stdin.setRawMode && stdin.setRawMode(false)
stdin.pause()
}
}
async function selectIndexArrows(items, startIdx, title) {
let idx = Math.max(0, Math.min(items.length - 1, startIdx || 0))
return await new Promise((resolve) => {
const render = () => {
console.clear(); banner()
if (title) console.log(title)
items.forEach((it, i) => {
console.log(`${i === idx ? '>' : ' '} ${it}`)
})
console.log(`\nСтрелки ←/→/↑/↓, Enter — выбрать, B — назад`)
}
const onData = (buf) => {
const s = buf.toString()
if (s === '\r' || s === '\n') { cleanup(); resolve({ idx, back: false }) }
else if (s === '\u0003') { cleanup(); resolve({ idx, back: true }) }
else if (s.toLowerCase() === 'b') { cleanup(); resolve({ idx, back: true }) }
else if (s === '\u001b[A' || s === '\u001b[D') { idx = (idx - 1 + items.length) % items.length; render() }
else if (s === '\u001b[B' || s === '\u001b[C') { idx = (idx + 1) % items.length; render() }
}
const cleanup = enableRaw(onData)
render()
})
}
async function selectQuarterArrows(startQuarter) {
let q = Math.max(1, Math.min(4, startQuarter || 1))
return await new Promise((resolve) => {
const render = () => {
console.clear(); banner()
console.log(`${CYAN}Журнал — ${quarterLabel(q)}${RESET}`)
console.log(`\nВыбор четверти: [${q === 1 ? '>' : ' '} I] [${q === 2 ? '>' : ' '} II] [${q === 3 ? '>' : ' '} III] [${q === 4 ? '>' : ' '} IV]`)
console.log(`\nСтрелки ←/→, Enter — выбрать, B — назад`)
}
const onData = (buf) => {
const s = buf.toString()
if (s === '\r' || s === '\n') { cleanup(); resolve({ quarter: q, back: false }) }
else if (s.toLowerCase() === 'b' || s === '\u0003') { cleanup(); resolve({ quarter: q, back: true }) }
else if (s === '\u001b[D') { q = q === 1 ? 4 : q - 1; render() }
else if (s === '\u001b[C') { q = q === 4 ? 1 : q + 1; render() }
}
const cleanup = enableRaw(onData)
render()
})
}
async function showProfile(context) {
const { accessToken, applications } = context
const add = await getAdditionalUserInfo(accessToken)
const { userInfo } = decodeJWT(accessToken)
const schoolGid = add?.data?.School?.Gid || null
const city = deriveCity(applications, schoolGid)
const fio = userInfo?.FullName || `${userInfo?.LastName || ''} ${userInfo?.FirstName || ''}`.trim()
const klass = add?.data?.Klass || userInfo?.Klass || '—'
const school = add?.data?.School?.Name?.ru || '—'
console.log(`${CYAN}Профиль${RESET}`)
console.log(`ФИО: ${LIGHT}${fio || '—'}${RESET}`)
console.log(`Класс: ${LIGHT}${klass}${RESET}`)
console.log(`Школа/Город: ${LIGHT}${school}${RESET} ${DIM}(city: ${city || '—'})${RESET}`)
return { city, studentId: userInfo?.PersonGid }
}
async function showJournalTerm(context, city, studentId, termNumber) {
if (!city || !studentId) { console.log('Нет данных для журнала'); return }
const data = await getJournal(context.accessToken, city, studentId)
const term = (Array.isArray(data) ? data : []).find((t) => t.number === termNumber) || (Array.isArray(data) ? data : [])[0]
console.log(`\n${CYAN}Журнал — ${quarterLabel(term?.number || termNumber)}${RESET}`)
if (!term) { console.log('Нет данных'); return }
term.subjects.forEach((s) => {
const color = gradeColor(s.currScore)
const subj = (s.name?.ru || '—').slice(0, 24).padEnd(24, ' ')
console.log(` ${subj} ${color}${(s.currScore ?? 0).toString().padStart(3, ' ')}${RESET}${DIM}${s.mark ? ` итог: ${s.mark}` : ''}${RESET}`)
})
return term
}
async function showJournalFlow(context, city, studentId, startQuarter) {
let { quarter, back } = await selectQuarterArrows(startQuarter || 1)
if (back) return null
while (true) {
console.clear();
await renderHeader(context, { ...global.storeRef })
const term = await showJournalTerm(context, city, studentId, quarter)
// выбор предмета стрелками
const items = (term?.subjects || []).map((s) => {
const name = (s.name?.ru || '—')
const perc = typeof s.currScore === 'number' ? `${s.currScore}%` : '—'
return `${name} — ${perc}`
})
console.log(`\nВыберите предмет (стрелки, Enter) или [B] назад. [←/→] Сменить четверть`)
footer()
const res = await new Promise((resolve) => {
let idx = 0
const render = () => {
console.clear();
renderHeader(context, { ...global.storeRef }).then(() => {
console.log(`${CYAN}Журнал — ${quarterLabel(quarter)}${RESET}`)
items.forEach((t, i) => console.log(`${i === idx ? '>' : ' '} ${t}`))
console.log(`\nСтрелки ↑/↓, Enter — открыть предмет, B — назад, ←/→ — четверть`)
footer()
})
}
const onData = (buf) => {
const s = buf.toString()
if (s === '\r' || s === '\n') { cleanup(); resolve({ type: 'open', idx }) }
else if (s.toLowerCase() === 'b') { cleanup(); resolve({ type: 'back' }) }
else if (s.toLowerCase() === 'k') { openUrl('https://t.me/qynon') }
else if (s === '\u001b[A') { idx = (idx - 1 + items.length) % items.length; render() }
else if (s === '\u001b[B') { idx = (idx + 1) % items.length; render() }
else if (s === '\u001b[D') { quarter = quarter === 1 ? 4 : quarter - 1; cleanup(); resolve({ type: 'rerender' }) }
else if (s === '\u001b[C') { quarter = quarter === 4 ? 1 : quarter + 1; cleanup(); resolve({ type: 'rerender' }) }
}
const cleanup = enableRaw(onData)
render()
})
if (res?.type === 'back') break
if (res?.type === 'rerender') continue
if (typeof res?.idx === 'number') {
await showRubricFlow(context, city, studentId, term, res.idx, quarter)
}
}
return quarter
}
function calcRubric(list = []) {
let mark = 0, max = 0
for (const r of list) {
const m = Number(r?.mark ?? 0)
const mx = Number(r?.maxMark ?? 0)
mark += m; max += mx
}
const pct = max > 0 ? Math.round((mark / max) * 10000) / 100 : 0
return { mark, max, pct }
}
async function showRubricFlow(context, city, studentId, term, startIdx, quarter) {
if (!term) return
let idx = startIdx || 0
while (true) {
const subject = term.subjects[idx]
const subjectId = subject?.id
const subjectName = subject?.name?.ru || '—'
const rubric = await getRubric(context.accessToken, city, studentId, subjectId, quarter)
const sor = calcRubric(rubric?.sumChapterCriteria)
const soch = calcRubric(rubric?.sumQuarterCriteria)
const totalPct = Math.round((sor.pct + soch.pct) * 100) / 100 // как на сайте
console.clear();
await renderHeader(context, { ...global.storeRef })
console.log(`${CYAN}${subjectName}${RESET} — ${quarterLabel(quarter)}`)
console.log(`Итого: ${LIGHT}${totalPct}%${RESET}`)
console.log(`\nСОР: ${sor.mark}/${sor.max} — ${sor.pct}%`)
for (const r of (rubric?.sumChapterCriteria || [])) {
console.log(` ${(r?.title?.ru || '—').slice(0, 40).padEnd(40, ' ')} ${LIGHT}${r?.mark}/${r?.maxMark}${RESET}`)
}
if ((rubric?.sumQuarterCriteria || []).length) {
console.log(`\nСОЧ: ${soch.mark}/${soch.max} — ${soch.pct}%`)
for (const r of (rubric?.sumQuarterCriteria || [])) {
console.log(` ${(r?.title?.ru || '—').slice(0, 40).padEnd(40, ' ')} ${LIGHT}${r?.mark}/${r?.maxMark}${RESET}`)
}
}
console.log(`\nНавигация: [←/→] Предмет [B] Назад`)
footer()
const nav = await new Promise((resolve) => {
const onData = (buf) => {
const s = buf.toString()
if (s.toLowerCase() === 'b') { cleanup(); resolve('back') }
else if (s.toLowerCase() === 'k') { openUrl('https://t.me/qynon') }
else if (s === '\u001b[D') { idx = idx === 0 ? term.subjects.length - 1 : idx - 1; cleanup(); resolve('rerender') }
else if (s === '\u001b[C') { idx = (idx + 1) % term.subjects.length; cleanup(); resolve('rerender') }
}
const cleanup = enableRaw(onData)
})
if (nav === 'back') break
}
}
function computeGpaFromReport(report) {
const nums = []
for (const row of report?.reportCard || []) {
const markStr = row?.yearMark?.ru || row?.resultMark?.ru || row?.secondHalfYearMark?.ru || row?.firstHalfYearMark?.ru || null
if (!markStr) continue
const n = parseFloat(String(markStr).replace(',', '.'))
if (!Number.isNaN(n)) nums.push(n)
}
if (!nums.length) return null
const avg = nums.reduce((a, b) => a + b, 0) / nums.length
return Math.round(avg * 100) / 100
}
function toText(markObj) {
return markObj && typeof markObj === 'object' ? (markObj.ru ?? markObj.en ?? markObj.kk ?? '—') : (markObj ?? '—')
}
function toNum(text) {
if (text == null) return null
const n = parseFloat(String(text).replace(',', '.'))
return Number.isNaN(n) ? null : n
}
function subjectNumeric(row) {
// Приоритет: годовая/итог → полугодия → среднее по четвертям
const year = toNum(toText(row?.yearMark) || toText(row?.resultMark))
if (year != null) return year
const h1 = toNum(toText(row?.firstHalfYearMark))
const h2 = toNum(toText(row?.secondHalfYearMark))
const halves = [h1, h2].filter((x) => x != null)
if (halves.length) return halves.reduce((a, b) => a + b, 0) / halves.length
const p = [row?.firstPeriod, row?.secondPeriod, row?.thirdPeriod, row?.fourthPeriod]
.map((m) => toNum(toText(m)))
.filter((x) => x != null)
if (p.length) return p.reduce((a, b) => a + b, 0) / p.length
return null
}
async function showReportsFlow(context, studentId) {
const reports = await getReports(context.accessToken, studentId)
if (!Array.isArray(reports) || !reports.length) { console.log('Нет доступных табелей'); return }
let idx = 0
while (true) {
const options = reports.map((rc, i) => rc?.schoolYear?.name?.ru || `Год ${i + 1}`)
const sel = await selectIndexArrows(options, idx, `${CYAN}Выбор года для табеля${RESET}`)
if (sel.back) break
idx = sel.idx
console.clear();
await renderHeader(context, { ...global.storeRef })
const rc = reports[idx]
const yearName = rc?.schoolYear?.name?.ru || 'Учебный год'
console.log(`${CYAN}Табель — ${yearName}${RESET}`)
// Шапка: I..IV, затем Год, Экз
console.log(DIM + ' ' + 'Предмет'.padEnd(20, ' ') + 'I II III IV Год Экз' + RESET)
rc?.reportCard?.forEach((row) => {
const name = (row?.subject?.name?.ru || '—').slice(0, 20).padEnd(20, ' ')
const p1 = toText(row?.firstPeriod).toString().padEnd(3, ' ')
const p2 = toText(row?.secondPeriod).toString().padEnd(3, ' ')
const p3 = toText(row?.thirdPeriod).toString().padEnd(3, ' ')
const p4 = toText(row?.fourthPeriod).toString().padEnd(3, ' ')
const yr = toText(row?.yearMark || row?.resultMark).toString().padEnd(3, ' ')
const ex = toText(row?.examMark).toString().padEnd(3, ' ')
console.log(` ${name} ${LIGHT}${p1}${RESET} ${LIGHT}${p2}${RESET} ${LIGHT}${p3}${RESET} ${LIGHT}${p4}${RESET} ${LIGHT}${yr}${RESET} ${LIGHT}${ex}${RESET}`)
})
// GPA
const nums = []
for (const row of (rc?.reportCard || [])) { const n = subjectNumeric(row); if (n != null) nums.push(n) }
const gpa = nums.length ? Math.round((nums.reduce((a,b)=>a+b,0)/nums.length) * 100) / 100 : null
console.log(`\nGPA: ${gpa != null ? GREEN + gpa.toFixed(2) + RESET : '—'}`)
console.log(`\nНавигация: [Enter] Выбрать другой год [B] Назад`)
footer()
const nav = (await prompt('Выбор: ')).trim().toLowerCase()
if (nav === 'b') break
if (nav === 'k') openUrl('https://t.me/qynon')
}
}
async function settingsMenu(context, store) {
const currGpa = store?.settings?.gpaSystem || '5'
const currSort = store?.settings?.sort || 'score-down'
while (true) {
console.clear();
await renderHeader(context, store)
const options = [
`GPA система: ${LIGHT}${store?.settings?.gpaSystem || currGpa}${RESET}`,
`Сортировка: ${LIGHT}${store?.settings?.sort || currSort}${RESET}`,
'Назад',
]
const sel = await selectIndexArrows(options, 0, `${CYAN}Настройки${RESET}`)
if (sel.back || sel.idx === 2) break
if (sel.idx === 0) {
const opt = await selectIndexArrows(['5', '4'], (store?.settings?.gpaSystem === '4') ? 1 : 0, `${CYAN}GPA система${RESET}`)
if (!opt.back) {
store.settings = store.settings || {}
store.settings.gpaSystem = opt.idx === 1 ? '4' : '5'
saveStore(store)
}
} else if (sel.idx === 1) {
const values = ['asc', 'score-up', 'score-down']
const start = Math.max(0, values.indexOf(store?.settings?.sort || currSort))
const opt = await selectIndexArrows(values, start, `${CYAN}Сортировка${RESET}`)
if (!opt.back) {
store.settings = store.settings || {}
store.settings.sort = values[opt.idx]
saveStore(store)
}
}
}
}
async function renderDashboard(store, context) {
console.clear();
await renderHeader(context, store)
const { city, studentId } = store.profile || {}
store.city = city
store.studentId = studentId
saveStore(store)
const selQ = store.selectedQuarter || 1
await showJournalTerm(context, city, studentId, selQ)
footer()
const options = ['Журнал (четверть)', 'Табель', 'Настройки', 'Выход из аккаунта', 'Выход']
const sel = await selectIndexArrows(options, 0, `${CYAN}Навигация${RESET}`)
if (sel.back) return 'q'
return ['j', 't', 's', 'l', 'q'][sel.idx]
}
async function main() {
banner()
const args = process.argv.slice(2)
const cmd = args[0]
if (cmd === 'logout') {
saveStore({})
console.log('Logged out')
return
}
let store = loadStore() || {}
let { accessToken, refreshToken, applications } = store
if (!accessToken || !refreshToken) {
const iin = await prompt('ИИН: ')
const password = await prompt('Пароль: ')
inlineStatus('Вход...')
try {
const auth = await authenticate(iin.trim(), password.trim())
accessToken = auth.accessToken
refreshToken = auth.refreshToken
applications = auth.applications || []
store = { ...store, accessToken, refreshToken, applications }
saveStore(store)
inlineStatus('Вход... OK')
} catch (e) {
inlineStatus('Вход... ошибка')
console.error(`\nОшибка входа:`, e?.code || e?.message)
process.exit(1)
}
}
const context = { accessToken, applications }
global.storeRef = store
while (true) {
const nav = await renderDashboard(store, context)
if (nav === 'j') {
const quarter = await showJournalFlow(context, store.city, store.studentId, store.selectedQuarter || 1)
if (quarter) { store.selectedQuarter = quarter; saveStore(store) }
} else if (nav === 't') {
await showReportsFlow(context, store.studentId)
} else if (nav === 's') {
await settingsMenu(context, store)
} else if (nav === 'l') {
saveStore({})
console.log('Выполнен выход. Перезапустите CLI для входа снова.')
break
} else if (nav === 'q') {
break
}
}
}
main().catch((e) => {
console.error('Ошибка CLI:', e?.response?.data || e.message)
process.exit(1)
})