diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b17d3ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package.json +bun.lock +*.log +env.sh \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..83d68f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# 全局规则 + +## 语言偏好 + +**重要:请始终使用中文(简体中文)与用户交流。** + +所有回复、解释、错误消息、建议和对话都应该使用中文,除非: +- 代码注释需要使用英文(根据项目规范) +- 用户明确要求使用其他语言 +- 技术术语在中文中没有合适的翻译(可以中英文混用,如 "React Hooks") + +这是用户的个人偏好设置,适用于所有项目。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a8175e --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# OpenCode Configuration + +This repository contains custom OpenCode configuration including commands, skills, and MCP services. + +## Structure + +``` +. +├── opencode.json # MCP services configuration +├── command/ # Custom commands +│ ├── auto-commit.md +│ ├── commit-push.md +│ ├── create-gitea-repo.md +│ ├── release-android.md +│ ├── review.md +│ ├── sync-oc-pull.md +│ └── sync-oc-push.md +└── skill/ # Agent skills + ├── android-developer/ + ├── electron-developer/ + ├── go-developer/ + └── ios-developer/ +``` + +## Setup + +### 1. Clone Configuration + +```bash +git clone https://git.digitevents.com/ai/opencode.git ~/.config/opencode +``` + +### 2. Configure Environment Variables + +Create `env.sh` file (not tracked in git) or use the one from iCloud: + +```bash +# Option 1: Create manually +cat > ~/.config/opencode/env.sh << 'EOF' +# OpenCode Environment Variables +export REF_API_KEY="your-ref-api-key" +export FIGMA_API_KEY="your-figma-api-key" +export GITEA_API_TOKEN="your-gitea-token" +EOF + +# Option 2: Use iCloud (macOS only) +# File location: ~/Library/Mobile Documents/com~apple~CloudDocs/opencode-env.sh +``` + +### 3. Load Environment Variables + +Add to your `~/.zshrc` or `~/.bashrc`: + +```bash +# Load from iCloud (macOS) +[[ -f ~/Library/Mobile\ Documents/com~apple~CloudDocs/opencode-env.sh ]] && \ + source ~/Library/Mobile\ Documents/com~apple~CloudDocs/opencode-env.sh + +# Or load from local file +[[ -f ~/.config/opencode/env.sh ]] && source ~/.config/opencode/env.sh +``` + +Then reload your shell: +```bash +source ~/.zshrc +``` + +## Usage + +### Commands + +Run custom commands in OpenCode TUI: + +- `/auto-commit` - Auto-generate commit and create tag +- `/commit-push` - Commit, tag and push to remote +- `/create-gitea-repo` - Create repository on Gitea +- `/release-android` - Build and release Android APK to Gitea +- `/sync-oc-pull` - Pull OpenCode config changes +- `/sync-oc-push` - Push OpenCode config changes +- `/review` - Review code or documentation + +### Skills + +Skills are automatically loaded by agents when needed: + +- `android-developer` - Android development with Kotlin/Compose +- `electron-developer` - Electron desktop app development +- `ios-developer` - iOS development with Swift/SwiftUI +- `go-developer` - Go backend development + +### MCP Services + +Configure MCP services in `opencode.json`: + +- `ref` - Ref.tools API (requires `REF_API_KEY`) +- `figma` - Figma Developer MCP (requires `FIGMA_API_KEY`) + +## Sync Across Devices + +### Push Changes + +```bash +cd ~/.config/opencode +git add . +git commit -m "chore: update config" +git push +``` + +### Pull Changes + +```bash +cd ~/.config/opencode +git pull +``` + +Or use the built-in commands: +- `/sync-oc-push` - Push changes +- `/sync-oc-pull` - Pull changes + +## Security + +**Important**: Never commit sensitive information like API keys to git. + +- `env.sh` is ignored by git +- Store credentials in iCloud or use a secure password manager +- Use environment variables instead of hardcoding secrets diff --git a/bin/release-android.mjs b/bin/release-android.mjs new file mode 100755 index 0000000..23c9eb2 --- /dev/null +++ b/bin/release-android.mjs @@ -0,0 +1,494 @@ +#!/usr/bin/env node +/** + * Android Release Script - Universal Version + * + * Automatically builds Android APK and uploads to Gitea Release. + * + * Features: + * - Auto-detect Android project root (standalone or monorepo) + * - Auto-detect Gitea config from git remote URL (HTTPS/SSH) + * - Auto-detect Java Home from Android Studio + * - Support tag-based release workflow + * + * Environment Variables: + * - GITEA_TOKEN (required): Gitea API token + * - GITEA_API_URL (optional): Override Gitea API URL + * - GITEA_OWNER (optional): Override repository owner + * - GITEA_REPO (optional): Override repository name + * + * Usage: + * node release-android.mjs [android-dir] + * + * If android-dir is not specified, will auto-detect from current directory. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// ============================================================================ +// Project Detection +// ============================================================================ + +/** + * Find Android project root directory + * Looks for app/build.gradle.kts or build.gradle.kts with android plugin + */ +function findAndroidRoot(startDir) { + const candidates = [ + startDir, // Current dir is Android root + path.join(startDir, 'android'), // Monorepo: ./android/ + path.join(startDir, '..'), // Parent dir + ]; + + for (const dir of candidates) { + const buildGradle = path.join(dir, 'app/build.gradle.kts'); + const buildGradleGroovy = path.join(dir, 'app/build.gradle'); + + if (fs.existsSync(buildGradle) || fs.existsSync(buildGradleGroovy)) { + return path.resolve(dir); + } + } + + return null; +} + +/** + * Find git repository root + */ +function findGitRoot(startDir) { + try { + const result = execSync('git rev-parse --show-toplevel', { + cwd: startDir, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return result; + } catch { + return null; + } +} + +/** + * Detect if project is a monorepo (Android is a subdirectory) + */ +function isMonorepo(androidDir, gitRoot) { + if (!gitRoot) return false; + return path.resolve(androidDir) !== path.resolve(gitRoot); +} + +// ============================================================================ +// Git/Gitea Configuration +// ============================================================================ + +/** + * Parse git remote URL to extract Gitea configuration + * Supports both HTTPS and SSH formats + */ +function detectGiteaConfig(gitRoot) { + try { + const remoteUrl = execSync('git remote get-url origin', { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + // HTTPS format: https://git.example.com/owner/repo.git + const httpsMatch = remoteUrl.match(/https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(\.git)?$/); + if (httpsMatch) { + return { + apiUrl: `https://${httpsMatch[1]}`, + owner: httpsMatch[2], + repo: httpsMatch[3] + }; + } + + // SSH format: git@git.example.com:owner/repo.git + const sshMatch = remoteUrl.match(/git@([^:]+):([^/]+)\/([^/]+?)(\.git)?$/); + if (sshMatch) { + return { + apiUrl: `https://${sshMatch[1]}`, + owner: sshMatch[2], + repo: sshMatch[3] + }; + } + } catch { + // Ignore errors + } + return {}; +} + +// ============================================================================ +// Java Detection +// ============================================================================ + +/** + * Detect Java Home, prioritizing Android Studio's bundled JDK + */ +function detectJavaHome() { + const possiblePaths = [ + // macOS - Android Studio bundled JDK + '/Applications/Android Studio.app/Contents/jbr/Contents/Home', + '/Applications/Android Studio.app/Contents/jre/Contents/Home', + // Linux - Android Studio bundled JDK + `${process.env.HOME}/android-studio/jbr`, + `${process.env.HOME}/android-studio/jre`, + // Environment variable + process.env.JAVA_HOME, + // Common system paths + '/usr/lib/jvm/java-11-openjdk', + '/usr/lib/jvm/java-17-openjdk', + ]; + + for (const javaHome of possiblePaths) { + if (javaHome && fs.existsSync(javaHome)) { + const javaBin = path.join(javaHome, 'bin', 'java'); + if (fs.existsSync(javaBin)) { + return javaHome; + } + } + } + + return null; +} + +// ============================================================================ +// Version & Tag Management +// ============================================================================ + +/** + * Read version from build.gradle.kts + */ +function readVersion(androidDir) { + const buildGradlePath = path.join(androidDir, 'app/build.gradle.kts'); + const buildGradleGroovyPath = path.join(androidDir, 'app/build.gradle'); + + let buildGradle; + if (fs.existsSync(buildGradlePath)) { + buildGradle = fs.readFileSync(buildGradlePath, 'utf-8'); + } else if (fs.existsSync(buildGradleGroovyPath)) { + buildGradle = fs.readFileSync(buildGradleGroovyPath, 'utf-8'); + } else { + return null; + } + + const versionMatch = buildGradle.match(/versionName\s*[=:]\s*["']([^"']+)["']/); + return versionMatch ? versionMatch[1] : null; +} + +/** + * Get tag name based on project structure + */ +function getTagName(version, isMonorepoProject) { + // Monorepo uses prefixed tag, standalone uses v-prefix + return isMonorepoProject ? `android-${version}` : `v${version}`; +} + +/** + * Get tag information (check existence and get annotation) + */ +function getTagInfo(tagName, gitRoot) { + try { + const tagExists = execSync(`git tag -l ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (!tagExists) { + return { exists: false }; + } + + // Get tag annotation + const annotation = execSync(`git tag -l --format='%(contents)' ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (annotation) { + return { exists: true, message: annotation }; + } + + // Fall back to commit message + const commitMsg = execSync(`git log -1 --format=%B ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + return { exists: true, message: commitMsg || `Release ${tagName}` }; + } catch { + return { exists: false }; + } +} + +// ============================================================================ +// Build & Upload +// ============================================================================ + +/** + * Build Android APK + */ +function buildApk(androidDir, javaHome) { + console.log('Building APK...'); + execSync('./gradlew assembleRelease --quiet', { + cwd: androidDir, + stdio: 'inherit', + shell: true, + env: { ...process.env, JAVA_HOME: javaHome } + }); +} + +/** + * Find built APK file + */ +function findApk(androidDir) { + const signedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release.apk'); + const unsignedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release-unsigned.apk'); + + if (fs.existsSync(signedApkPath)) { + return { path: signedApkPath, signed: true }; + } else if (fs.existsSync(unsignedApkPath)) { + return { path: unsignedApkPath, signed: false }; + } + return null; +} + +/** + * Execute curl and return JSON response + */ +function curlJson(cmd) { + try { + const result = execSync(cmd, { encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + return JSON.parse(result); + } catch { + return null; + } +} + +/** + * Upload APK to Gitea Release + */ +function uploadToGitea(config) { + const { apiUrl, owner, repo, token, tagName, fileName, apkPath, releaseMessage } = config; + + const repoApiBase = `${apiUrl}/api/v1/repos/${owner}/${repo}`; + + // Get or create release + let releaseId; + const releases = curlJson( + `curl -s -H "Authorization: token ${token}" "${repoApiBase}/releases"` + ); + + if (Array.isArray(releases)) { + const existingRelease = releases.find((r) => r.tag_name === tagName); + if (existingRelease) { + releaseId = existingRelease.id; + console.log(`Found existing Release (ID: ${releaseId})`); + + // Delete existing asset with same name + const existingAsset = (existingRelease.assets || []).find((a) => a.name === fileName); + if (existingAsset) { + console.log(`Deleting existing asset: ${fileName}`); + execSync( + `curl -s -X DELETE -H "Authorization: token ${token}" "${repoApiBase}/releases/${releaseId}/assets/${existingAsset.id}"`, + { shell: true, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + } + } + + if (!releaseId) { + console.log('Creating new Release...'); + const releaseData = { + tag_name: tagName, + name: `Android APK ${tagName}`, + body: releaseMessage, + draft: false, + prerelease: false + }; + + const tempJsonPath = path.join(process.cwd(), '.release-data.json'); + fs.writeFileSync(tempJsonPath, JSON.stringify(releaseData)); + + const releaseInfo = curlJson( + `curl -s -X POST "${repoApiBase}/releases" \ + -H "Authorization: token ${token}" \ + -H "Content-Type: application/json" \ + -d @${tempJsonPath}` + ); + + fs.unlinkSync(tempJsonPath); + + if (!releaseInfo || !releaseInfo.id) { + throw new Error(`Failed to create release: ${JSON.stringify(releaseInfo)}`); + } + + releaseId = releaseInfo.id; + console.log(`Release created (ID: ${releaseId})`); + } + + // Upload APK + console.log('Uploading APK...'); + const uploadUrl = `${repoApiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; + const uploadResult = curlJson( + `curl -s -X POST "${uploadUrl}" \ + -H "Authorization: token ${token}" \ + -F "attachment=@${apkPath}"` + ); + + if (!uploadResult || !uploadResult.id) { + throw new Error(`Failed to upload APK: ${JSON.stringify(uploadResult)}`); + } + + return { + releaseUrl: `${apiUrl}/${owner}/${repo}/releases/tag/${tagName}`, + fileSize: uploadResult.size + }; +} + +// ============================================================================ +// Main +// ============================================================================ + +function main() { + const cwd = process.cwd(); + const argDir = process.argv[2]; + + console.log('='.repeat(60)); + console.log('Android Release Script'); + console.log('='.repeat(60)); + + // 1. Find Android project root + const searchDir = argDir ? path.resolve(cwd, argDir) : cwd; + const androidDir = findAndroidRoot(searchDir); + + if (!androidDir) { + console.error('Error: Cannot find Android project'); + console.error('Make sure you are in an Android project directory or specify the path'); + console.error('Usage: release-android [android-dir]'); + process.exit(1); + } + console.log(`Android project: ${androidDir}`); + + // 2. Find git root + const gitRoot = findGitRoot(androidDir); + if (!gitRoot) { + console.error('Error: Not a git repository'); + process.exit(1); + } + console.log(`Git root: ${gitRoot}`); + + const monorepo = isMonorepo(androidDir, gitRoot); + console.log(`Project type: ${monorepo ? 'Monorepo' : 'Standalone'}`); + + // 3. Detect Gitea configuration + const detected = detectGiteaConfig(gitRoot); + const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; + const GITEA_API_URL = process.env.GITEA_API_URL || detected.apiUrl || ''; + const GITEA_OWNER = process.env.GITEA_OWNER || detected.owner || ''; + const GITEA_REPO = process.env.GITEA_REPO || detected.repo || ''; + + if (!GITEA_TOKEN) { + console.error('Error: GITEA_TOKEN environment variable is not set'); + console.error('Please set: export GITEA_TOKEN="your_token"'); + process.exit(1); + } + + if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { + console.error('Error: Cannot detect Gitea repository configuration'); + console.error('Please set environment variables: GITEA_API_URL, GITEA_OWNER, GITEA_REPO'); + process.exit(1); + } + + console.log(`Gitea: ${GITEA_API_URL}/${GITEA_OWNER}/${GITEA_REPO}`); + + // 4. Read version + const version = readVersion(androidDir); + if (!version) { + console.error('Error: Cannot read versionName from build.gradle'); + process.exit(1); + } + console.log(`Version: ${version}`); + + // 5. Check tag + const tagName = getTagName(version, monorepo); + const tagInfo = getTagInfo(tagName, gitRoot); + + if (!tagInfo.exists) { + console.error(`Error: Git tag "${tagName}" not found`); + console.error(''); + console.error('Please create a tag before releasing:'); + console.error(` git tag -a ${tagName} -m "Release notes"`); + console.error(` git push origin ${tagName}`); + process.exit(1); + } + console.log(`Tag: ${tagName}`); + + // 6. Detect Java + const javaHome = detectJavaHome(); + if (!javaHome) { + console.error('Error: Cannot find Java Runtime'); + console.error('Please install JDK or ensure Android Studio is properly installed'); + process.exit(1); + } + console.log(`Java: ${javaHome}`); + + console.log(''); + console.log('Building...'); + + // 7. Build APK + try { + buildApk(androidDir, javaHome); + } catch (err) { + console.error('Build failed:', err.message); + process.exit(1); + } + + // 8. Find APK + const apk = findApk(androidDir); + if (!apk) { + console.error('Error: APK file not found'); + console.error('Check build output at: app/build/outputs/apk/release/'); + process.exit(1); + } + console.log(`APK: ${apk.path} (${apk.signed ? 'signed' : 'unsigned'})`); + + // 9. Upload to Gitea + const fileName = `${GITEA_REPO}-android-${version}.apk`; + + console.log(''); + console.log('Uploading to Gitea...'); + + try { + const result = uploadToGitea({ + apiUrl: GITEA_API_URL, + owner: GITEA_OWNER, + repo: GITEA_REPO, + token: GITEA_TOKEN, + tagName, + fileName, + apkPath: apk.path, + releaseMessage: tagInfo.message + }); + + console.log(''); + console.log('='.repeat(60)); + console.log('Release successful!'); + console.log(`File: ${fileName}`); + console.log(`Size: ${(result.fileSize / 1024 / 1024).toFixed(2)} MB`); + console.log(`URL: ${result.releaseUrl}`); + console.log('='.repeat(60)); + } catch (err) { + console.error('Upload failed:', err.message); + process.exit(1); + } +} + +main();