Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,32 @@ All notable changes to `sustech-cli` are documented in this file.
headless servers, containers, and CI environments without requiring a desktop
D-Bus session or `secret-tool`. The encrypted store requires a master password
on first use and never stores credentials in plaintext.
- `tis schedule` now supports `--date YYYY-MM-DD` to query a specific date's
schedule, resolving the teaching week from the academic calendar automatically.
The `today` behavior uses `--date` with the current Shanghai date internally.
- Personal schedule entries from week-specific queries (`tis schedule --week N`,
`--date YYYY-MM-DD`, or current-week default) now include full ISO-8601
timestamps: `startAt` / `endAt` in Asia/Shanghai time (e.g.
`2026-09-15T14:00:00+08:00`) combining class date with period-based clock
times. The existing `periodStart` / `periodEnd` fields remain for
compatibility. Catalog `schedule[]` slots lack concrete dates and retain
period fields only. The official SUSTech period→clock mapping is documented in
`docs/ARCHITECTURE.md` so agents and humans share one source of truth.
- Schedule entries with multiple rooms (e.g. "505, 506") now populate a
structured `rooms` array when parseable, while keeping the primary `room`
field for compatibility.

### Changed

- Linux `auth login` no longer fails with `CREDENTIAL_STORE_UNAVAILABLE` when
Secret Service is unavailable. Instead, it automatically uses the encrypted
file backend at `~/.config/sustech-cli/encrypted-credentials/` with file
mode `0600`.
- Credential unlock failures now use stable, machine-readable error codes:
`MASTER_PASSWORD_REQUIRED` (missing master password for encrypted-file backend)
and `MASTER_PASSWORD_INVALID` (decryption failed). Remediation messages
consistently mention `SUSTECH_MASTER_PASSWORD` or interactive unlock across
`auth status`, `doctor`, and actual credential reads.

## [0.12.1] - 2026-09-12

Expand Down
146 changes: 146 additions & 0 deletions FEATURE_DEMO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Feature Demo: Schedule UX Improvements

This document demonstrates the new schedule UX improvements in `sustech-cli`.

## 1. Full ISO-8601 Datetime Timestamps

When querying a specific week (`--week`, `--date`, or current-week default),
personal schedule entries now include full ISO-8601 timestamps combining date
and clock time:

```json
{
"rwh": "2026-2027-1-CS101-001",
"courseCode": "CS101",
"courseName": "Programming",
"teacher": "Prof. Zhang",
"room": "一教101",
"day": 1,
"periodStart": 1,
"periodEnd": 2,
"startAt": "2026-09-07T08:00:00+08:00",
"endAt": "2026-09-07T09:50:00+08:00",
"weeks": [1, 2, 3, ...]
}
```

### Benefits for Agents
- **Direct datetime comparisons**: "Is there class this afternoon?" → Compare
current time against `startAt` / `endAt` directly
- **No date reassembly needed**: Timestamps are complete Asia/Shanghai ISO-8601
strings ready for parsing
- **Natural language queries**: "What time does CS101 start on Monday?" →
`startAt` field contains both date and time

### Catalog vs Personal Schedule

- **Personal schedule** (week-specific queries): Full `startAt` / `endAt` timestamps
- **Catalog search** (`tis courses search`): `schedule[]` slots lack concrete
dates, so only `periodStart` / `periodEnd` are provided

## 2. Date-Based Schedule Queries

Query schedules by specific date instead of week number:

```bash
# Old way (required knowing the teaching week)
sustech tis schedule --week 5

# New way (natural date query)
sustech tis schedule --date 2026-09-15

# Still works: query by week
sustech tis schedule --week 5

# Default behavior: show current week
sustech tis schedule
```

### Benefits
- More intuitive for "where is class on Friday?" questions
- Automatically resolves teaching week from academic calendar
- Validates date is within semester teaching period

## 3. Structured Room Fields

Multiple rooms are now parsed into a structured array:

```json
{
"room": "505, 506",
"rooms": ["505", "506"]
}
```

Single rooms remain as-is without the `rooms` array:

```json
{
"room": "一教101"
}
```

### Benefits
- Easy to detect multiple room assignments
- Structured data for route planning or resource allocation
- Backward compatible: existing `room` field unchanged

## 4. Credential Error Consistency

Master password errors are now clearly identified:

```bash
# Missing master password
Error: Encrypted credential store requires a master password.
Set SUSTECH_MASTER_PASSWORD or run interactively.
Code: MASTER_PASSWORD_REQUIRED

# Incorrect master password
Error: Encrypted store decryption failed; the master password may be incorrect.
Code: MASTER_PASSWORD_INVALID
```

### Benefits
- Clear distinction between missing vs incorrect password
- Consistent error codes across `auth status`, `doctor`, and credential reads
- Remediation always mentions `SUSTECH_MASTER_PASSWORD` when relevant

## 5. Official Period Mapping Documentation

The SUSTech period→clock mapping is now documented in `docs/ARCHITECTURE.md`:

| Period | Start | End | Duration |
|--------|--------|--------|----------|
| 1 | 08:00 | 08:50 | 50min |
| 2 | 09:00 | 09:50 | 50min |
| 3 | 10:20 | 11:10 | 50min |
| ... | ... | ... | ... |

### Benefits
- Single source of truth for humans and agents
- ISO timestamps use this mapping automatically
- Automatic handling of legacy vs current schedules

**Note**: When a specific week is queried, the CLI automatically combines this
mapping with the class date to produce full ISO-8601 timestamps. No manual
date arithmetic needed.

## Backward Compatibility

All changes are backward compatible:
- Period fields (`periodStart`, `periodEnd`) remain unchanged
- New fields (`startAt`, `endAt`, `rooms`) are optional and additive
- `startAt` / `endAt` are only added for week-specific personal schedule queries
- Catalog `schedule[]` slots continue to use period fields only
- Existing JSON consumers continue to work
- `--week` option still works alongside new `--date` option

## Testing

All 474 tests pass, including 6 new tests for:
- Schedule entry normalization (periods, rooms)
- ISO timestamp enrichment with full datetimes
- Multiple room parsing
- Single room behavior
- Missing period data handling
- Week filtering for timestamp enrichment
37 changes: 37 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ The CLI is organized around one rule: service logic returns typed values and
never prints. Commands turn those values into a `CommandResult`; the output
layer renders text, JSON, or JSONL.

## Teaching period to clock time mapping

SUSTech schedules courses by period number (节次). The CLI converts periods to
Asia/Shanghai clock times when day and period data are available.

**Current schedule** (effective 2026-09-07):

| Period | Start | End | Duration |
|--------|--------|--------|----------|
| 1 | 08:00 | 08:50 | 50min |
| 2 | 09:00 | 09:50 | 50min |
| 3 | 10:20 | 11:10 | 50min |
| 4 | 11:20 | 12:10 | 50min |
| 5 | 14:00 | 14:50 | 50min |
| 6 | 15:00 | 15:50 | 50min |
| 7 | 16:20 | 17:10 | 50min |
| 8 | 17:20 | 18:10 | 50min |
| 9 | 19:00 | 19:50 | 50min |
| 10 | 20:00 | 20:50 | 50min |
| 11 | 21:00 | 21:50 | 50min |

All periods are 50 minutes. When `tis schedule` queries a specific week
(via `--week`, `--date`, or current-week resolution), personal schedule entries
are enriched with full ISO-8601 timestamps: `startAt` / `endAt` fields in
Asia/Shanghai time (e.g. `2026-09-15T14:00:00+08:00`) that combine the class
date with period-based clock times. The existing `periodStart` / `periodEnd`
fields remain for compatibility.

Catalog `schedule[]` slots span many weeks and lack a concrete date, so they
retain period fields only without `startAt` / `endAt`. Agents answering
"where is class this afternoon?" can use the ISO timestamps from personal
schedule queries without reassembling date + clock themselves.

Legacy schedules (pre-2026-09-07) used different afternoon/evening times and
additional periods 12-13; the CLI recognizes dates and selects the correct
mapping automatically.

```text
command parser
Expand Down
14 changes: 11 additions & 3 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,17 @@ profile metadata or assume the password expired merely because the collection
is locked.

For the encrypted-file backend, decryption failures indicate an incorrect
master password. The backend does not impose a retry limit or lockout; protect
the master password accordingly. Each encrypted credential entry uses a unique
salt and initialization vector to prevent cross-entry attacks.
master password and produce `MASTER_PASSWORD_INVALID`. Missing master passwords
produce `MASTER_PASSWORD_REQUIRED` with remediation mentioning
`SUSTECH_MASTER_PASSWORD` or interactive unlock. The backend does not impose a
retry limit or lockout; protect the master password accordingly. Each encrypted
credential entry uses a unique salt and initialization vector to prevent
cross-entry attacks.

`auth status`, `doctor`, and credential read paths now consistently distinguish:
backend available vs profile metadata present vs secret unlockable vs remote
auth OK. When the linux-encrypted-file backend is active, missing or incorrect
master passwords fail with stable error codes rather than generic store errors.

## Profiles

Expand Down
64 changes: 54 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
import { parseSemester, type Semester } from "./core/semester.js";
import { CLI_VERSION } from "./core/version.js";
import { checkForUpdate, installLatest, shouldAutomaticallyCheck } from "./core/update.js";
import { AcademicCalendar, CalendarClient } from "./calendar/client.js";
import { AcademicCalendar, CalendarClient, CalendarTerm } from "./calendar/client.js";
import { formatCalendarDay, formatCalendarTerms } from "./calendar/text.js";
import type { CalendarLevel } from "./calendar/types.js";
import {
Expand Down Expand Up @@ -548,7 +548,7 @@ Usage:
sustech tis courses available [KEYWORD] --round ROUND [--semester YYYY-YYYY-N] [--limit N]
sustech tis courses detail CODE [--rwh RWH] [--round ROUND] [--semester YYYY-YYYY-N]
sustech tis enrolled [--semester YYYY-YYYY-N]
sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--all]
sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--date YYYY-MM-DD|--all]
sustech tis grades [--semester YYYY-YYYY-N]
sustech tis exams
sustech tis timetable CODE... [--semester YYYY-YYYY-N] [--block MON:1-4] [--max N] [--refresh]
Expand Down Expand Up @@ -1044,22 +1044,66 @@ async function main(argv: string[]): Promise<void> {
}
if (command === "schedule" && operation === undefined) {
if (values.all && values.week !== undefined) throw usageError("Choose either --week or --all, not both.");
if (values.all && values.date !== undefined) throw usageError("Choose either --date or --all, not both.");
if (values.date !== undefined && values.week !== undefined) throw usageError("Choose either --date or --week, not both.");
const semester = parseSemester(values.semester);
const client = await tisClient(values);
const week = values.all
? undefined
: values.week === undefined
? await client.currentWeek()
: parsePositiveInteger(values.week, 1, "--week");
let week: number | undefined;
let resolvedDate: string | undefined;
if (values.all) {
week = undefined;
} else if (values.date !== undefined) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
throw usageError("--date must be in YYYY-MM-DD format.");
}
resolvedDate = values.date;
const calendar = await new CalendarClient().loadYear(Number(semester.xn.split("-")[0]), "undergraduate");
const term = calendar.terms().find((t: CalendarTerm) => t.snapshot.semester.value === semester.value);
if (!term) {
throw new CliError(`Calendar term not found for semester ${semester.value}.`, "CALENDAR_TERM_NOT_FOUND", 2);
}
week = term.weekOf(resolvedDate);
if (week === 0) {
throw new CliError(`Date ${resolvedDate} is not within the teaching period of ${semester.value}.`, "DATE_OUT_OF_SEMESTER", 2);
}
} else if (values.week === undefined) {
week = await client.currentWeek();
} else {
week = parsePositiveInteger(values.week, 1, "--week");
}
if (week !== undefined && week > 36) throw usageError("--week must be between 1 and 36.");
const entries = await client.schedule(semester, week);
const data = { semester, ...(week !== undefined ? { week } : {}), entries, total: entries.length };
let entries = await client.schedule(semester, week);

if (week !== undefined) {
const calendar = await new CalendarClient().loadYear(Number(semester.xn.split("-")[0]), "undergraduate");
const term = calendar.terms().find((t: CalendarTerm) => t.snapshot.semester.value === semester.value);
if (term) {
const { enrichScheduleEntriesWithDatetimes } = await import("./tis/client.js");
entries = enrichScheduleEntriesWithDatetimes(entries, {
teachingStartDate: term.snapshot.teachingStart,
week,
});
}
}

const data = {
semester,
...(week !== undefined ? { week } : {}),
...(resolvedDate ? { date: resolvedDate } : {}),
entries,
total: entries.length,
};
writeSuccess({
command: "tis schedule",
data,
text: formatScheduleEntries(semester, entries, week),
items: entries,
summary: { semester: semester.value, ...(week !== undefined ? { week } : {}), total: entries.length },
summary: {
semester: semester.value,
...(week !== undefined ? { week } : {}),
...(resolvedDate ? { date: resolvedDate } : {}),
total: entries.length,
},
}, output);
return;
}
Expand Down
8 changes: 7 additions & 1 deletion src/core/encrypted-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { constants } from "node:fs";
import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { CliError } from "./errors.js";

const pbkdf2Async = promisify(pbkdf2);

Expand Down Expand Up @@ -77,7 +78,12 @@ export class EncryptedStore {
if (error && typeof error === "object" && "message" in error) {
const message = String(error.message);
if (/Unsupported state|bad decrypt/i.test(message)) {
throw new Error("Encrypted store decryption failed; the master password may be incorrect.");
throw new CliError(
"Encrypted store decryption failed; the master password may be incorrect.",
"MASTER_PASSWORD_INVALID",
2,
{ backend: "linux-encrypted-file" },
);
}
}
throw error;
Expand Down
Loading
Loading