Skip to content

Commit 8c79e20

Browse files
RyanLee-Devclaude
andcommitted
Refactor distribution: remove zod, centralize config I/O, add self-update
- Remove zod dependency (-200KB bundle); replace with 20-line manual validation - Centralize config read/write into readConfigFile/writeConfigFile, eliminating yaml parse/stringify duplication across 5 files - Add src/update/checker.ts: background update check, 24h throttle, fire-and-forget - Add src/update/self-update.ts: SHA256-verified atomic binary replacement - Add `minimax update [stable|latest|VERSION]` command - Startup update notification (prints to stderr after command, respects --quiet) - Rewrite install.sh: SHA256 verification, Rosetta2 detection, musl detection, curl/wget fallback, channel param, defaults to ~/.local/bin (no sudo) - build.ts: proper bun musl targets, Windows target, generates manifest.json with per-platform SHA256 checksums - release.yml: test job gates release job; uploads manifest.json alongside binaries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0a051ba commit 8c79e20

16 files changed

Lines changed: 573 additions & 163 deletions

File tree

.github/workflows/release.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,20 @@ on:
44
tags: ['v*']
55

66
jobs:
7-
build:
7+
test:
88
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
- uses: oven-sh/setup-bun@v2
12+
- run: bun install
13+
- run: bun run typecheck
14+
- run: bun test
15+
16+
release:
17+
needs: test
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write
921
steps:
1022
- uses: actions/checkout@v4
1123
- uses: oven-sh/setup-bun@v2
@@ -17,5 +29,7 @@ jobs:
1729
- name: Create GitHub Release
1830
uses: softprops/action-gh-release@v2
1931
with:
20-
files: dist/minimax-*
32+
files: |
33+
dist/minimax-*
34+
dist/manifest.json
2135
generate_release_notes: true

build.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
11
import { $ } from 'bun';
2+
import { createHash } from 'crypto';
3+
import { readFileSync, writeFileSync } from 'fs';
24

35
const VERSION = process.env.VERSION ?? 'dev';
46

57
const targets = [
6-
{ target: 'bun-linux-x64', output: 'minimax-linux-x64' },
7-
{ target: 'bun-linux-arm64', output: 'minimax-linux-arm64' },
8-
{ target: 'bun-darwin-x64', output: 'minimax-darwin-x64' },
9-
{ target: 'bun-darwin-arm64', output: 'minimax-darwin-arm64' },
10-
{ target: 'bun-windows-x64', output: 'minimax-windows-x64.exe' },
8+
{ bunTarget: 'bun-linux-x64', platform: 'linux-x64', output: 'minimax-linux-x64' },
9+
{ bunTarget: 'bun-linux-x64-musl', platform: 'linux-x64-musl', output: 'minimax-linux-x64-musl' },
10+
{ bunTarget: 'bun-linux-arm64', platform: 'linux-arm64', output: 'minimax-linux-arm64' },
11+
{ bunTarget: 'bun-linux-arm64-musl', platform: 'linux-arm64-musl', output: 'minimax-linux-arm64-musl' },
12+
{ bunTarget: 'bun-darwin-x64', platform: 'darwin-x64', output: 'minimax-darwin-x64' },
13+
{ bunTarget: 'bun-darwin-arm64', platform: 'darwin-arm64', output: 'minimax-darwin-arm64' },
14+
{ bunTarget: 'bun-windows-x64', platform: 'windows-x64', output: 'minimax-windows-x64.exe' },
1115
];
1216

17+
function sha256(path: string): string {
18+
return createHash('sha256').update(readFileSync(path)).digest('hex');
19+
}
20+
1321
console.log(`Building minimax-cli ${VERSION}...\n`);
1422

15-
for (const { target, output } of targets) {
16-
console.log(` Building ${output}...`);
23+
const manifest: {
24+
version: string;
25+
platforms: Record<string, { checksum: string }>;
26+
} = { version: VERSION, platforms: {} };
27+
28+
for (const { bunTarget, platform, output } of targets) {
29+
const outPath = `dist/${output}`;
30+
process.stdout.write(` ${output}...`);
1731
await $`bun build src/main.ts \
1832
--compile \
19-
--target ${target} \
20-
--outfile dist/${output} \
33+
--target ${bunTarget} \
34+
--outfile ${outPath} \
2135
--define "process.env.CLI_VERSION='${VERSION}'"`.quiet();
22-
console.log(` ✓ dist/${output}`);
36+
manifest.platforms[platform] = { checksum: sha256(outPath) };
37+
console.log(' ✓');
2338
}
2439

25-
console.log('\nDone. Binaries are in dist/');
40+
writeFileSync('dist/manifest.json', JSON.stringify(manifest, null, 2));
41+
console.log(' manifest.json ✓');
42+
console.log(`\nDone. ${targets.length} binaries in dist/`);

install.sh

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,129 @@
11
#!/bin/sh
22
set -e
33

4+
# Usage: install.sh [stable|latest|VERSION]
5+
CHANNEL="${1:-stable}"
6+
7+
case "$CHANNEL" in
8+
stable|latest) ;;
9+
v*|[0-9]*) ;;
10+
*)
11+
echo "Usage: $0 [stable|latest|VERSION]" >&2
12+
exit 1
13+
;;
14+
esac
15+
416
REPO="MiniMax-AI-Dev/minimax-cli"
5-
INSTALL_DIR="${MINIMAX_INSTALL_DIR:-/usr/local/bin}"
6-
7-
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
8-
ARCH=$(uname -m)
9-
case "$ARCH" in
10-
x86_64) ARCH="x64" ;;
11-
aarch64|arm64) ARCH="arm64" ;;
12-
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
17+
INSTALL_DIR="${MINIMAX_INSTALL_DIR:-$HOME/.local/bin}"
18+
19+
# Dependency check: curl or wget
20+
if command -v curl >/dev/null 2>&1; then
21+
download() { curl -fsSL "$1"; }
22+
download_to() { curl -fsSL -o "$2" "$1"; }
23+
elif command -v wget >/dev/null 2>&1; then
24+
download() { wget -qO- "$1"; }
25+
download_to() { wget -qO "$2" "$1"; }
26+
else
27+
echo "curl or wget is required." >&2; exit 1
28+
fi
29+
30+
# Detect OS
31+
case "$(uname -s)" in
32+
Darwin) OS="darwin" ;;
33+
Linux) OS="linux" ;;
34+
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
35+
esac
36+
37+
# Detect architecture
38+
case "$(uname -m)" in
39+
x86_64|amd64) ARCH="x64" ;;
40+
arm64|aarch64) ARCH="arm64" ;;
41+
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
42+
esac
43+
44+
# Rosetta 2: x64 shell on ARM Mac → use native arm64 binary
45+
if [ "$OS" = "darwin" ] && [ "$ARCH" = "x64" ]; then
46+
if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then
47+
ARCH="arm64"
48+
fi
49+
fi
50+
51+
# musl detection on Linux
52+
PLATFORM="${OS}-${ARCH}"
53+
if [ "$OS" = "linux" ]; then
54+
if [ -f /lib/libc.musl-x86_64.so.1 ] || \
55+
[ -f /lib/libc.musl-aarch64.so.1 ] || \
56+
ldd /bin/ls 2>&1 | grep -q musl; then
57+
PLATFORM="${OS}-${ARCH}-musl"
58+
fi
59+
fi
60+
61+
# Resolve version from channel
62+
GH_API="https://api.github.com/repos/${REPO}"
63+
case "$CHANNEL" in
64+
stable)
65+
VERSION=$(download "${GH_API}/releases/latest" \
66+
| grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
67+
;;
68+
latest)
69+
VERSION=$(download "${GH_API}/releases?per_page=1" \
70+
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
71+
;;
72+
*)
73+
case "$CHANNEL" in v*) VERSION="$CHANNEL" ;; *) VERSION="v${CHANNEL}" ;; esac
74+
;;
1375
esac
1476

15-
BINARY="minimax-${OS}-${ARCH}"
16-
URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
77+
if [ -z "$VERSION" ]; then
78+
echo "Failed to resolve version." >&2; exit 1
79+
fi
1780

18-
echo "Downloading ${BINARY}..."
19-
curl -fsSL "$URL" -o "${INSTALL_DIR}/minimax"
20-
chmod +x "${INSTALL_DIR}/minimax"
21-
echo "Installed minimax to ${INSTALL_DIR}/minimax"
22-
"${INSTALL_DIR}/minimax" --version
81+
echo "Installing minimax ${VERSION} for ${PLATFORM}..."
82+
83+
# Fetch manifest and extract SHA256 (pure sh, no jq required)
84+
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
85+
MANIFEST=$(download "${BASE_URL}/manifest.json") || {
86+
echo "Failed to fetch manifest.json" >&2; exit 1
87+
}
88+
CHECKSUM=$(printf '%s' "$MANIFEST" | tr -d '\n' | \
89+
sed "s/.*\"${PLATFORM}\"[^}]*\"checksum\" *: *\"\([a-f0-9]*\)\".*/\1/")
90+
91+
if [ -z "$CHECKSUM" ] || [ "${#CHECKSUM}" -ne 64 ]; then
92+
echo "Platform '${PLATFORM}' not found in manifest." >&2; exit 1
93+
fi
94+
95+
# Download binary to temp file
96+
TMP=$(mktemp)
97+
trap 'rm -f "$TMP"' EXIT
98+
99+
download_to "${BASE_URL}/minimax-${PLATFORM}" "$TMP" || {
100+
echo "Download failed." >&2; exit 1
101+
}
102+
103+
# Verify SHA256
104+
if command -v shasum >/dev/null 2>&1; then
105+
ACTUAL=$(shasum -a 256 "$TMP" | cut -d' ' -f1)
106+
elif command -v sha256sum >/dev/null 2>&1; then
107+
ACTUAL=$(sha256sum "$TMP" | cut -d' ' -f1)
108+
else
109+
echo "shasum or sha256sum is required." >&2; exit 1
110+
fi
111+
112+
if [ "$ACTUAL" != "$CHECKSUM" ]; then
113+
echo "Checksum verification failed." >&2; exit 1
114+
fi
115+
116+
chmod +x "$TMP"
117+
mkdir -p "$INSTALL_DIR"
118+
mv "$TMP" "${INSTALL_DIR}/minimax"
119+
120+
echo "Installed minimax ${VERSION} to ${INSTALL_DIR}/minimax"
121+
122+
# Warn if install dir is not in PATH
123+
case ":${PATH}:" in
124+
*":${INSTALL_DIR}:"*) ;;
125+
*)
126+
printf '\nNote: %s is not in PATH. Add to your shell profile:\n' "$INSTALL_DIR"
127+
printf ' export PATH="%s:$PATH"\n\n' "$INSTALL_DIR"
128+
;;
129+
esac

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
"prepublishOnly": "bun run build:npm"
2222
},
2323
"dependencies": {
24-
"yaml": "^2.7.1",
25-
"zod": "^3.24.4"
24+
"yaml": "^2.7.1"
2625
},
2726
"devDependencies": {
2827
"typescript": "^5.8.3",

src/commands/auth/login.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ import { startBrowserFlow, startDeviceCodeFlow } from '../../auth/oauth';
66
import { requestJson } from '../../client/http';
77
import { quotaEndpoint } from '../../client/endpoints';
88
import { formatOutput } from '../../output/formatter';
9-
import { ensureConfigDir, getConfigPath } from '../../config/paths';
9+
import { getConfigPath } from '../../config/paths';
10+
import { readConfigFile, writeConfigFile } from '../../config/loader';
1011
import type { Config } from '../../config/schema';
1112
import type { GlobalFlags } from '../../types/flags';
1213
import type { CredentialFile } from '../../auth/types';
1314
import type { QuotaResponse } from '../../types/api';
14-
import { readFileSync, writeFileSync, existsSync } from 'fs';
15-
import { parse as parseYaml, stringify as yamlStringify } from 'yaml';
1615

1716
export default defineCommand({
1817
name: 'auth login',
@@ -60,17 +59,10 @@ export default defineCommand({
6059
}
6160

6261
// Store key in config.yaml
63-
await ensureConfigDir();
64-
const configPath = getConfigPath();
65-
let existing: Record<string, unknown> = {};
66-
if (existsSync(configPath)) {
67-
try {
68-
existing = parseYaml(readFileSync(configPath, 'utf-8')) || {};
69-
} catch { /* ignore */ }
70-
}
62+
const existing = readConfigFile() as Record<string, unknown>;
7163
existing.api_key = key;
72-
writeFileSync(configPath, yamlStringify(existing), { mode: 0o600 });
73-
process.stderr.write(`API key saved to ${configPath}\n`);
64+
await writeConfigFile(existing);
65+
process.stderr.write(`API key saved to ${getConfigPath()}\n`);
7466
} else {
7567
console.log('Would validate and save API key.');
7668
}

src/commands/auth/logout.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { defineCommand } from '../../command';
22
import { clearCredentials, loadCredentials } from '../../auth/credentials';
3-
import { getConfigPath } from '../../config/paths';
3+
import { readConfigFile, writeConfigFile } from '../../config/loader';
44
import type { Config } from '../../config/schema';
55
import type { GlobalFlags } from '../../types/flags';
6-
import { existsSync, readFileSync, writeFileSync } from 'fs';
7-
import { parse as parseYaml, stringify as yamlStringify } from 'yaml';
86

97
export default defineCommand({
108
name: 'auth logout',
@@ -17,13 +15,8 @@ export default defineCommand({
1715
],
1816
async run(config: Config, flags: GlobalFlags) {
1917
const creds = await loadCredentials();
20-
const configPath = getConfigPath();
21-
const hasConfigKey = existsSync(configPath) && (() => {
22-
try {
23-
const parsed = parseYaml(readFileSync(configPath, 'utf-8'));
24-
return parsed?.api_key;
25-
} catch { return false; }
26-
})();
18+
const fileConfig = readConfigFile();
19+
const hasConfigKey = !!fileConfig.api_key;
2720

2821
if (config.dryRun) {
2922
if (creds) console.log('Would remove ~/.minimax/credentials.json');
@@ -40,10 +33,9 @@ export default defineCommand({
4033

4134
if (hasConfigKey) {
4235
try {
43-
const raw = readFileSync(configPath, 'utf-8');
44-
const parsed = parseYaml(raw) || {};
45-
delete parsed.api_key;
46-
writeFileSync(configPath, yamlStringify(parsed), { mode: 0o600 });
36+
const updated = fileConfig as Record<string, unknown>;
37+
delete updated.api_key;
38+
await writeConfigFile(updated);
4739
process.stderr.write('Cleared api_key from ~/.minimax/config.yaml\n');
4840
} catch { /* ignore */ }
4941
}

src/commands/config/set.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { defineCommand } from '../../command';
22
import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
4-
import { ensureConfigDir, getConfigPath } from '../../config/paths';
54
import { formatOutput, detectOutputFormat } from '../../output/formatter';
5+
import { readConfigFile, writeConfigFile } from '../../config/loader';
66
import type { Config } from '../../config/schema';
77
import type { GlobalFlags } from '../../types/flags';
8-
import { readFileSync, writeFileSync, existsSync } from 'fs';
9-
import { parse as parseYaml, stringify as yamlStringify } from 'yaml';
108

119
const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key'];
1210

@@ -74,24 +72,9 @@ export default defineCommand({
7472
return;
7573
}
7674

77-
await ensureConfigDir();
78-
const configPath = getConfigPath();
79-
80-
let existing: Record<string, unknown> = {};
81-
if (existsSync(configPath)) {
82-
try {
83-
existing = parseYaml(readFileSync(configPath, 'utf-8')) || {};
84-
} catch { /* ignore */ }
85-
}
86-
87-
// Convert numeric values
88-
if (key === 'timeout') {
89-
existing[key] = Number(value);
90-
} else {
91-
existing[key] = value;
92-
}
93-
94-
writeFileSync(configPath, yamlStringify(existing), { mode: 0o600 });
75+
const existing = readConfigFile() as Record<string, unknown>;
76+
existing[key] = key === 'timeout' ? Number(value) : value;
77+
await writeConfigFile(existing);
9578

9679
if (!config.quiet) {
9780
console.log(formatOutput({ [key]: existing[key] }, format));

src/commands/config/show.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineCommand } from '../../command';
2-
import { loadConfigFile } from '../../config/loader';
2+
import { readConfigFile as loadConfigFile } from '../../config/loader';
33
import { getConfigPath } from '../../config/paths';
44
import { formatOutput, detectOutputFormat } from '../../output/formatter';
55
import type { Config } from '../../config/schema';

0 commit comments

Comments
 (0)