diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c2359..8695e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7fae810..4e411ab 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/cli.ts b/src/cli.ts index 1a0be15..e46ba3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { @@ -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"; @@ -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] @@ -724,6 +728,7 @@ type Values = OutputFlags & { "weight-gap-period"?: string; "weight-distinct-weekday"?: string; "weight-campus-switch"?: string; + yes?: boolean; help?: boolean; }; @@ -750,6 +755,21 @@ async function main(argv: string[]): Promise { 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({ @@ -765,6 +785,28 @@ async function main(argv: string[]): Promise { 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({ diff --git a/src/core/argv.ts b/src/core/argv.ts index 6e57538..6e69436 100644 --- a/src/core/argv.ts +++ b/src/core/argv.ts @@ -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)) diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 77bbaab..5ce1be4 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -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; @@ -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> = { + update: ["yes"], describe: [], "auth login": ["profile", "sid", "service", "password-stdin"], "auth status": ["profile"], diff --git a/src/core/prompt.ts b/src/core/prompt.ts index 1d0929b..714a144 100644 --- a/src/core/prompt.ts +++ b/src/core/prompt.ts @@ -14,6 +14,16 @@ export async function promptLoginSid(): Promise { } } +export async function promptYesNo(question: string): Promise { + 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 { requireInteractiveTerminal(); const output = new MutedOutput(process.stderr); diff --git a/src/core/update.ts b/src/core/update.ts new file mode 100644 index 0000000..1798e8e --- /dev/null +++ b/src/core/update.ts @@ -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 { + 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 { + try { + const value = JSON.parse(await readFile(path, "utf8")) as Partial; + 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 { + try { + await lstat(path); + return true; + } catch { + return false; + } +} + +async function runCaptured(command: string, args: string[]): Promise { + const result = await execFileAsync(command, args, { encoding: "utf8" }); + return result.stdout; +} + +async function runCommand(command: string, args: string[], showOutput: boolean): Promise { + await new Promise((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"; +} diff --git a/src/test/update.test.ts b/src/test/update.test.ts new file mode 100644 index 0000000..f3bd5be --- /dev/null +++ b/src/test/update.test.ts @@ -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 }); + } +});