diff --git a/.github/workflows/cli-release-build.yml b/.github/workflows/cli-release-build.yml new file mode 100644 index 000000000..b5ea8aecf --- /dev/null +++ b/.github/workflows/cli-release-build.yml @@ -0,0 +1,142 @@ +name: Build CLI Binary + +on: + workflow_call: + inputs: + binary-name: + required: true + type: string + description: 'Name of the CLI binary to build' + new-version: + required: true + type: string + description: 'Version string for the build' + artifact-name: + required: false + type: string + description: 'Optional artifact containing staging metadata' + default: '' + checkout-ref: + required: false + type: string + description: 'Git ref to checkout' + default: '' + env-overrides: + required: false + type: string + description: 'JSON object of environment variable overrides' + default: '{}' + +jobs: + build-binaries: + strategy: + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + bun_target: bun-linux-x64 + platform: linux + arch: x64 + - os: ubuntu-latest + target: linux-arm64 + bun_target: bun-linux-arm64 + platform: linux + arch: arm64 + - os: macos-13 + target: darwin-x64 + bun_target: bun-darwin-x64 + platform: darwin + arch: x64 + - os: macos-14 + target: darwin-arm64 + bun_target: bun-darwin-arm64 + platform: darwin + arch: arm64 + - os: windows-latest + target: win32-x64 + bun_target: bun-windows-x64 + platform: win32 + arch: x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + + - name: Download staging metadata + if: inputs.artifact-name != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: cli/release-staging/ + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.2.16' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + */node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lockb', '**/package.json') }} + restore-keys: | + ${{ runner.os }}-deps-${{ hashFiles('**/bun.lockb') }} + ${{ runner.os }}-deps- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Configure environment variables + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + ENV_OVERRIDES: ${{ inputs.env-overrides }} + shell: bash + run: | + VAR_NAMES=$(node scripts/generate-ci-env.js --prefix NEXT_PUBLIC_) + + echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' + to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value + ' >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_TOKEN=${{ secrets.CODEBUFF_GITHUB_TOKEN }}" >> $GITHUB_ENV + if [ "$ENV_OVERRIDES" != "{}" ]; then + echo "$ENV_OVERRIDES" | jq -r 'to_entries | .[] | .key + "=" + .value' >> $GITHUB_ENV + fi + + - name: Build binary + run: bun run scripts/build-binary.ts ${{ inputs.binary-name }} ${{ inputs.new-version }} + working-directory: cli + shell: bash + env: + VERBOSE: true + OVERRIDE_TARGET: ${{ matrix.bun_target }} + OVERRIDE_PLATFORM: ${{ matrix.platform }} + OVERRIDE_ARCH: ${{ matrix.arch }} + + - name: Smoke test binary + shell: bash + run: | + cd cli/bin + if [[ "${{ runner.os }}" == "Windows" ]]; then + ./${{ inputs.binary-name }}.exe --version + else + ./${{ inputs.binary-name }} --version + fi + + - name: Create tarball + shell: bash + run: | + BINARY_FILE="${{ inputs.binary-name }}" + if [[ "${{ runner.os }}" == "Windows" ]]; then + BINARY_FILE="${{ inputs.binary-name }}.exe" + fi + tar -czf codebuff-cli-${{ matrix.target }}.tar.gz -C cli/bin "$BINARY_FILE" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: codebuff-cli-${{ matrix.target }} + path: codebuff-cli-${{ matrix.target }}.tar.gz diff --git a/.github/workflows/cli-release-staging.yml b/.github/workflows/cli-release-staging.yml new file mode 100644 index 000000000..b2c7d8c1e --- /dev/null +++ b/.github/workflows/cli-release-staging.yml @@ -0,0 +1,239 @@ +name: CLI Release Staging + +on: + pull_request: + branches: ['main'] + +concurrency: + group: cli-staging-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + prepare-and-commit-staging: + runs-on: ubuntu-latest + if: contains(github.event.pull_request.title, '[codebuff-cli]') + outputs: + new_version: ${{ steps.bump_version.outputs.new_version }} + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.2.16' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + */node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lockb', '**/package.json') }} + restore-keys: | + ${{ runner.os }}-deps-${{ hashFiles('**/bun.lockb') }} + ${{ runner.os }}-deps- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Calculate staging version + id: bump_version + env: + GITHUB_TOKEN: ${{ secrets.CODEBUFF_GITHUB_TOKEN }} + run: | + cd cli/release-staging + + BASE_VERSION=$(node -e "console.log(require('./package.json').version)") + echo "Base version: $BASE_VERSION" + + echo "Fetching latest CLI prerelease from GitHub..." + RELEASES_JSON=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/CodebuffAI/codebuff/releases?per_page=100") + + LATEST_BETA=$(echo "$RELEASES_JSON" | jq -r '.[] | select(.prerelease == true and (.name // "" | test("Codebuff CLI v"))) | .tag_name' | sort -V | tail -n 1) + + if [ "$LATEST_BETA" = "null" ]; then + LATEST_BETA="" + fi + + if [ -z "$LATEST_BETA" ]; then + echo "No existing CLI beta releases found, starting with beta.1" + NEW_VERSION="${BASE_VERSION}-beta.1" + else + echo "Latest CLI beta tag: $LATEST_BETA" + LATEST_VERSION=${LATEST_BETA#v} + LATEST_BASE=$(echo "$LATEST_VERSION" | sed 's/-beta\..*$//') + LATEST_BETA_NUM=$(echo "$LATEST_VERSION" | sed 's/.*-beta\.//') + + if [ "$LATEST_BASE" = "$BASE_VERSION" ]; then + NEXT=$((LATEST_BETA_NUM + 1)) + NEW_VERSION="${BASE_VERSION}-beta.${NEXT}" + else + echo "Base version changed, resetting beta counter" + NEW_VERSION="${BASE_VERSION}-beta.1" + fi + fi + + echo "New staging version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + node -e " + const fs = require('fs'); + const path = require('path'); + const version = '$NEW_VERSION'; + const stagingPath = path.join(process.cwd(), 'package.json'); + const stagingPkg = JSON.parse(fs.readFileSync(stagingPath, 'utf8')); + stagingPkg.version = version; + fs.writeFileSync(stagingPath, JSON.stringify(stagingPkg, null, 2) + '\n'); + const rootPkgPath = path.join(process.cwd(), '..', 'package.json'); + const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf8')); + rootPkg.version = version; + fs.writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, 2) + '\n'); + " + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit staging release snapshot + run: | + git add -A + git commit -m "Staging CLI Release v${{ steps.bump_version.outputs.new_version }} [codebuff-cli] + + Captures the staged state for the CLI prerelease, including the version bump. + + 🤖 Generated with Codebuff + Co-Authored-By: Codebuff " + + - name: Create and push staging tag + run: | + git tag "v${{ steps.bump_version.outputs.new_version }}" + git push origin "v${{ steps.bump_version.outputs.new_version }}" + + - name: Upload staging metadata + uses: actions/upload-artifact@v4 + with: + name: cli-staging-metadata + path: cli/release-staging/ + + build-staging-binaries: + needs: prepare-and-commit-staging + uses: ./.github/workflows/cli-release-build.yml + with: + binary-name: codebuff-cli + new-version: ${{ needs.prepare-and-commit-staging.outputs.new_version }} + artifact-name: cli-staging-metadata + checkout-ref: ${{ github.event.pull_request.head.sha }} + env-overrides: '{}' + secrets: inherit + + create-staging-release: + needs: [prepare-and-commit-staging, build-staging-binaries] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Clean up old CLI prereleases + run: | + ONE_WEEK_AGO=$(date -d '7 days ago' -u +%Y-%m-%dT%H:%M:%SZ) + echo "Removing CLI prereleases older than: $ONE_WEEK_AGO" + + RELEASES=$(curl -s -H "Authorization: token ${{ secrets.CODEBUFF_GITHUB_TOKEN }}" \ + "https://api.github.com/repos/CodebuffAI/codebuff/releases?per_page=100") + + if echo "$RELEASES" | jq -e . >/dev/null 2>&1; then + OLD=$(echo "$RELEASES" | jq -r '.[] | select(.prerelease == true and .created_at < "'$ONE_WEEK_AGO'" and (.tag_name | test("^v[0-9].*-beta\\.[0-9]+$"))) | "\(.id):\(.tag_name)"') + + if [ -n "$OLD" ]; then + echo "Deleting old prereleases:" + echo "$OLD" + echo "$OLD" | while IFS=: read -r RELEASE_ID TAG_NAME; do + curl -s -X DELETE \ + -H "Authorization: token ${{ secrets.CODEBUFF_GITHUB_TOKEN }}" \ + "https://api.github.com/repos/CodebuffAI/codebuff/releases/$RELEASE_ID" + done + else + echo "No stale prereleases found." + fi + else + echo "Failed to parse releases response:" + echo "$RELEASES" | head -20 + fi + + - name: Download all binary artifacts + uses: actions/download-artifact@v4 + with: + path: binaries/ + + - name: Download staging metadata + uses: actions/download-artifact@v4 + with: + name: cli-staging-metadata + path: cli/release-staging/ + + - name: Create GitHub prerelease + env: + VERSION: ${{ needs.prepare-and-commit-staging.outputs.new_version }} + run: | + CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + RELEASE_BODY=$(cat < = { + 'linux-x64': { bunTarget: 'bun-linux-x64', platform: 'linux', arch: 'x64' }, + 'linux-arm64': { + bunTarget: 'bun-linux-arm64', + platform: 'linux', + arch: 'arm64', + }, + 'darwin-x64': { + bunTarget: 'bun-darwin-x64', + platform: 'darwin', + arch: 'x64', + }, + 'darwin-arm64': { + bunTarget: 'bun-darwin-arm64', + platform: 'darwin', + arch: 'arm64', + }, + 'win32-x64': { + bunTarget: 'bun-windows-x64', + platform: 'win32', + arch: 'x64', + }, + } + + const key = `${platform}-${arch}` + const target = mappings[key] + + if (!target) { + throw new Error(`Unsupported build target: ${key}`) + } + + return target +} + +async function main() { + const [, , binaryNameArg, version] = process.argv + const binaryName = binaryNameArg ?? 'codebuff-cli' + + if (!version) { + throw new Error('Version argument is required when building a binary') + } + + log(`Building ${binaryName} @ ${version}`) + + const targetInfo = getTargetInfo() + const binDir = join(cliRoot, 'bin') + + if (!existsSync(binDir)) { + mkdirSync(binDir, { recursive: true }) + } + + // Ensure SDK assets exist before compiling the CLI + log('Building SDK dependencies...') + runCommand('bun', ['run', 'build:sdk'], { cwd: cliRoot }) + + const outputFilename = + targetInfo.platform === 'win32' ? `${binaryName}.exe` : binaryName + const outputFile = join(binDir, outputFilename) + + const defineFlags = [ + ['process.env.NODE_ENV', '"production"'], + ['process.env.CODEBUFF_IS_BINARY', '"true"'], + ['process.env.CODEBUFF_CLI_VERSION', `"${version}"`], + [ + 'process.env.CODEBUFF_CLI_TARGET', + `"${targetInfo.platform}-${targetInfo.arch}"`, + ], + ] + + const buildArgs = [ + 'build', + 'src/index.tsx', + '--compile', + `--target=${targetInfo.bunTarget}`, + `--outfile=${outputFile}`, + '--sourcemap=none', + ...defineFlags.flatMap(([key, value]) => ['--define', `${key}=${value}`]), + ] + + log( + `bun ${buildArgs + .map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)) + .join(' ')}`, + ) + + runCommand('bun', buildArgs, { cwd: cliRoot }) + + if (targetInfo.platform !== 'win32') { + chmodSync(outputFile, 0o755) + } + + logAlways(`✅ Built ${outputFilename} (${targetInfo.platform}-${targetInfo.arch})`) +} + +main().catch((error: unknown) => { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(error) + } + process.exit(1) +}) diff --git a/cli/src/hooks/use-system-theme-detector.ts b/cli/src/hooks/use-system-theme-detector.ts index 46e478d92..7eae860ac 100644 --- a/cli/src/hooks/use-system-theme-detector.ts +++ b/cli/src/hooks/use-system-theme-detector.ts @@ -15,10 +15,6 @@ const DEFAULT_POLL_INTERVAL_MS = 60000 // 60 seconds * Falls back to slower polling on other platforms or if watcher fails. * * @returns The current system theme name - * - * Environment Variables: - * - OPEN_TUI_THEME_POLL_INTERVAL: Polling interval in milliseconds (default: 60000) - * Set to 0 to disable automatic polling (only affects non-macOS or if watcher fails) */ export const useSystemThemeDetector = (): ThemeName => { const [themeName, setThemeName] = useState(() => detectSystemTheme()) @@ -58,17 +54,7 @@ export const useSystemThemeDetector = (): ThemeName => { } // Fall back to polling for non-macOS or if listener failed - const envInterval = process.env.OPEN_TUI_THEME_POLL_INTERVAL - const pollIntervalMs = envInterval - ? parseInt(envInterval, 10) - : DEFAULT_POLL_INTERVAL_MS - - // If interval is 0 or invalid, disable polling - if (!pollIntervalMs || pollIntervalMs <= 0 || isNaN(pollIntervalMs)) { - return - } - - const intervalId = setInterval(handleThemeChange, pollIntervalMs) + const intervalId = setInterval(handleThemeChange, DEFAULT_POLL_INTERVAL_MS) return () => { clearInterval(intervalId) diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 2201ae765..acc0cae57 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -2,22 +2,103 @@ import './polyfills/bun-strip-ansi' import { render } from '@opentui/react' import React from 'react' +import { createRequire } from 'module' import { App } from './chat' import { clearLogFile } from './utils/logger' -function parseArgs(): { initialPrompt: string | null; clearLogs: boolean } { +const require = createRequire(import.meta.url) + +function loadPackageVersion(): string { + if (process.env.CODEBUFF_CLI_VERSION) { + return process.env.CODEBUFF_CLI_VERSION + } + + try { + const pkg = require('../package.json') as { version?: string } + if (pkg.version) { + return pkg.version + } + } catch { + // Continue to dev fallback + } + + return 'dev' +} + +const VERSION = loadPackageVersion() + +type ParsedArgs = { + initialPrompt: string | null + clearLogs: boolean + showHelp: boolean + showVersion: boolean +} + +function parseArgs(): ParsedArgs { const args = process.argv.slice(2) - const clearLogs = args.includes('--clear-logs') + let clearLogs = false + let showHelp = false + let showVersion = false + const promptParts: string[] = [] - // Filter out --clear-logs and use remaining args as the prompt - const promptArgs = args.filter((arg) => arg !== '--clear-logs') - const initialPrompt = promptArgs.length > 0 ? promptArgs.join(' ') : null + for (const arg of args) { + switch (arg) { + case '--clear-logs': + clearLogs = true + break + case '--help': + case '-h': + showHelp = true + break + case '--version': + case '-v': + showVersion = true + break + default: + promptParts.push(arg) + break + } + } - return { initialPrompt, clearLogs } + return { + initialPrompt: promptParts.length > 0 ? promptParts.join(' ') : null, + clearLogs, + showHelp, + showVersion, + } } -const { initialPrompt, clearLogs } = parseArgs() +function printHelp() { + console.log(`Codebuff CLI v${VERSION}`) + console.log('') + console.log('Usage: codebuff-cli [options] [initial prompt]') + console.log('') + console.log('Options:') + console.log(' --help, -h Show this help message and exit') + console.log(' --version, -v Print the CLI version and exit') + console.log(' --clear-logs Remove any existing CLI log files before starting') + console.log('') + console.log( + 'Provide a prompt after the options to automatically seed the first conversation.', + ) +} + +function printVersion() { + console.log(`Codebuff CLI v${VERSION}`) +} + +const { initialPrompt, clearLogs, showHelp, showVersion } = parseArgs() + +if (showVersion) { + printVersion() + process.exit(0) +} + +if (showHelp) { + printHelp() + process.exit(0) +} if (clearLogs) { clearLogFile() diff --git a/scripts/generate-ci-env.js b/scripts/generate-ci-env.js index c9a6f4926..40e9221b6 100644 --- a/scripts/generate-ci-env.js +++ b/scripts/generate-ci-env.js @@ -2,6 +2,7 @@ // Script to dynamically generate environment variables for GitHub Actions // by reading the required variables from env.ts and outputting them as a JSON array. +// Supports optional filters so callers can request only specific subsets. import fs from 'fs' import path from 'path' @@ -10,6 +11,40 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const args = process.argv.slice(2) + +function parseArgs() { + let prefix = '' + let scope = 'all' + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] + if (arg === '--prefix' && args[i + 1]) { + prefix = args[i + 1] + i += 1 + continue + } + if (arg.startsWith('--prefix=')) { + prefix = arg.split('=')[1] ?? '' + continue + } + if (arg === '--scope' && args[i + 1]) { + scope = args[i + 1] + i += 1 + continue + } + if (arg.startsWith('--scope=')) { + scope = arg.split('=')[1] ?? 'all' + } + } + + if (!['all', 'server', 'client'].includes(scope)) { + scope = 'all' + } + + return { prefix, scope } +} + function extractEnvVarsFromEnvTs() { const envTsPath = path.join( __dirname, @@ -21,33 +56,47 @@ function extractEnvVarsFromEnvTs() { ) const envTsContent = fs.readFileSync(envTsPath, 'utf8') - // Extract server and client variables from the env.ts file const serverMatch = envTsContent.match(/server:\s*{([^}]+)}/s) const clientMatch = envTsContent.match(/client:\s*{([^}]+)}/s) - const envVars = new Set() - - const extractVars = (match) => { - if (match) { - // Look for variable names followed by a colon - const vars = match[1].match(/(\w+):/g) - if (vars) { - vars.forEach((v) => { - envVars.add(v.replace(':', '')) - }) - } - } + const serverVars = new Set() + const clientVars = new Set() + + const extractVars = (match, targetSet) => { + if (!match) return + const vars = match[1].match(/(\w+):/g) + if (!vars) return + vars.forEach((v) => targetSet.add(v.replace(':', ''))) } - extractVars(serverMatch) - extractVars(clientMatch) + extractVars(serverMatch, serverVars) + extractVars(clientMatch, clientVars) - return Array.from(envVars).sort() + return { + server: Array.from(serverVars), + client: Array.from(clientVars), + } } function generateGitHubEnv() { - const envVars = extractEnvVarsFromEnvTs() - console.log(JSON.stringify(envVars)) + const { prefix, scope } = parseArgs() + const varsByScope = extractEnvVarsFromEnvTs() + + let selected = [] + if (scope === 'server') { + selected = varsByScope.server + } else if (scope === 'client') { + selected = varsByScope.client + } else { + selected = Array.from(new Set([...varsByScope.server, ...varsByScope.client])) + } + + if (prefix) { + selected = selected.filter((name) => name.startsWith(prefix)) + } + + selected.sort() + console.log(JSON.stringify(selected)) } generateGitHubEnv()