diff --git a/.github/workflows/agentic-marketplace.yml b/.github/workflows/agentic-marketplace.yml index a112e95..a54b2ca 100644 --- a/.github/workflows/agentic-marketplace.yml +++ b/.github/workflows/agentic-marketplace.yml @@ -14,6 +14,14 @@ on: description: 'Preview changes without committing' default: false type: boolean + create-opencode-release: + description: 'Create OpenCode-compatible plugin releases' + default: false + type: boolean + release-version: + description: 'Version for releases (default: YYYY.MM.DD)' + default: '' + type: string secrets: token: description: 'GitHub token for PR creation' @@ -68,3 +76,5 @@ jobs: config-path: ${{ inputs.config-path }} github-token: ${{ secrets.token }} auto-merge: ${{ inputs.auto-merge }} + create-opencode-release: ${{ inputs.create-opencode-release }} + release-version: ${{ inputs.release-version }} diff --git a/agentic-marketplace/generate/action.yml b/agentic-marketplace/generate/action.yml index 7681534..a571216 100644 --- a/agentic-marketplace/generate/action.yml +++ b/agentic-marketplace/generate/action.yml @@ -18,6 +18,14 @@ inputs: description: 'Preview changes without committing' required: false default: 'false' + create-opencode-release: + description: 'Create OpenCode-compatible plugin releases' + required: false + default: 'false' + release-version: + description: 'Version for releases (default: YYYY.MM.DD)' + required: false + default: '' outputs: pr-number: @@ -26,6 +34,9 @@ outputs: pr-url: description: 'Pull request URL if created' value: ${{ steps.create-pr.outputs.pull-request-url }} + releases-created: + description: 'Number of OpenCode releases created' + value: ${{ steps.opencode-release.outputs.count }} runs: using: 'composite' @@ -96,6 +107,29 @@ runs: echo "No PR created (no changes detected)" fi + - name: Create OpenCode Releases + id: opencode-release + if: ${{ inputs.create-opencode-release == 'true' && inputs.dry-run != 'true' }} + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_VERSION: ${{ inputs.release-version }} + run: | + set -e + + # Use bundled script from this action + SCRIPT_PATH="${GITHUB_ACTION_PATH}/../../scripts/dist/opencode-release.cjs" + + if [ ! -f "$SCRIPT_PATH" ]; then + echo "ERROR: Bundled script not found at $SCRIPT_PATH" >&2 + exit 1 + fi + + echo "Creating OpenCode releases..." + node "$SCRIPT_PATH" opencode-release + + echo "✓ Release creation complete" + branding: icon: 'file-text' color: 'purple' diff --git a/scripts/build.js b/scripts/build.js index 0d25fad..62cf3a5 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -13,6 +13,7 @@ if (!fs.existsSync(distDir)) { fs.mkdirSync(distDir, { recursive: true }); } +// Bundle discover-components.js esbuild.buildSync({ entryPoints: [path.join(__dirname, 'src/discover-components.js')], bundle: true, @@ -28,7 +29,26 @@ esbuild.buildSync({ }); // Make the output executable -const outputPath = path.join(__dirname, 'dist/discover-components.cjs'); -fs.chmodSync(outputPath, 0o755); - +const discoverPath = path.join(__dirname, 'dist/discover-components.cjs'); +fs.chmodSync(discoverPath, 0o755); console.log('✓ Bundled discover-components.cjs'); + +// Bundle opencode-release.js +esbuild.buildSync({ + entryPoints: [path.join(__dirname, 'src/opencode-release.js')], + bundle: true, + platform: 'node', + target: 'node20', + outfile: path.join(__dirname, 'dist/opencode-release.cjs'), + banner: { + js: '#!/usr/bin/env node\n' + }, + external: [], // Bundle all dependencies + minify: false, // Keep readable for debugging + sourcemap: false +}); + +// Make the output executable +const releasePath = path.join(__dirname, 'dist/opencode-release.cjs'); +fs.chmodSync(releasePath, 0o755); +console.log('✓ Bundled opencode-release.cjs'); diff --git a/scripts/dist/opencode-release.cjs b/scripts/dist/opencode-release.cjs new file mode 100755 index 0000000..8833496 --- /dev/null +++ b/scripts/dist/opencode-release.cjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + + +// scripts/src/opencode-release.js +var fs = require("fs"); +var path = require("path"); +var { execSync } = require("child_process"); +function getLatestTag(pluginName = null) { + try { + const pattern = pluginName ? `${pluginName}-v*` : "*"; + const tags = execSync(`git tag -l '${pattern}' --sort=-version:refname`, { encoding: "utf8" }).trim().split("\n").filter(Boolean); + return tags[0] || null; + } catch (err) { + return null; + } +} +function getChangedFiles(since, pathFilter = "") { + try { + const cmd = pathFilter ? `git diff --name-only ${since}...HEAD -- ${pathFilter}` : `git diff --name-only ${since}...HEAD`; + const output = execSync(cmd, { encoding: "utf8" }).trim(); + return output ? output.split("\n") : []; + } catch (err) { + console.error(`Error getting changed files: ${err.message}`); + return []; + } +} +function parseConventionalCommits(since, pathFilter = "") { + try { + const cmd = pathFilter ? `git log ${since}...HEAD --pretty=format:"%s" -- ${pathFilter}` : `git log ${since}...HEAD --pretty=format:"%s"`; + const output = execSync(cmd, { encoding: "utf8" }).trim(); + const commits = output ? output.split("\n") : []; + const categories = { + features: [], + fixes: [], + other: [] + }; + commits.forEach((commit) => { + if (commit.startsWith("feat:") || commit.startsWith("feat(")) { + categories.features.push(commit.replace(/^feat(\([^)]+\))?:\s*/, "")); + } else if (commit.startsWith("fix:") || commit.startsWith("fix(")) { + categories.fixes.push(commit.replace(/^fix(\([^)]+\))?:\s*/, "")); + } else if (!commit.startsWith("chore:") && !commit.startsWith("chore(")) { + categories.other.push(commit.replace(/^[a-z]+(\([^)]+\))?:\s*/, "")); + } + }); + return categories; + } catch (err) { + console.error(`Error parsing commits: ${err.message}`); + return { features: [], fixes: [], other: [] }; + } +} +function generateChangelog(pluginName, version, commits) { + const lines = [ + `# Changelog - ${pluginName} v${version}`, + "" + ]; + if (commits.features.length > 0) { + lines.push("## Features"); + commits.features.forEach((feat) => lines.push(`- ${feat}`)); + lines.push(""); + } + if (commits.fixes.length > 0) { + lines.push("## Bug Fixes"); + commits.fixes.forEach((fix) => lines.push(`- ${fix}`)); + lines.push(""); + } + if (commits.other.length > 0) { + lines.push("## Other Changes"); + commits.other.forEach((change) => lines.push(`- ${change}`)); + lines.push(""); + } + return lines.join("\n"); +} +function generateInstallGuide(pluginName, version) { + return `# Installation - ${pluginName} v${version} + +## Quick Install + +\`\`\`bash +# Download and extract to OpenCode plugins directory +curl -L https://github.com/bitcomplete/bc-llm-skills/releases/download/${pluginName}-v${version}/${pluginName}.zip -o ${pluginName}.zip +unzip ${pluginName}.zip -d ~/.config/opencode/plugins/ +rm ${pluginName}.zip +\`\`\` + +## Manual Install + +1. Download \`${pluginName}.zip\` from this release +2. Extract to \`~/.config/opencode/plugins/${pluginName}/\` +3. Restart OpenCode + +## Verify Installation + +After restarting OpenCode, the plugin commands should appear in autocomplete. + +## Platform Notes + +**Linux/macOS**: Default location is \`~/.config/opencode/plugins/\` + +**Windows**: Use \`%USERPROFILE%\\.config\\opencode\\plugins\\\` + +## Compatibility + +This plugin works with both OpenCode and Claude Code. Paths are auto-detected at runtime. +`; +} +function createPluginZip(pluginPath, outputPath, changelog, installGuide) { + try { + const tempDir = path.join(process.cwd(), ".tmp-release"); + const pluginName = path.basename(pluginPath); + const stagingDir = path.join(tempDir, pluginName); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + fs.mkdirSync(stagingDir, { recursive: true }); + execSync(`cp -R "${pluginPath}"/* "${stagingDir}/"`, { stdio: "inherit" }); + fs.writeFileSync(path.join(stagingDir, "CHANGELOG.md"), changelog); + fs.writeFileSync(path.join(stagingDir, "INSTALL.md"), installGuide); + const zipFile = path.basename(outputPath); + execSync(`cd "${tempDir}" && zip -r "${zipFile}" "${pluginName}"`, { stdio: "inherit" }); + execSync(`mv "${tempDir}/${zipFile}" "${outputPath}"`, { stdio: "inherit" }); + fs.rmSync(tempDir, { recursive: true }); + console.log(`\u2713 Created ${outputPath}`); + } catch (err) { + console.error(`Error creating zip: ${err.message}`); + throw err; + } +} +function createGitHubRelease(pluginName, version, zipPath, changelog) { + try { + const tag = `${pluginName}-v${version}`; + const title = `${pluginName} v${version}`; + const notes = `OpenCode-compatible release of ${pluginName}. + +${changelog} + +## Installation + +Download \`${pluginName}.zip\` and extract to \`~/.config/opencode/plugins/\` + +See INSTALL.md in the zip for detailed instructions. +`; + const notesFile = path.join(process.cwd(), `.tmp-notes-${pluginName}.md`); + fs.writeFileSync(notesFile, notes); + execSync( + `gh release create "${tag}" "${zipPath}" --title "${title}" --notes-file "${notesFile}"`, + { stdio: "inherit" } + ); + fs.unlinkSync(notesFile); + console.log(`\u2713 Created release: ${tag}`); + } catch (err) { + console.error(`Error creating GitHub release: ${err.message}`); + throw err; + } +} +function detectChangedPlugins(marketplacePath) { + if (!fs.existsSync(marketplacePath)) { + console.error(`Marketplace file not found: ${marketplacePath}`); + return []; + } + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8")); + const changedPlugins = []; + for (const plugin of marketplace.plugins) { + const pluginName = plugin.name; + const pluginPath = path.dirname(plugin.source); + const lastTag = getLatestTag(pluginName) || getLatestTag(); + if (!lastTag) { + console.log(`No previous releases found for ${pluginName}, skipping`); + continue; + } + const changedFiles = getChangedFiles(lastTag, pluginPath); + if (changedFiles.length > 0) { + console.log(`\u2713 Changes detected in ${pluginName} (${changedFiles.length} files)`); + changedPlugins.push({ + name: pluginName, + path: pluginPath, + lastTag, + changedFiles + }); + } + } + return changedPlugins; +} +function main() { + const command = process.argv[2]; + if (command !== "opencode-release") { + console.log("Usage: node opencode-release.js opencode-release"); + process.exit(1); + } + const marketplacePath = path.join(".claude-plugin", "marketplace.json"); + const releasesDir = path.join(process.cwd(), ".releases"); + const version = process.env.RELEASE_VERSION || (/* @__PURE__ */ new Date()).toISOString().split("T")[0].replace(/-/g, "."); + console.log("Detecting changed plugins..."); + const changedPlugins = detectChangedPlugins(marketplacePath); + if (changedPlugins.length === 0) { + console.log("No plugins with changes detected"); + process.exit(0); + } + if (!fs.existsSync(releasesDir)) { + fs.mkdirSync(releasesDir, { recursive: true }); + } + let releasesCreated = 0; + for (const plugin of changedPlugins) { + console.log(` +Creating release for ${plugin.name}...`); + const commits = parseConventionalCommits(plugin.lastTag, plugin.path); + const changelog = generateChangelog(plugin.name, version, commits); + const installGuide = generateInstallGuide(plugin.name, version); + const zipPath = path.join(releasesDir, `${plugin.name}.zip`); + createPluginZip(plugin.path, zipPath, changelog, installGuide); + createGitHubRelease(plugin.name, version, zipPath, changelog); + releasesCreated++; + } + console.log(` +\u2713 Created ${releasesCreated} OpenCode release(s)`); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${releasesCreated} +`); + } +} +if (require.main === module) { + main(); +} +module.exports = { + getLatestTag, + getChangedFiles, + parseConventionalCommits, + generateChangelog, + generateInstallGuide, + createPluginZip, + createGitHubRelease, + detectChangedPlugins +}; diff --git a/scripts/src/opencode-release.js b/scripts/src/opencode-release.js new file mode 100644 index 0000000..80e4302 --- /dev/null +++ b/scripts/src/opencode-release.js @@ -0,0 +1,361 @@ +/** + * @file opencode-release.js + * @description OpenCode plugin release automation + * + * Detects changed plugins, generates changelogs from conventional commits, + * packages plugins as zips, and creates GitHub releases. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +/** + * Get the latest tag for a specific plugin or overall repository + * @param {string} pluginName - Plugin name or null for latest repo tag + * @returns {string|null} Tag name or null if no tags exist + */ +function getLatestTag(pluginName = null) { + try { + const pattern = pluginName ? `${pluginName}-v*` : '*'; + const tags = execSync(`git tag -l '${pattern}' --sort=-version:refname`, { encoding: 'utf8' }) + .trim() + .split('\n') + .filter(Boolean); + return tags[0] || null; + } catch (err) { + return null; + } +} + +/** + * Get files changed since a specific tag or commit + * @param {string} since - Tag or commit to compare from + * @param {string} pathFilter - Optional path filter (e.g., "analysis/bc-generate-sitrep/") + * @returns {string[]} Array of changed file paths + */ +function getChangedFiles(since, pathFilter = '') { + try { + const cmd = pathFilter + ? `git diff --name-only ${since}...HEAD -- ${pathFilter}` + : `git diff --name-only ${since}...HEAD`; + const output = execSync(cmd, { encoding: 'utf8' }).trim(); + return output ? output.split('\n') : []; + } catch (err) { + console.error(`Error getting changed files: ${err.message}`); + return []; + } +} + +/** + * Parse conventional commit messages since a tag + * @param {string} since - Tag or commit to compare from + * @param {string} pathFilter - Optional path filter + * @returns {Object} Categorized commits + */ +function parseConventionalCommits(since, pathFilter = '') { + try { + const cmd = pathFilter + ? `git log ${since}...HEAD --pretty=format:"%s" -- ${pathFilter}` + : `git log ${since}...HEAD --pretty=format:"%s"`; + const output = execSync(cmd, { encoding: 'utf8' }).trim(); + const commits = output ? output.split('\n') : []; + + const categories = { + features: [], + fixes: [], + other: [] + }; + + commits.forEach(commit => { + if (commit.startsWith('feat:') || commit.startsWith('feat(')) { + categories.features.push(commit.replace(/^feat(\([^)]+\))?:\s*/, '')); + } else if (commit.startsWith('fix:') || commit.startsWith('fix(')) { + categories.fixes.push(commit.replace(/^fix(\([^)]+\))?:\s*/, '')); + } else if (!commit.startsWith('chore:') && !commit.startsWith('chore(')) { + // Skip chore commits, include everything else + categories.other.push(commit.replace(/^[a-z]+(\([^)]+\))?:\s*/, '')); + } + }); + + return categories; + } catch (err) { + console.error(`Error parsing commits: ${err.message}`); + return { features: [], fixes: [], other: [] }; + } +} + +/** + * Generate changelog content from categorized commits + * @param {string} pluginName - Plugin name + * @param {string} version - Version string + * @param {Object} commits - Categorized commits + * @returns {string} Markdown changelog + */ +function generateChangelog(pluginName, version, commits) { + const lines = [ + `# Changelog - ${pluginName} v${version}`, + '' + ]; + + if (commits.features.length > 0) { + lines.push('## Features'); + commits.features.forEach(feat => lines.push(`- ${feat}`)); + lines.push(''); + } + + if (commits.fixes.length > 0) { + lines.push('## Bug Fixes'); + commits.fixes.forEach(fix => lines.push(`- ${fix}`)); + lines.push(''); + } + + if (commits.other.length > 0) { + lines.push('## Other Changes'); + commits.other.forEach(change => lines.push(`- ${change}`)); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Generate installation instructions for OpenCode + * @param {string} pluginName - Plugin name + * @param {string} version - Version string + * @returns {string} Markdown installation guide + */ +function generateInstallGuide(pluginName, version) { + return `# Installation - ${pluginName} v${version} + +## Quick Install + +\`\`\`bash +# Download and extract to OpenCode plugins directory +curl -L https://github.com/bitcomplete/bc-llm-skills/releases/download/${pluginName}-v${version}/${pluginName}.zip -o ${pluginName}.zip +unzip ${pluginName}.zip -d ~/.config/opencode/plugins/ +rm ${pluginName}.zip +\`\`\` + +## Manual Install + +1. Download \`${pluginName}.zip\` from this release +2. Extract to \`~/.config/opencode/plugins/${pluginName}/\` +3. Restart OpenCode + +## Verify Installation + +After restarting OpenCode, the plugin commands should appear in autocomplete. + +## Platform Notes + +**Linux/macOS**: Default location is \`~/.config/opencode/plugins/\` + +**Windows**: Use \`%USERPROFILE%\\.config\\opencode\\plugins\\\` + +## Compatibility + +This plugin works with both OpenCode and Claude Code. Paths are auto-detected at runtime. +`; +} + +/** + * Create a zip archive of plugin directory + * @param {string} pluginPath - Path to plugin directory + * @param {string} outputPath - Output zip file path + * @param {string} changelog - Changelog content to include + * @param {string} installGuide - Install guide content to include + */ +function createPluginZip(pluginPath, outputPath, changelog, installGuide) { + try { + const tempDir = path.join(process.cwd(), '.tmp-release'); + const pluginName = path.basename(pluginPath); + const stagingDir = path.join(tempDir, pluginName); + + // Clean and create staging directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + fs.mkdirSync(stagingDir, { recursive: true }); + + // Copy plugin files + execSync(`cp -R "${pluginPath}"/* "${stagingDir}/"`, { stdio: 'inherit' }); + + // Add changelog and install guide + fs.writeFileSync(path.join(stagingDir, 'CHANGELOG.md'), changelog); + fs.writeFileSync(path.join(stagingDir, 'INSTALL.md'), installGuide); + + // Create zip from staging directory + const zipFile = path.basename(outputPath); + execSync(`cd "${tempDir}" && zip -r "${zipFile}" "${pluginName}"`, { stdio: 'inherit' }); + execSync(`mv "${tempDir}/${zipFile}" "${outputPath}"`, { stdio: 'inherit' }); + + // Cleanup + fs.rmSync(tempDir, { recursive: true }); + + console.log(`✓ Created ${outputPath}`); + } catch (err) { + console.error(`Error creating zip: ${err.message}`); + throw err; + } +} + +/** + * Create GitHub release with artifact + * @param {string} pluginName - Plugin name + * @param {string} version - Version string + * @param {string} zipPath - Path to zip artifact + * @param {string} changelog - Changelog content for release notes + */ +function createGitHubRelease(pluginName, version, zipPath, changelog) { + try { + const tag = `${pluginName}-v${version}`; + const title = `${pluginName} v${version}`; + + // Create release notes with changelog + const notes = `OpenCode-compatible release of ${pluginName}. + +${changelog} + +## Installation + +Download \`${pluginName}.zip\` and extract to \`~/.config/opencode/plugins/\` + +See INSTALL.md in the zip for detailed instructions. +`; + + const notesFile = path.join(process.cwd(), `.tmp-notes-${pluginName}.md`); + fs.writeFileSync(notesFile, notes); + + // Create release using gh CLI + execSync( + `gh release create "${tag}" "${zipPath}" --title "${title}" --notes-file "${notesFile}"`, + { stdio: 'inherit' } + ); + + // Cleanup + fs.unlinkSync(notesFile); + + console.log(`✓ Created release: ${tag}`); + } catch (err) { + console.error(`Error creating GitHub release: ${err.message}`); + throw err; + } +} + +/** + * Detect plugins with changes since last release + * @param {string} marketplacePath - Path to marketplace.json + * @returns {Array} Array of changed plugin objects + */ +function detectChangedPlugins(marketplacePath) { + if (!fs.existsSync(marketplacePath)) { + console.error(`Marketplace file not found: ${marketplacePath}`); + return []; + } + + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf8')); + const changedPlugins = []; + + for (const plugin of marketplace.plugins) { + const pluginName = plugin.name; + const pluginPath = path.dirname(plugin.source); + + // Get latest tag for this plugin + const lastTag = getLatestTag(pluginName) || getLatestTag(); // Fallback to any tag + + if (!lastTag) { + console.log(`No previous releases found for ${pluginName}, skipping`); + continue; + } + + // Check for changes in plugin directory + const changedFiles = getChangedFiles(lastTag, pluginPath); + + if (changedFiles.length > 0) { + console.log(`✓ Changes detected in ${pluginName} (${changedFiles.length} files)`); + changedPlugins.push({ + name: pluginName, + path: pluginPath, + lastTag, + changedFiles + }); + } + } + + return changedPlugins; +} + +/** + * Main function to create OpenCode releases + */ +function main() { + const command = process.argv[2]; + + if (command !== 'opencode-release') { + console.log('Usage: node opencode-release.js opencode-release'); + process.exit(1); + } + + const marketplacePath = path.join('.claude-plugin', 'marketplace.json'); + const releasesDir = path.join(process.cwd(), '.releases'); + + // Get version from env or use date-based + const version = process.env.RELEASE_VERSION || new Date().toISOString().split('T')[0].replace(/-/g, '.'); + + console.log('Detecting changed plugins...'); + const changedPlugins = detectChangedPlugins(marketplacePath); + + if (changedPlugins.length === 0) { + console.log('No plugins with changes detected'); + process.exit(0); + } + + // Create releases directory + if (!fs.existsSync(releasesDir)) { + fs.mkdirSync(releasesDir, { recursive: true }); + } + + let releasesCreated = 0; + + for (const plugin of changedPlugins) { + console.log(`\nCreating release for ${plugin.name}...`); + + // Parse commits for changelog + const commits = parseConventionalCommits(plugin.lastTag, plugin.path); + const changelog = generateChangelog(plugin.name, version, commits); + const installGuide = generateInstallGuide(plugin.name, version); + + // Create zip + const zipPath = path.join(releasesDir, `${plugin.name}.zip`); + createPluginZip(plugin.path, zipPath, changelog, installGuide); + + // Create GitHub release + createGitHubRelease(plugin.name, version, zipPath, changelog); + + releasesCreated++; + } + + console.log(`\n✓ Created ${releasesCreated} OpenCode release(s)`); + + // Output for GitHub Actions + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${releasesCreated}\n`); + } +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = { + getLatestTag, + getChangedFiles, + parseConventionalCommits, + generateChangelog, + generateInstallGuide, + createPluginZip, + createGitHubRelease, + detectChangedPlugins +};