redminectl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Redmine CLI
2
+
3
+ AI Agent 友好的 Redmine 命令行工具。
4
+
5
+ ## 安装
6
+
7
+ ### 方式一:一键安装(推荐)
8
+
9
+ **macOS / Linux:**
10
+ ```bash
11
+ curl -fsSL https://raw.githubusercontent.com/largeoliu/redmine-cli/main/scripts/install.sh | sh
12
+ ```
13
+
14
+ **Windows (PowerShell):**
15
+ ```powershell
16
+ irm https://raw.githubusercontent.com/largeoliu/redmine-cli/main/scripts/install.ps1 | iex
17
+ ```
18
+
19
+ ### 方式二:npm 安装
20
+
21
+ ```bash
22
+ npm install -g rmcli
23
+ ```
24
+
25
+ ### 方式三:手动下载
26
+
27
+ 从 [GitHub Releases](https://github.com/largeoliu/redmine-cli/releases) 下载对应平台的预编译二进制。
28
+
29
+ ### 方式四:从源码构建
30
+
31
+ ```bash
32
+ go install github.com/largeoliu/redmine-cli@latest
33
+ ```
34
+
35
+ ## 快速开始
36
+
37
+ ```bash
38
+ # 登录
39
+ redmine login
40
+
41
+ # 列出 Issues
42
+ redmine issue list
43
+
44
+ # 创建 Issue
45
+ redmine issue create --project 1 --subject "Bug report"
46
+ ```
47
+
48
+ ## 文档
49
+
50
+ - [设计文档](docs/specs/2026-04-12-redmine-cli-design.md)
package/bin/redmine ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const binDir = path.join(__dirname, '..', 'bin');
8
+ const binaryName = os.platform() === 'win32' ? 'redmine.exe' : 'redmine';
9
+ const binaryPath = path.join(binDir, binaryName);
10
+
11
+ const child = spawn(binaryPath, process.argv.slice(2), {
12
+ stdio: 'inherit',
13
+ env: process.env
14
+ });
15
+
16
+ child.on('exit', (code) => {
17
+ process.exit(code || 0);
18
+ });
Binary file
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "redminectl",
3
+ "version": "0.1.0",
4
+ "description": "AI Agent friendly Redmine CLI tool",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "redmine": "./bin/redmine"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node scripts/download.js"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/largeoliu/redmine-cli.git"
15
+ },
16
+ "keywords": [
17
+ "redmine",
18
+ "cli",
19
+ "issue-tracker",
20
+ "project-management",
21
+ "ai-agent"
22
+ ],
23
+ "author": "largeoliu",
24
+ "license": "MIT",
25
+ "bugs": {
26
+ "url": "https://github.com/largeoliu/redmine-cli/issues"
27
+ },
28
+ "homepage": "https://github.com/largeoliu/redmine-cli#readme",
29
+ "engines": {
30
+ "node": ">=14"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "scripts"
35
+ ],
36
+ "dependencies": {
37
+ "axios": "^1.6.0",
38
+ "unzipper": "^0.10.14",
39
+ "tar": "^6.2.0"
40
+ }
41
+ }
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { pipeline } = require('stream');
8
+ const { promisify } = require('util');
9
+ const streamPipeline = promisify(pipeline);
10
+
11
+ const REPO = 'largeoliu/redmine-cli';
12
+ const BINARY_NAME = 'redmine';
13
+
14
+ function getPlatform() {
15
+ switch (process.platform) {
16
+ case 'win32': return 'windows';
17
+ case 'darwin': return 'darwin';
18
+ case 'linux': return 'linux';
19
+ default: throw new Error(`Unsupported platform: ${process.platform}`);
20
+ }
21
+ }
22
+
23
+ function getArch() {
24
+ switch (process.arch) {
25
+ case 'x64': return 'amd64';
26
+ case 'arm64': return 'arm64';
27
+ default: throw new Error(`Unsupported architecture: ${process.arch}`);
28
+ }
29
+ }
30
+
31
+ async function fetchJson(url) {
32
+ return new Promise((resolve, reject) => {
33
+ https.get(url, { headers: { 'User-Agent': 'redmine-cli-npm' } }, (res) => {
34
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
35
+ return fetchJson(res.headers.location).then(resolve).catch(reject);
36
+ }
37
+ if (res.statusCode !== 200) {
38
+ return reject(new Error(`HTTP ${res.statusCode}`));
39
+ }
40
+ let data = '';
41
+ res.on('data', chunk => data += chunk);
42
+ res.on('end', () => {
43
+ try {
44
+ resolve(JSON.parse(data));
45
+ } catch (e) {
46
+ reject(e);
47
+ }
48
+ });
49
+ }).on('error', reject);
50
+ });
51
+ }
52
+
53
+ async function getLatestVersion() {
54
+ const releases = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
55
+ return releases.tag_name;
56
+ }
57
+
58
+ async function downloadFile(url, dest) {
59
+ return new Promise((resolve, reject) => {
60
+ const protocol = url.startsWith('https') ? https : http;
61
+ protocol.get(url, (res) => {
62
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
63
+ return downloadFile(res.headers.location, dest).then(resolve).catch(reject);
64
+ }
65
+ if (res.statusCode !== 200) {
66
+ return reject(new Error(`HTTP ${res.statusCode}`));
67
+ }
68
+ const file = fs.createWriteStream(dest);
69
+ streamPipeline(res, file).then(resolve).catch(reject);
70
+ }).on('error', reject);
71
+ });
72
+ }
73
+
74
+ async function extractArchive(archivePath, destDir, isZip) {
75
+ const targetDir = path.dirname(destDir);
76
+
77
+ if (isZip) {
78
+ const unzipper = require('unzipper');
79
+ await new Promise((resolve, reject) => {
80
+ fs.createReadStream(archivePath)
81
+ .pipe(unzipper.Extract({ path: targetDir }))
82
+ .on('close', resolve)
83
+ .on('error', reject);
84
+ });
85
+ } else {
86
+ const tar = require('tar');
87
+ await tar.x({ file: archivePath, cwd: targetDir });
88
+ }
89
+ }
90
+
91
+ async function main() {
92
+ const binDir = path.join(__dirname, '..', 'bin');
93
+ const platform = getPlatform();
94
+ const arch = getArch();
95
+
96
+ console.log(`[INFO] Installing redmine-cli for ${platform}/${arch}...`);
97
+
98
+ let version;
99
+ try {
100
+ version = await getLatestVersion();
101
+ console.log(`[INFO] Latest version: ${version}`);
102
+ } catch (e) {
103
+ console.error('[ERROR] Failed to get latest version:', e.message);
104
+ process.exit(1);
105
+ }
106
+
107
+ const ext = platform === 'windows' ? 'zip' : 'tar.gz';
108
+ const archiveName = `${BINARY_NAME}_${version.replace('v', '')}_${platform}_${arch}.${ext}`;
109
+ const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${archiveName}`;
110
+
111
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'redmine-cli-'));
112
+ const archivePath = path.join(tmpDir, archiveName);
113
+
114
+ try {
115
+ console.log(`[INFO] Downloading ${archiveName}...`);
116
+ await downloadFile(downloadUrl, archivePath);
117
+
118
+ console.log('[INFO] Extracting...');
119
+ await extractArchive(archivePath, binDir, platform === 'windows');
120
+
121
+ const binaryName = platform === 'windows' ? `${BINARY_NAME}.exe` : BINARY_NAME;
122
+ const extractedPath = path.join(tmpDir, binaryName);
123
+ const destPath = path.join(binDir, binaryName);
124
+
125
+ if (fs.existsSync(extractedPath)) {
126
+ fs.renameSync(extractedPath, destPath);
127
+ }
128
+
129
+ if (platform !== 'windows') {
130
+ fs.chmodSync(destPath, 0o755);
131
+ }
132
+
133
+ console.log('[INFO] Successfully installed redmine-cli');
134
+ } catch (e) {
135
+ console.error('[ERROR] Installation failed:', e.message);
136
+ process.exit(1);
137
+ } finally {
138
+ fs.rmSync(tmpDir, { recursive: true, force: true });
139
+ }
140
+ }
141
+
142
+ main();
@@ -0,0 +1,177 @@
1
+ param(
2
+ [string]$InstallDir = "",
3
+ [string]$Version = ""
4
+ )
5
+
6
+ $ErrorActionPreference = "Stop"
7
+
8
+ $REPO = "largeoliu/redmine-cli"
9
+ $BINARY_NAME = "redmine"
10
+
11
+ if (-not $InstallDir) {
12
+ $InstallDir = Join-Path $env:USERPROFILE ".local\bin"
13
+ }
14
+
15
+ function Write-Info {
16
+ param([string]$Message)
17
+ Write-Host "[INFO] " -ForegroundColor Green -NoNewline
18
+ Write-Host $Message
19
+ }
20
+
21
+ function Write-Warn {
22
+ param([string]$Message)
23
+ Write-Host "[WARN] " -ForegroundColor Yellow -NoNewline
24
+ Write-Host $Message
25
+ }
26
+
27
+ function Write-Error-Exit {
28
+ param([string]$Message)
29
+ Write-Host "[ERROR] " -ForegroundColor Red -NoNewline
30
+ Write-Host $Message
31
+ exit 1
32
+ }
33
+
34
+ function Get-OS {
35
+ if ($IsWindows -or ($env:OS -match "Windows")) {
36
+ return "windows"
37
+ } elseif ($IsMacOS) {
38
+ return "darwin"
39
+ } elseif ($IsLinux) {
40
+ return "linux"
41
+ }
42
+ Write-Error-Exit "Unsupported OS"
43
+ }
44
+
45
+ function Get-Arch {
46
+ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
47
+ switch ($arch) {
48
+ "X64" { return "amd64" }
49
+ "Arm64" { return "arm64" }
50
+ default { Write-Error-Exit "Unsupported architecture: $arch" }
51
+ }
52
+ }
53
+
54
+ function Get-LatestVersion {
55
+ $latestUrl = "https://github.com/$REPO/releases/latest"
56
+ try {
57
+ $response = Invoke-WebRequest -Uri $latestUrl -Method Head -MaximumRedirection 0 -ErrorAction SilentlyContinue
58
+ } catch {
59
+ $response = $_.Exception.Response
60
+ }
61
+
62
+ if ($response.Headers.Location) {
63
+ $location = $response.Headers.Location.ToString()
64
+ $version = $location.Split("/")[-1]
65
+ return $version
66
+ }
67
+
68
+ Write-Error-Exit "Failed to get latest version"
69
+ }
70
+
71
+ function Download-Binary {
72
+ param(
73
+ [string]$Version,
74
+ [string]$OS,
75
+ [string]$Arch
76
+ )
77
+
78
+ $archiveName = "${BINARY_NAME}_$($Version.Substring(1))_${OS}_${Arch}.zip"
79
+ $downloadUrl = "https://github.com/$REPO/releases/download/$Version/$archiveName"
80
+
81
+ Write-Info "Downloading $archiveName..."
82
+
83
+ $tmpDir = New-TemporaryDirectory
84
+ $archivePath = Join-Path $tmpDir $archiveName
85
+
86
+ try {
87
+ Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
88
+ } catch {
89
+ Write-Error-Exit "Failed to download $archiveName : $_"
90
+ }
91
+
92
+ Write-Info "Extracting..."
93
+
94
+ try {
95
+ Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
96
+ } catch {
97
+ Write-Error-Exit "Failed to extract archive: $_"
98
+ }
99
+
100
+ return $tmpDir
101
+ }
102
+
103
+ function New-TemporaryDirectory {
104
+ $tmpPath = [System.IO.Path]::GetTempPath()
105
+ $tmpDir = [System.IO.Path]::Combine($tmpPath, [System.IO.Path]::GetRandomFileName())
106
+ New-Item -ItemType Directory -Path $tmpDir | Out-Null
107
+ return $tmpDir
108
+ }
109
+
110
+ function Install-Binary {
111
+ param([string]$TmpDir)
112
+
113
+ if (-not (Test-Path $InstallDir)) {
114
+ Write-Info "Creating install directory: $InstallDir"
115
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
116
+ }
117
+
118
+ $binaryPath = Join-Path $TmpDir "$BINARY_NAME.exe"
119
+
120
+ if (-not (Test-Path $binaryPath)) {
121
+ $binaryPath = Join-Path $TmpDir $BINARY_NAME
122
+ }
123
+
124
+ if (-not (Test-Path $binaryPath)) {
125
+ Write-Error-Exit "Binary not found in archive"
126
+ }
127
+
128
+ $destPath = Join-Path $InstallDir "$BINARY_NAME.exe"
129
+ Move-Item -Path $binaryPath -Destination $destPath -Force
130
+
131
+ Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
132
+ }
133
+
134
+ function Test-PathInEnv {
135
+ $pathDirs = $env:PATH -split ";"
136
+ return $pathDirs -contains $InstallDir
137
+ }
138
+
139
+ function Add-ToPath {
140
+ $currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
141
+ if ($currentPath -notlike "*$InstallDir*") {
142
+ $newPath = "$currentPath;$InstallDir"
143
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
144
+ Write-Info "Added $InstallDir to user PATH"
145
+ }
146
+ }
147
+
148
+ $os = Get-OS
149
+ $arch = Get-Arch
150
+
151
+ if (-not $Version) {
152
+ $Version = Get-LatestVersion
153
+ }
154
+
155
+ Write-Info "Installing $BINARY_NAME..."
156
+ Write-Info "OS: $os, Arch: $arch, Version: $Version"
157
+
158
+ $tmpDir = Download-Binary -Version $Version -OS $os -Arch $arch
159
+ Install-Binary -TmpDir $tmpDir
160
+
161
+ Write-Info "Successfully installed $BINARY_NAME to $InstallDir"
162
+
163
+ if (-not (Test-PathInEnv)) {
164
+ Write-Host ""
165
+ Write-Warn "$InstallDir is not in your PATH"
166
+ Write-Host ""
167
+
168
+ $addToPath = Read-Host "Would you like to add it to your PATH? (Y/n)"
169
+ if ($addToPath -ne "n" -and $addToPath -ne "N") {
170
+ Add-ToPath
171
+ Write-Host ""
172
+ Write-Info "Please restart your terminal or run: `$env:Path = [System.Environment]::GetEnvironmentVariable('Path','User')"
173
+ }
174
+ }
175
+
176
+ Write-Host ""
177
+ Write-Info "Run '$BINARY_NAME --help' to get started"
@@ -0,0 +1,150 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ REPO="largeoliu/redmine-cli"
6
+ BINARY_NAME="redmine"
7
+ INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
8
+
9
+ GREEN='\033[0;32m'
10
+ RED='\033[0;31m'
11
+ YELLOW='\033[1;33m'
12
+ NC='\033[0m'
13
+
14
+ info() {
15
+ echo "${GREEN}[INFO]${NC} $1"
16
+ }
17
+
18
+ warn() {
19
+ echo "${YELLOW}[WARN]${NC} $1"
20
+ }
21
+
22
+ error() {
23
+ echo "${RED}[ERROR]${NC} $1"
24
+ exit 1
25
+ }
26
+
27
+ detect_os() {
28
+ case "$(uname -s)" in
29
+ Darwin*) echo "darwin" ;;
30
+ Linux*) echo "linux" ;;
31
+ CYGWIN*|MINGW*|MSYS*) echo "windows" ;;
32
+ *) error "Unsupported OS: $(uname -s)" ;;
33
+ esac
34
+ }
35
+
36
+ detect_arch() {
37
+ case "$(uname -m)" in
38
+ x86_64|amd64) echo "amd64" ;;
39
+ arm64|aarch64) echo "arm64" ;;
40
+ *) error "Unsupported architecture: $(uname -m)" ;;
41
+ esac
42
+ }
43
+
44
+ get_latest_version() {
45
+ latest_url="https://github.com/${REPO}/releases/latest"
46
+ version=$(curl -sI "$latest_url" | grep -i "location:" | sed 's/.*\/tag\/\(.*\)/\1/' | tr -d '\r\n')
47
+ if [ -z "$version" ]; then
48
+ error "Failed to get latest version"
49
+ fi
50
+ echo "$version"
51
+ }
52
+
53
+ download_binary() {
54
+ version="$1"
55
+ os="$2"
56
+ arch="$3"
57
+
58
+ if [ "$os" = "windows" ]; then
59
+ archive_name="${BINARY_NAME}_${version#v}_${os}_${arch}.zip"
60
+ else
61
+ archive_name="${BINARY_NAME}_${version#v}_${os}_${arch}.tar.gz"
62
+ fi
63
+
64
+ download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
65
+
66
+ info "Downloading ${archive_name}..."
67
+
68
+ tmp_dir=$(mktemp -d)
69
+ archive_path="${tmp_dir}/${archive_name}"
70
+
71
+ if ! curl -fsSL "$download_url" -o "$archive_path"; then
72
+ error "Failed to download ${archive_name}"
73
+ fi
74
+
75
+ info "Extracting..."
76
+
77
+ if [ "$os" = "windows" ]; then
78
+ if ! unzip -q "$archive_path" -d "$tmp_dir"; then
79
+ error "Failed to extract archive"
80
+ fi
81
+ else
82
+ if ! tar -xzf "$archive_path" -C "$tmp_dir"; then
83
+ error "Failed to extract archive"
84
+ fi
85
+ fi
86
+
87
+ echo "$tmp_dir"
88
+ }
89
+
90
+ install_binary() {
91
+ tmp_dir="$1"
92
+
93
+ if [ ! -d "$INSTALL_DIR" ]; then
94
+ info "Creating install directory: $INSTALL_DIR"
95
+ mkdir -p "$INSTALL_DIR"
96
+ fi
97
+
98
+ binary_path="${tmp_dir}/${BINARY_NAME}"
99
+
100
+ if [ ! -f "$binary_path" ]; then
101
+ error "Binary not found in archive"
102
+ fi
103
+
104
+ chmod +x "$binary_path"
105
+ mv "$binary_path" "${INSTALL_DIR}/${BINARY_NAME}"
106
+
107
+ rm -rf "$tmp_dir"
108
+ }
109
+
110
+ check_path() {
111
+ case ":$PATH:" in
112
+ *":$INSTALL_DIR:"*)
113
+ return 0
114
+ ;;
115
+ *)
116
+ return 1
117
+ ;;
118
+ esac
119
+ }
120
+
121
+ main() {
122
+ info "Installing ${BINARY_NAME}..."
123
+
124
+ os=$(detect_os)
125
+ arch=$(detect_arch)
126
+ version=$(get_latest_version)
127
+
128
+ info "OS: ${os}, Arch: ${arch}, Version: ${version}"
129
+
130
+ tmp_dir=$(download_binary "$version" "$os" "$arch")
131
+ install_binary "$tmp_dir"
132
+
133
+ info "Successfully installed ${BINARY_NAME} to ${INSTALL_DIR}"
134
+
135
+ if ! check_path; then
136
+ echo ""
137
+ warn "${INSTALL_DIR} is not in your PATH"
138
+ echo ""
139
+ echo "Add the following to your shell profile (~/.bashrc, ~/.zshrc, etc.):"
140
+ echo ""
141
+ echo " export PATH=\"\$PATH:${INSTALL_DIR}\""
142
+ echo ""
143
+ echo "Then restart your shell or run: source ~/.bashrc (or ~/.zshrc)"
144
+ fi
145
+
146
+ echo ""
147
+ info "Run '${BINARY_NAME} --help' to get started"
148
+ }
149
+
150
+ main "$@"