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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `sustech-cli` are documented in this file.

## [Unreleased]

### Added

- Check the official npm release once per day in interactive terminals and ask
before updating. Add `sustech update [--yes]` for an explicit check or
confirmed install, while keeping CI, pipes, JSON, and JSONL prompt-free.

## [0.12.1] - 2026-09-12

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ npm exec --package=sustech-cli -- sustech version
your shell `PATH`. Package developers can use `npm install --global .` or
`npm link` after building.

The CLI checks npm for a newer stable release at most once every 24 hours when
run in an interactive terminal. If one is available, it asks before updating.
JSON/JSONL output, redirected commands, and CI runs are never prompted. Use
`sustech update` to check immediately, `sustech update --yes` to install
without the confirmation prompt, or set `SUSTECH_DISABLE_UPDATE_CHECK=1` to
disable automatic checks.

## Quick start

Public data does not require an account:
Expand Down
44 changes: 43 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { realpathSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { dirname, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import {
Expand Down Expand Up @@ -68,11 +68,13 @@ import {
import {
promptHiddenPassword,
promptLoginSid,
promptYesNo,
readCalendarLinkFromStdin,
readPasswordFromStdin,
} from "./core/prompt.js";
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 { formatCalendarDay, formatCalendarTerms } from "./calendar/text.js";
import type { CalendarLevel } from "./calendar/types.js";
Expand Down Expand Up @@ -416,11 +418,13 @@ import {
import type { ExamRecord, PersonalScheduleEntry } from "./tis/types.js";

const VERSION = CLI_VERSION;
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));

const HELP = `sustech — SUSTech services for humans and agents

Usage:
sustech version [--json|--jsonl]
sustech update [--yes] [--json|--jsonl]
sustech capabilities [--json|--jsonl]
sustech describe COMMAND... [--json|--jsonl]
sustech consequences [OPERATION] [--json|--jsonl]
Expand Down Expand Up @@ -724,6 +728,7 @@ type Values = OutputFlags & {
"weight-gap-period"?: string;
"weight-distinct-weekday"?: string;
"weight-campus-switch"?: string;
yes?: boolean;
help?: boolean;
};

Expand All @@ -750,6 +755,21 @@ async function main(argv: string[]): Promise<void> {
process.stdout.write(HELP);
return;
}
if (shouldAutomaticallyCheck(argv)) {
const status = await checkForUpdate({ currentVersion: VERSION });
if (!status.cached && status.updateAvailable && status.latestVersion) {
const accepted = await promptYesNo(`A new sustech-cli version is available: ${VERSION} → ${status.latestVersion}. Update now?`);
if (accepted) {
try {
await installLatest(PACKAGE_ROOT);
process.stderr.write(`Updated sustech-cli to ${status.latestVersion}. Run the command again to use it.\n`);
return;
} catch (error) {
process.stderr.write(`Update failed: ${error instanceof Error ? error.message : String(error)}\nContinuing with sustech-cli ${VERSION}.\n`);
}
}
}
}
if (parsed.positionals.length === 0) {
const credentials = await getCredentialStatus(values.profile);
process.stdout.write(`${formatDashboard({
Expand All @@ -765,6 +785,28 @@ async function main(argv: string[]): Promise<void> {
const [group, command, operation] = parsed.positionals;
validateCommandOptions(inferCommandName(argv), argv);

if (group === "update" && command === undefined) {
const status = await checkForUpdate({ currentVersion: VERSION, force: true });
if (!status.latestVersion) {
throw new CliError("Could not check the latest npm release.", "UPDATE_CHECK_FAILED", 1);
}
let updated = false;
let method: "source" | "npm" | undefined;
const shouldInstall = status.updateAvailable && (values.yes || (output.mode === "text" && await promptYesNo(`Update sustech-cli ${VERSION} → ${status.latestVersion}?`)));
if (shouldInstall) {
method = await installLatest(PACKAGE_ROOT, output.mode === "text");
updated = true;
}
const data = { ...status, updated, ...(method ? { method } : {}) };
const text = !status.updateAvailable
? `sustech-cli ${VERSION} is up to date.`
: updated
? `Updated sustech-cli to ${status.latestVersion}. Run it again to use the new version.`
: `sustech-cli ${status.latestVersion} is available (current: ${VERSION}). Run \u0060sustech update --yes\u0060 to install it.`;
writeSuccess({ command: "update", data, text }, output);
return;
}

if (group === "version" && command === undefined) {
const data = { version: VERSION, runtime: `node ${process.version}` };
writeSuccess({
Expand Down
2 changes: 1 addition & 1 deletion src/core/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function inferCommandName(argv: string[]): string {

const [group, command] = positionals;
if (!group) return "unknown";
if (group === "version" || group === "capabilities" || group === "context" || group === "consequences" || group === "describe") return group;
if (group === "version" || group === "update" || group === "capabilities" || group === "context" || group === "consequences" || group === "describe") return group;
if (!command) return group;
if (
(group === "tis" && ["courses", "enroll", "classroom", "selection", "bid", "plan", "degree"].includes(command))
Expand Down
2 changes: 2 additions & 0 deletions src/core/command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const CLI_PARSE_OPTIONS = {
json: { type: "boolean", default: false },
jsonl: { type: "boolean", default: false },
pretty: { type: "boolean", default: false },
yes: { type: "boolean", short: "y", default: false },
help: { type: "boolean", short: "h", default: false },
} as const;

Expand All @@ -139,6 +140,7 @@ export type CliOptionName = keyof typeof CLI_PARSE_OPTIONS;
export const SHARED_OUTPUT_OPTION_NAMES = ["output", "json", "jsonl", "pretty"] as const;

export const COMMAND_OPTIONS: Readonly<Record<string, readonly CliOptionName[]>> = {
update: ["yes"],
describe: [],
"auth login": ["profile", "sid", "service", "password-stdin"],
"auth status": ["profile"],
Expand Down
10 changes: 10 additions & 0 deletions src/core/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export async function promptLoginSid(): Promise<string> {
}
}

export async function promptYesNo(question: string): Promise<boolean> {
requireInteractiveTerminal();
const readline = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
try {
return /^(y|yes)$/i.test((await readline.question(`${question} [y/N] `)).trim());
} finally {
readline.close();
}
}

export async function promptHiddenPassword(): Promise<string> {
requireInteractiveTerminal();
const output = new MutedOutput(process.stderr);
Expand Down
146 changes: 146 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { execFile, spawn } from "node:child_process";
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { defaultConfigDirectory, writeJsonAtomically } from "./local-store.js";

const execFileAsync = promisify(execFile);
const REGISTRY_URL = "https://registry.npmjs.org/sustech-cli/latest";
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1_000;

interface UpdateCache {
schemaVersion: "1";
checkedAt: string;
latestVersion?: string;
}

export interface UpdateStatus {
currentVersion: string;
latestVersion?: string;
updateAvailable: boolean;
checkedAt: string;
cached: boolean;
}

export interface CheckUpdateOptions {
currentVersion: string;
force?: boolean;
now?: Date;
configDirectory?: string;
fetchImpl?: typeof fetch;
}

export function isNewerVersion(candidate: string, current: string): boolean {
const candidateParts = parseStableVersion(candidate);
const currentParts = parseStableVersion(current);
if (!candidateParts || !currentParts) return false;
for (let index = 0; index < 3; index += 1) {
if (candidateParts[index] !== currentParts[index]) return candidateParts[index] > currentParts[index];
}
return false;
}

export function shouldAutomaticallyCheck(argv: string[], env: NodeJS.ProcessEnv = process.env): boolean {
if (!process.stdin.isTTY || !process.stderr.isTTY) return false;
if (env.CI || env.SUSTECH_DISABLE_UPDATE_CHECK === "1") return false;
if (argv.some((argument) => argument === "--json" || argument === "--jsonl" || /^--output=(json|jsonl)$/.test(argument))) return false;
const outputIndex = argv.indexOf("--output");
if (outputIndex >= 0 && ["json", "jsonl"].includes(argv[outputIndex + 1] ?? "")) return false;
const command = argv.find((argument) => !argument.startsWith("-"));
return command !== "update" && command !== "version" && !argv.includes("--help") && !argv.includes("-h");
}

export async function checkForUpdate(options: CheckUpdateOptions): Promise<UpdateStatus> {
const now = options.now ?? new Date();
const cachePath = join(options.configDirectory ?? defaultConfigDirectory(), "update-check.json");
if (!options.force) {
const cached = await readCache(cachePath);
if (cached && now.getTime() - Date.parse(cached.checkedAt) < UPDATE_CHECK_INTERVAL_MS) {
return statusFromCache(options.currentVersion, cached, true);
}
}

let latestVersion: string | undefined;
try {
const response = await (options.fetchImpl ?? fetch)(REGISTRY_URL, {
headers: { accept: "application/json", "user-agent": `sustech-cli/${options.currentVersion}` },
signal: AbortSignal.timeout(3_000),
});
if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}.`);
const body = await response.json() as { version?: unknown };
if (typeof body.version === "string" && parseStableVersion(body.version)) latestVersion = body.version;
} catch {
// Update checks must never make the requested CLI command fail.
}
const cache: UpdateCache = { schemaVersion: "1", checkedAt: now.toISOString(), ...(latestVersion ? { latestVersion } : {}) };
await writeJsonAtomically(cachePath, cache).catch(() => undefined);
return statusFromCache(options.currentVersion, cache, false);
}

export async function installLatest(packageRoot: string, showInstallerOutput = true): Promise<"source" | "npm"> {
if (await exists(join(packageRoot, ".git"))) {
const branch = (await runCaptured("git", ["-C", packageRoot, "branch", "--show-current"])).trim();
const dirty = (await runCaptured("git", ["-C", packageRoot, "status", "--porcelain"])).trim();
if (branch !== "main" || dirty) {
throw new Error("Source checkout must be on a clean main branch before it can update itself.");
}
await runCommand("git", ["-C", packageRoot, "pull", "--ff-only"], showInstallerOutput);
await runCommand(npmExecutable(), ["--prefix", packageRoot, "ci"], showInstallerOutput);
return "source";
}
await runCommand(npmExecutable(), ["install", "--global", "sustech-cli@latest"], showInstallerOutput);
return "npm";
}

function parseStableVersion(value: string): [number, number, number] | undefined {
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
}

function statusFromCache(currentVersion: string, cache: UpdateCache, cached: boolean): UpdateStatus {
return {
currentVersion,
...(cache.latestVersion ? { latestVersion: cache.latestVersion } : {}),
updateAvailable: cache.latestVersion ? isNewerVersion(cache.latestVersion, currentVersion) : false,
checkedAt: cache.checkedAt,
cached,
};
}

async function readCache(path: string): Promise<UpdateCache | undefined> {
try {
const value = JSON.parse(await readFile(path, "utf8")) as Partial<UpdateCache>;
if (value.schemaVersion === "1" && typeof value.checkedAt === "string" && Number.isFinite(Date.parse(value.checkedAt))) {
return { schemaVersion: "1", checkedAt: value.checkedAt, ...(typeof value.latestVersion === "string" ? { latestVersion: value.latestVersion } : {}) };
}
} catch {
// A missing or invalid cache simply causes a fresh check.
}
return undefined;
}

async function exists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch {
return false;
}
}

async function runCaptured(command: string, args: string[]): Promise<string> {
const result = await execFileAsync(command, args, { encoding: "utf8" });
return result.stdout;
}

async function runCommand(command: string, args: string[], showOutput: boolean): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: showOutput ? "inherit" : "ignore", shell: false });
child.once("error", reject);
child.once("exit", (code, signal) => code === 0 ? resolve() : reject(new Error(`${command} failed${signal ? ` with ${signal}` : ` with exit code ${code ?? "unknown"}`}.`)));
});
}

function npmExecutable(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
84 changes: 84 additions & 0 deletions src/test/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { checkForUpdate, isNewerVersion, UPDATE_CHECK_INTERVAL_MS } from "../core/update.js";

test("stable release versions compare numerically", () => {
assert.equal(isNewerVersion("0.12.2", "0.12.1"), true);
assert.equal(isNewerVersion("0.13.0", "0.12.9"), true);
assert.equal(isNewerVersion("1.0.0", "0.99.99"), true);
assert.equal(isNewerVersion("0.12.1", "0.12.1"), false);
assert.equal(isNewerVersion("0.12.0", "0.12.1"), false);
assert.equal(isNewerVersion("0.13.0-beta.1", "0.12.1"), false);
});

test("automatic checks reuse the npm result for 24 hours", async () => {
const configDirectory = mkdtempSync(join(process.cwd(), ".tmp-sustech-cli-update-"));
let requests = 0;
const fetchImpl = async () => {
requests += 1;
return new Response(JSON.stringify({ version: "0.13.0" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};

try {
const first = await checkForUpdate({
currentVersion: "0.12.1",
configDirectory,
now: new Date("2026-09-14T00:00:00Z"),
fetchImpl,
});
const cached = await checkForUpdate({
currentVersion: "0.12.1",
configDirectory,
now: new Date("2026-09-14T12:00:00Z"),
fetchImpl,
});
const refreshed = await checkForUpdate({
currentVersion: "0.12.1",
configDirectory,
now: new Date(Date.parse("2026-09-14T00:00:00Z") + UPDATE_CHECK_INTERVAL_MS),
fetchImpl,
});

assert.equal(first.updateAvailable, true);
assert.equal(first.cached, false);
assert.equal(cached.cached, true);
assert.equal(refreshed.cached, false);
assert.equal(requests, 2);
} finally {
rmSync(configDirectory, { recursive: true, force: true });
}
});

test("a registry failure is quiet and is cached to avoid delaying every command", async () => {
const configDirectory = mkdtempSync(join(process.cwd(), ".tmp-sustech-cli-update-failure-"));
let requests = 0;
const fetchImpl = async () => {
requests += 1;
throw new Error("offline");
};

try {
const first = await checkForUpdate({
currentVersion: "0.12.1",
configDirectory,
now: new Date("2026-09-14T00:00:00Z"),
fetchImpl,
});
const cached = await checkForUpdate({
currentVersion: "0.12.1",
configDirectory,
now: new Date("2026-09-14T01:00:00Z"),
fetchImpl,
});
assert.equal(first.latestVersion, undefined);
assert.equal(cached.cached, true);
assert.equal(requests, 1);
} finally {
rmSync(configDirectory, { recursive: true, force: true });
}
});