diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 527ec3c..6574c84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,20 @@ on: tags: ['v*'] jobs: - build: + test: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run typecheck + - run: bun test + + release: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -17,5 +29,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: dist/minimax-* + files: | + dist/minimax-* + dist/manifest.json generate_release_notes: true diff --git a/build.ts b/build.ts index 5ce17bc..d2229d9 100644 --- a/build.ts +++ b/build.ts @@ -1,25 +1,42 @@ import { $ } from 'bun'; +import { createHash } from 'crypto'; +import { readFileSync, writeFileSync } from 'fs'; const VERSION = process.env.VERSION ?? 'dev'; const targets = [ - { target: 'bun-linux-x64', output: 'minimax-linux-x64' }, - { target: 'bun-linux-arm64', output: 'minimax-linux-arm64' }, - { target: 'bun-darwin-x64', output: 'minimax-darwin-x64' }, - { target: 'bun-darwin-arm64', output: 'minimax-darwin-arm64' }, - { target: 'bun-windows-x64', output: 'minimax-windows-x64.exe' }, + { bunTarget: 'bun-linux-x64', platform: 'linux-x64', output: 'minimax-linux-x64' }, + { bunTarget: 'bun-linux-x64-musl', platform: 'linux-x64-musl', output: 'minimax-linux-x64-musl' }, + { bunTarget: 'bun-linux-arm64', platform: 'linux-arm64', output: 'minimax-linux-arm64' }, + { bunTarget: 'bun-linux-arm64-musl', platform: 'linux-arm64-musl', output: 'minimax-linux-arm64-musl' }, + { bunTarget: 'bun-darwin-x64', platform: 'darwin-x64', output: 'minimax-darwin-x64' }, + { bunTarget: 'bun-darwin-arm64', platform: 'darwin-arm64', output: 'minimax-darwin-arm64' }, + { bunTarget: 'bun-windows-x64', platform: 'windows-x64', output: 'minimax-windows-x64.exe' }, ]; +function sha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + console.log(`Building minimax-cli ${VERSION}...\n`); -for (const { target, output } of targets) { - console.log(` Building ${output}...`); +const manifest: { + version: string; + platforms: Record; +} = { version: VERSION, platforms: {} }; + +for (const { bunTarget, platform, output } of targets) { + const outPath = `dist/${output}`; + process.stdout.write(` ${output}...`); await $`bun build src/main.ts \ --compile \ - --target ${target} \ - --outfile dist/${output} \ + --target ${bunTarget} \ + --outfile ${outPath} \ --define "process.env.CLI_VERSION='${VERSION}'"`.quiet(); - console.log(` ✓ dist/${output}`); + manifest.platforms[platform] = { checksum: sha256(outPath) }; + console.log(' ✓'); } -console.log('\nDone. Binaries are in dist/'); +writeFileSync('dist/manifest.json', JSON.stringify(manifest, null, 2)); +console.log(' manifest.json ✓'); +console.log(`\nDone. ${targets.length} binaries in dist/`); diff --git a/install.sh b/install.sh index 0155d88..b8a591e 100644 --- a/install.sh +++ b/install.sh @@ -1,22 +1,129 @@ #!/bin/sh set -e +# Usage: install.sh [stable|latest|VERSION] +CHANNEL="${1:-stable}" + +case "$CHANNEL" in + stable|latest) ;; + v*|[0-9]*) ;; + *) + echo "Usage: $0 [stable|latest|VERSION]" >&2 + exit 1 + ;; +esac + REPO="MiniMax-AI-Dev/minimax-cli" -INSTALL_DIR="${MINIMAX_INSTALL_DIR:-/usr/local/bin}" - -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH="x64" ;; - aarch64|arm64) ARCH="arm64" ;; - *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; +INSTALL_DIR="${MINIMAX_INSTALL_DIR:-$HOME/.local/bin}" + +# Dependency check: curl or wget +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL "$1"; } + download_to() { curl -fsSL -o "$2" "$1"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -qO- "$1"; } + download_to() { wget -qO "$2" "$1"; } +else + echo "curl or wget is required." >&2; exit 1 +fi + +# Detect OS +case "$(uname -s)" in + Darwin) OS="darwin" ;; + Linux) OS="linux" ;; + *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +# Detect architecture +case "$(uname -m)" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +# Rosetta 2: x64 shell on ARM Mac → use native arm64 binary +if [ "$OS" = "darwin" ] && [ "$ARCH" = "x64" ]; then + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then + ARCH="arm64" + fi +fi + +# musl detection on Linux +PLATFORM="${OS}-${ARCH}" +if [ "$OS" = "linux" ]; then + if [ -f /lib/libc.musl-x86_64.so.1 ] || \ + [ -f /lib/libc.musl-aarch64.so.1 ] || \ + ldd /bin/ls 2>&1 | grep -q musl; then + PLATFORM="${OS}-${ARCH}-musl" + fi +fi + +# Resolve version from channel +GH_API="https://api.github.com/repos/${REPO}" +case "$CHANNEL" in + stable) + VERSION=$(download "${GH_API}/releases/latest" \ + | grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + ;; + latest) + VERSION=$(download "${GH_API}/releases?per_page=1" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + ;; + *) + case "$CHANNEL" in v*) VERSION="$CHANNEL" ;; *) VERSION="v${CHANNEL}" ;; esac + ;; esac -BINARY="minimax-${OS}-${ARCH}" -URL="https://github.com/${REPO}/releases/latest/download/${BINARY}" +if [ -z "$VERSION" ]; then + echo "Failed to resolve version." >&2; exit 1 +fi -echo "Downloading ${BINARY}..." -curl -fsSL "$URL" -o "${INSTALL_DIR}/minimax" -chmod +x "${INSTALL_DIR}/minimax" -echo "Installed minimax to ${INSTALL_DIR}/minimax" -"${INSTALL_DIR}/minimax" --version +echo "Installing minimax ${VERSION} for ${PLATFORM}..." + +# Fetch manifest and extract SHA256 (pure sh, no jq required) +BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" +MANIFEST=$(download "${BASE_URL}/manifest.json") || { + echo "Failed to fetch manifest.json" >&2; exit 1 +} +CHECKSUM=$(printf '%s' "$MANIFEST" | tr -d '\n' | \ + sed "s/.*\"${PLATFORM}\"[^}]*\"checksum\" *: *\"\([a-f0-9]*\)\".*/\1/") + +if [ -z "$CHECKSUM" ] || [ "${#CHECKSUM}" -ne 64 ]; then + echo "Platform '${PLATFORM}' not found in manifest." >&2; exit 1 +fi + +# Download binary to temp file +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +download_to "${BASE_URL}/minimax-${PLATFORM}" "$TMP" || { + echo "Download failed." >&2; exit 1 +} + +# Verify SHA256 +if command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$TMP" | cut -d' ' -f1) +elif command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$TMP" | cut -d' ' -f1) +else + echo "shasum or sha256sum is required." >&2; exit 1 +fi + +if [ "$ACTUAL" != "$CHECKSUM" ]; then + echo "Checksum verification failed." >&2; exit 1 +fi + +chmod +x "$TMP" +mkdir -p "$INSTALL_DIR" +mv "$TMP" "${INSTALL_DIR}/minimax" + +echo "Installed minimax ${VERSION} to ${INSTALL_DIR}/minimax" + +# Warn if install dir is not in PATH +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + printf '\nNote: %s is not in PATH. Add to your shell profile:\n' "$INSTALL_DIR" + printf ' export PATH="%s:$PATH"\n\n' "$INSTALL_DIR" + ;; +esac diff --git a/package.json b/package.json index 9439e58..e9dd68c 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ "prepublishOnly": "bun run build:npm" }, "dependencies": { - "yaml": "^2.7.1", - "zod": "^3.24.4" + "yaml": "^2.7.1" }, "devDependencies": { "typescript": "^5.8.3", diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index ede5034..e52bef8 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -6,13 +6,12 @@ import { startBrowserFlow, startDeviceCodeFlow } from '../../auth/oauth'; import { requestJson } from '../../client/http'; import { quotaEndpoint } from '../../client/endpoints'; import { formatOutput } from '../../output/formatter'; -import { ensureConfigDir, getConfigPath } from '../../config/paths'; +import { getConfigPath } from '../../config/paths'; +import { readConfigFile, writeConfigFile } from '../../config/loader'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { CredentialFile } from '../../auth/types'; import type { QuotaResponse } from '../../types/api'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; export default defineCommand({ name: 'auth login', @@ -60,17 +59,10 @@ export default defineCommand({ } // Store key in config.yaml - await ensureConfigDir(); - const configPath = getConfigPath(); - let existing: Record = {}; - if (existsSync(configPath)) { - try { - existing = parseYaml(readFileSync(configPath, 'utf-8')) || {}; - } catch { /* ignore */ } - } + const existing = readConfigFile() as Record; existing.api_key = key; - writeFileSync(configPath, yamlStringify(existing), { mode: 0o600 }); - process.stderr.write(`API key saved to ${configPath}\n`); + await writeConfigFile(existing); + process.stderr.write(`API key saved to ${getConfigPath()}\n`); } else { console.log('Would validate and save API key.'); } diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 9b4f9ec..6c91c50 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,10 +1,8 @@ import { defineCommand } from '../../command'; import { clearCredentials, loadCredentials } from '../../auth/credentials'; -import { getConfigPath } from '../../config/paths'; +import { readConfigFile, writeConfigFile } from '../../config/loader'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; export default defineCommand({ name: 'auth logout', @@ -17,13 +15,8 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { const creds = await loadCredentials(); - const configPath = getConfigPath(); - const hasConfigKey = existsSync(configPath) && (() => { - try { - const parsed = parseYaml(readFileSync(configPath, 'utf-8')); - return parsed?.api_key; - } catch { return false; } - })(); + const fileConfig = readConfigFile(); + const hasConfigKey = !!fileConfig.api_key; if (config.dryRun) { if (creds) console.log('Would remove ~/.minimax/credentials.json'); @@ -40,10 +33,9 @@ export default defineCommand({ if (hasConfigKey) { try { - const raw = readFileSync(configPath, 'utf-8'); - const parsed = parseYaml(raw) || {}; - delete parsed.api_key; - writeFileSync(configPath, yamlStringify(parsed), { mode: 0o600 }); + const updated = fileConfig as Record; + delete updated.api_key; + await writeConfigFile(updated); process.stderr.write('Cleared api_key from ~/.minimax/config.yaml\n'); } catch { /* ignore */ } } diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 0f2e940..83deff9 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -1,12 +1,10 @@ import { defineCommand } from '../../command'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; -import { ensureConfigDir, getConfigPath } from '../../config/paths'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { readConfigFile, writeConfigFile } from '../../config/loader'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key']; @@ -74,24 +72,9 @@ export default defineCommand({ return; } - await ensureConfigDir(); - const configPath = getConfigPath(); - - let existing: Record = {}; - if (existsSync(configPath)) { - try { - existing = parseYaml(readFileSync(configPath, 'utf-8')) || {}; - } catch { /* ignore */ } - } - - // Convert numeric values - if (key === 'timeout') { - existing[key] = Number(value); - } else { - existing[key] = value; - } - - writeFileSync(configPath, yamlStringify(existing), { mode: 0o600 }); + const existing = readConfigFile() as Record; + existing[key] = key === 'timeout' ? Number(value) : value; + await writeConfigFile(existing); if (!config.quiet) { console.log(formatOutput({ [key]: existing[key] }, format)); diff --git a/src/commands/config/show.ts b/src/commands/config/show.ts index 8003683..6085581 100644 --- a/src/commands/config/show.ts +++ b/src/commands/config/show.ts @@ -1,5 +1,5 @@ import { defineCommand } from '../../command'; -import { loadConfigFile } from '../../config/loader'; +import { readConfigFile as loadConfigFile } from '../../config/loader'; import { getConfigPath } from '../../config/paths'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import type { Config } from '../../config/schema'; diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..527a305 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,57 @@ +import { defineCommand } from '../command'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; +import { resolveUpdateTarget, applySelfUpdate, type Channel } from '../update/self-update'; + +const CLI_VERSION = process.env.CLI_VERSION ?? '0.1.0'; + +export default defineCommand({ + name: 'update', + description: 'Update minimax to a newer version', + usage: 'minimax update [stable|latest|VERSION]', + options: [ + { flag: '[channel]', description: 'Target: stable (default), latest, or a version like 0.2.0' }, + ], + examples: [ + 'minimax update', + 'minimax update latest', + 'minimax update 0.2.0', + ], + async run(config, flags) { + // Detect current binary path + const currentBin = process.execPath; + if (currentBin.endsWith('bun') || currentBin.endsWith('node')) { + throw new CLIError( + 'Self-update is only supported for standalone binary installs.\n' + + 'For npm installs, run: npm update -g minimax-cli', + ExitCode.USAGE, + ); + } + + const rawChannel = (flags._positional as string[] | undefined)?.[0] ?? 'stable'; + const validChannels = new Set(['stable', 'latest']); + const channel: Channel = validChannels.has(rawChannel) || /^\d/.test(rawChannel) || rawChannel.startsWith('v') + ? rawChannel + : (() => { throw new CLIError(`Unknown channel: ${rawChannel}`, ExitCode.USAGE); })(); + + process.stderr.write(`Checking for updates (channel: ${channel})...\n`); + const target = await resolveUpdateTarget(channel); + + if (target.version === `v${CLI_VERSION}`) { + process.stderr.write(`Already up to date (${CLI_VERSION}).\n`); + return; + } + + process.stderr.write(`Update available: v${CLI_VERSION} → ${target.version}\n`); + + if (config.dryRun) { + process.stderr.write(`Would replace: ${currentBin}\n`); + return; + } + + await applySelfUpdate(target, currentBin); + + process.stderr.write(`\nUpdated to ${target.version}.\n`); + process.stderr.write(`https://github.com/MiniMax-AI-Dev/minimax-cli/releases/tag/${target.version}\n`); + }, +}); diff --git a/src/config/detect-region.ts b/src/config/detect-region.ts index 1417c75..e41e928 100644 --- a/src/config/detect-region.ts +++ b/src/config/detect-region.ts @@ -1,24 +1,16 @@ import { REGIONS, type Region } from './schema'; -import { ensureConfigDir, getConfigPath } from './paths'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; +import { readConfigFile, writeConfigFile } from './loader'; const QUOTA_PATH = '/v1/api/openplatform/coding_plan/remains'; function quotaUrl(region: Region): string { - const apiHost = REGIONS[region]; - // Quota endpoint uses www subdomain - const wwwHost = apiHost.replace('://api.', '://www.'); - return `${wwwHost}${QUOTA_PATH}`; + return REGIONS[region].replace('://api.', '://www.') + QUOTA_PATH; } async function probeRegion(region: Region, apiKey: string, timeoutMs: number): Promise { try { const res = await fetch(quotaUrl(region), { - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return false; @@ -29,44 +21,20 @@ async function probeRegion(region: Region, apiKey: string, timeoutMs: number): P } } -/** - * Probes both region endpoints in parallel to detect which region an API key belongs to. - * Returns the detected region, or 'global' as fallback. - */ export async function detectRegion(apiKey: string): Promise { process.stderr.write('Detecting region...'); - - const timeout = 5000; const regions = Object.keys(REGIONS) as Region[]; - const results = await Promise.all( - regions.map(async (r) => ({ region: r, ok: await probeRegion(r, apiKey, timeout) })), + regions.map(async (r) => ({ region: r, ok: await probeRegion(r, apiKey, 5000) })), ); - - const match = results.find((r) => r.ok); - const detected: Region = match?.region ?? 'global'; - + const detected: Region = results.find((r) => r.ok)?.region ?? 'global'; process.stderr.write(` ${detected}\n`); return detected; } -/** - * Saves the detected region and key fingerprint to ~/.minimax/config.yaml. - */ export async function saveDetectedRegion(region: Region, keyFingerprint?: string): Promise { - await ensureConfigDir(); - const configPath = getConfigPath(); - - let existing: Record = {}; - if (existsSync(configPath)) { - try { - existing = parseYaml(readFileSync(configPath, 'utf-8')) || {}; - } catch { /* ignore */ } - } - + const existing = readConfigFile() as Record; existing.region = region; - if (keyFingerprint) { - existing.region_key_fingerprint = keyFingerprint; - } - writeFileSync(configPath, yamlStringify(existing), { mode: 0o600 }); + if (keyFingerprint) existing.region_key_fingerprint = keyFingerprint; + await writeConfigFile(existing); } diff --git a/src/config/loader.ts b/src/config/loader.ts index cc0f9d0..0b4c364 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,46 +1,42 @@ -import { readFileSync, existsSync } from 'fs'; -import { parse as parseYaml } from 'yaml'; -import { ConfigSchema, REGIONS, type Config, type ConfigFile, type Region } from './schema'; -import { getConfigPath } from './paths'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { parse as parseYaml, stringify as yamlStringify } from 'yaml'; +import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema'; +import { ensureConfigDir, getConfigPath } from './paths'; import { detectOutputFormat, type OutputFormat } from '../output/formatter'; import type { GlobalFlags } from '../types/flags'; -export function loadConfigFile(): Partial { +export function readConfigFile(): ConfigFile { const path = getConfigPath(); if (!existsSync(path)) return {}; - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = parseYaml(raw); - if (!parsed || typeof parsed !== 'object') return {}; - return ConfigSchema.partial().parse(parsed); + return parseConfigFile(parseYaml(readFileSync(path, 'utf-8'))); } catch { return {}; } } +export async function writeConfigFile(data: Record): Promise { + await ensureConfigDir(); + writeFileSync(getConfigPath(), yamlStringify(data), { mode: 0o600 }); +} + export function loadConfig(flags: GlobalFlags): Config { - const file = loadConfigFile(); + const file = readConfigFile(); const apiKey = flags.apiKey || undefined; const envApiKey = process.env.MINIMAX_API_KEY || undefined; const fileApiKey = file.api_key; - const explicitRegion = (flags.region as string) - || process.env.MINIMAX_REGION - || undefined; - + const explicitRegion = (flags.region as string) || process.env.MINIMAX_REGION || undefined; const cachedRegion = file.region; const region = (explicitRegion || cachedRegion || 'global') as Region; // Re-detect if: no explicit region AND (no cached region OR key fingerprint changed) - // Precedence must match resolver: flag > config file > env var (OAuth skipped — no stable fingerprint) const activeKey = apiKey || fileApiKey || envApiKey; const keyFingerprint = activeKey ? activeKey.slice(0, 8) : undefined; const needsRegionDetection = !explicitRegion && (!cachedRegion || (keyFingerprint !== undefined && keyFingerprint !== file.region_key_fingerprint)); - // Explicit --base-url overrides region-derived URL const baseUrl = flags.baseUrl || process.env.MINIMAX_BASE_URL || file.base_url @@ -56,12 +52,6 @@ export function loadConfig(flags: GlobalFlags): Config { ?? file.timeout ?? 300; - const verbose = flags.verbose || process.env.MINIMAX_VERBOSE === '1'; - const quiet = flags.quiet || false; - const noColor = flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY; - const yes = flags.yes || false; - const dryRun = flags.dryRun || false; - return { apiKey, envApiKey, @@ -70,11 +60,11 @@ export function loadConfig(flags: GlobalFlags): Config { baseUrl, output, timeout, - verbose, - quiet, - noColor, - yes, - dryRun, + verbose: flags.verbose || process.env.MINIMAX_VERBOSE === '1', + quiet: flags.quiet || false, + noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY, + yes: flags.yes || false, + dryRun: flags.dryRun || false, needsRegionDetection, }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 68c5e59..2c33f4c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,5 +1,3 @@ -import { z } from 'zod'; - export const REGIONS = { global: 'https://api.minimax.io', cn: 'https://api.minimaxi.com', @@ -7,16 +5,32 @@ export const REGIONS = { export type Region = keyof typeof REGIONS; -export const ConfigSchema = z.object({ - api_key: z.string().optional(), - region: z.enum(['global', 'cn']).default('global'), - region_key_fingerprint: z.string().optional(), - base_url: z.string().url().optional(), - output: z.enum(['text', 'json', 'yaml']).default('text'), - timeout: z.number().positive().default(300), -}); +export interface ConfigFile { + api_key?: string; + region?: Region; + region_key_fingerprint?: string; + base_url?: string; + output?: 'text' | 'json' | 'yaml'; + timeout?: number; +} + +const VALID_REGIONS = new Set(['global', 'cn']); +const VALID_OUTPUTS = new Set(['text', 'json', 'yaml']); -export type ConfigFile = z.infer; +export function parseConfigFile(raw: unknown): ConfigFile { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const obj = raw as Record; + const out: ConfigFile = {}; + + if (typeof obj.api_key === 'string') out.api_key = obj.api_key; + if (typeof obj.region === 'string' && VALID_REGIONS.has(obj.region)) out.region = obj.region as Region; + if (typeof obj.region_key_fingerprint === 'string') out.region_key_fingerprint = obj.region_key_fingerprint; + if (typeof obj.base_url === 'string' && obj.base_url.startsWith('http')) out.base_url = obj.base_url; + if (typeof obj.output === 'string' && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile['output']; + if (typeof obj.timeout === 'number' && obj.timeout > 0) out.timeout = obj.timeout; + + return out; +} export interface Config { apiKey?: string; diff --git a/src/main.ts b/src/main.ts index e5f3e26..11f9e02 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,9 @@ import { parseArgs } from './args'; import { registry } from './registry'; import { handleError } from './errors/handler'; import { loadConfig } from './config/loader'; -import { resolveCredential } from './auth/resolver'; -import { formatOutput } from './output/formatter'; import { detectRegion, saveDetectedRegion } from './config/detect-region'; import { REGIONS } from './config/schema'; +import { checkForUpdate, getPendingUpdateNotification } from './update/checker'; const CLI_VERSION = process.env.CLI_VERSION ?? '0.1.0'; @@ -24,7 +23,9 @@ async function main() { process.exit(0); } - const command = registry.resolve(commandPath); + const { command, extra } = registry.resolve(commandPath); + if (extra.length > 0) (flags as Record)._positional = extra; + const config = loadConfig(flags); // Auto-detect region when no explicit region is set and the API key has changed @@ -39,7 +40,18 @@ async function main() { } } + // Fire-and-forget update check (non-blocking) + const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {}); + await command.execute(config, flags); + + // After command finishes, flush the update check and notify if needed + await updateCheckPromise; + const newVersion = getPendingUpdateNotification(); + if (newVersion && !config.quiet) { + process.stderr.write(`\n Update available: v${CLI_VERSION} → ${newVersion}\n`); + process.stderr.write(` Run 'minimax update' to upgrade.\n\n`); + } } main().catch(handleError); diff --git a/src/registry.ts b/src/registry.ts index 54b9f09..05979dd 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -18,6 +18,7 @@ import visionDescribe from './commands/vision/describe'; import quotaShow from './commands/quota/show'; import configShow from './commands/config/show'; import configSet from './commands/config/set'; +import update from './commands/update'; interface CommandNode { command?: Command; @@ -45,7 +46,7 @@ class CommandRegistry { node.command = command; } - resolve(commandPath: string[]): Command { + resolve(commandPath: string[]): { command: Command; extra: string[] } { let node = this.root; const matched: string[] = []; @@ -57,7 +58,7 @@ class CommandRegistry { } if (node.command) { - return node.command; + return { command: node.command, extra: commandPath.slice(matched.length) }; } // If we matched some path but no command, show help for that group @@ -133,6 +134,7 @@ Resources: vision Image understanding (describe) quota Usage quotas (show) config CLI configuration (show, set) + update Update minimax to a newer version Global Flags: --api-key API key (overrides all other auth) @@ -207,4 +209,5 @@ export const registry = new CommandRegistry({ 'quota show': quotaShow, 'config show': configShow, 'config set': configSet, + 'update': update, }); diff --git a/src/update/checker.ts b/src/update/checker.ts new file mode 100644 index 0000000..75fab42 --- /dev/null +++ b/src/update/checker.ts @@ -0,0 +1,76 @@ +import { join } from 'path'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { getConfigDir } from '../config/paths'; + +const STATE_FILE = () => join(getConfigDir(), 'update-state.json'); +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const FETCH_TIMEOUT_MS = 3000; +const REPO = 'MiniMax-AI-Dev/minimax-cli'; + +interface UpdateState { + lastChecked: number; + latestVersion: string; +} + +function readState(): UpdateState | null { + try { + const raw = readFileSync(STATE_FILE(), 'utf-8'); + return JSON.parse(raw) as UpdateState; + } catch { + return null; + } +} + +function writeState(state: UpdateState): void { + try { + writeFileSync(STATE_FILE(), JSON.stringify(state)); + } catch { /* ignore */ } +} + +async function fetchLatestVersion(): Promise { + try { + const res = await fetch( + `https://api.github.com/repos/${REPO}/releases/latest`, + { + headers: { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }, + ); + if (!res.ok) return null; + const data = await res.json() as { tag_name?: string }; + return data.tag_name ?? null; + } catch { + return null; + } +} + +let pendingNotification: string | null = null; + +export function getPendingUpdateNotification(): string | null { + return pendingNotification; +} + +export async function checkForUpdate(currentVersion: string): Promise { + // Skip in CI / non-TTY environments + if (process.env.CI || !process.stderr.isTTY) return; + + const state = readState(); + const now = Date.now(); + + // Throttle: skip if checked within the last 24h + if (state && now - state.lastChecked < CHECK_INTERVAL_MS) { + if (state.latestVersion && state.latestVersion !== `v${currentVersion}`) { + pendingNotification = state.latestVersion; + } + return; + } + + const latest = await fetchLatestVersion(); + if (!latest) return; + + writeState({ lastChecked: now, latestVersion: latest }); + + if (latest !== `v${currentVersion}`) { + pendingNotification = latest; + } +} diff --git a/src/update/self-update.ts b/src/update/self-update.ts new file mode 100644 index 0000000..1a35d13 --- /dev/null +++ b/src/update/self-update.ts @@ -0,0 +1,186 @@ +import { createWriteStream, renameSync, chmodSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; + +const REPO = 'MiniMax-AI-Dev/minimax-cli'; +const GH_API = 'https://api.github.com'; + +interface ManifestPlatform { + checksum: string; + size?: number; +} + +interface Manifest { + version: string; + platforms: Record; +} + +export interface UpdateTarget { + version: string; // resolved tag, e.g. "v0.2.0" + downloadUrl: string; + checksum: string; +} + +export type Channel = 'stable' | 'latest' | string; // string = exact version tag + +function detectPlatform(): string { + const os = process.platform; + const arch = process.arch; + + const osMap: Record = { darwin: 'darwin', linux: 'linux' }; + const archMap: Record = { x64: 'x64', arm64: 'arm64' }; + + const mappedOs = osMap[os]; + const mappedArch = archMap[arch]; + + if (!mappedOs || !mappedArch) { + throw new CLIError(`Unsupported platform: ${os}/${arch}`, ExitCode.GENERAL); + } + + // Detect musl on Linux + if (mappedOs === 'linux') { + try { + const { execSync } = require('child_process') as typeof import('child_process'); + const ldd = execSync('ldd /bin/ls 2>&1', { encoding: 'utf-8' }); + if (ldd.includes('musl')) return `linux-${mappedArch}-musl`; + } catch { /* non-fatal */ } + } + + return `${mappedOs}-${mappedArch}`; +} + +async function ghFetch(path: string): Promise { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (process.env.GITHUB_TOKEN) headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`; + return fetch(`${GH_API}${path}`, { headers, signal: AbortSignal.timeout(10_000) }); +} + +async function resolveVersion(channel: Channel): Promise { + if (channel !== 'stable' && channel !== 'latest') { + // Exact version: normalise to tag format + return channel.startsWith('v') ? channel : `v${channel}`; + } + + // latest = most recent release including pre-releases + if (channel === 'latest') { + const res = await ghFetch(`/repos/${REPO}/releases?per_page=1`); + if (!res.ok) throw new CLIError('Failed to fetch releases from GitHub.', ExitCode.GENERAL); + const releases = await res.json() as Array<{ tag_name: string }>; + if (!releases.length) throw new CLIError('No releases found.', ExitCode.GENERAL); + return releases[0].tag_name; + } + + // stable = latest non-prerelease + const res = await ghFetch(`/repos/${REPO}/releases/latest`); + if (!res.ok) throw new CLIError('Failed to fetch latest release from GitHub.', ExitCode.GENERAL); + const release = await res.json() as { tag_name: string }; + return release.tag_name; +} + +async function fetchManifest(version: string): Promise { + const url = `https://github.com/${REPO}/releases/download/${version}/manifest.json`; + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new CLIError(`manifest.json not found for ${version}.`, ExitCode.GENERAL); + return res.json() as Promise; +} + +async function verifySha256(filePath: string, expected: string): Promise { + const { createHash } = await import('crypto'); + const { readFileSync } = await import('fs'); + const actual = createHash('sha256').update(readFileSync(filePath)).digest('hex'); + if (actual !== expected) { + throw new CLIError( + `Checksum mismatch.\n expected: ${expected}\n actual: ${actual}`, + ExitCode.GENERAL, + ); + } +} + +async function downloadFile(url: string, dest: string, onProgress?: (pct: number) => void): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new CLIError(`Download failed: ${res.status} ${res.statusText}`, ExitCode.GENERAL); + + const total = Number(res.headers.get('content-length') ?? 0); + let received = 0; + + const writer = createWriteStream(dest); + const reader = res.body!.getReader(); + + await new Promise((resolve, reject) => { + writer.on('error', reject); + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { writer.end(); break; } + writer.write(value); + received += value.length; + if (onProgress && total > 0) onProgress(Math.round(received / total * 100)); + } + resolve(); + } catch (e) { reject(e); } + }; + pump(); + }); +} + +export async function resolveUpdateTarget(channel: Channel): Promise { + const platform = detectPlatform(); + const version = await resolveVersion(channel); + const manifest = await fetchManifest(version); + + const entry = manifest.platforms[platform]; + if (!entry) { + throw new CLIError( + `Platform "${platform}" not found in manifest for ${version}.`, + ExitCode.GENERAL, + ); + } + + const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/minimax-${platform}`; + return { version, downloadUrl, checksum: entry.checksum }; +} + +export async function applySelfUpdate(target: UpdateTarget, currentBin: string): Promise { + const tmp = join(tmpdir(), `minimax-update-${Date.now()}`); + + process.stderr.write(`Downloading ${target.version}...\n`); + let lastPct = -1; + await downloadFile(target.downloadUrl, tmp, (pct) => { + if (pct !== lastPct && pct % 10 === 0) { + process.stderr.write(` ${pct}%\r`); + lastPct = pct; + } + }); + process.stderr.write(' \r'); + + process.stderr.write('Verifying checksum...\n'); + await verifySha256(tmp, target.checksum); + + chmodSync(tmp, 0o755); + + // Atomic replace: rename works on same filesystem + // If cross-device, fall back to copy+rename + try { + renameSync(tmp, currentBin); + } catch { + const { copyFileSync, unlinkSync } = await import('fs'); + const backup = `${currentBin}.bak`; + copyFileSync(currentBin, backup); + try { + copyFileSync(tmp, currentBin); + chmodSync(currentBin, 0o755); + unlinkSync(tmp); + if (existsSync(backup)) unlinkSync(backup); + } catch (e) { + // Restore backup + if (existsSync(backup)) renameSync(backup, currentBin); + throw e; + } + } +}