diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..1e6267b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,38 @@ +pipeline { + environment { + registry = 'https://registry.hub.docker.com' + registryCredential = 'dockerhub_jcabillot' + dockerImage = 'jcabillot/openchamber' + } + + agent any + + triggers { + cron('@midnight') + } + + stages { + stage('Clone repository') { + steps{ + checkout scm + } + } + + stage('Build image') { + steps{ + sh 'docker build --force-rm=true --no-cache=true --pull -f src/Dockerfile -t ${dockerImage} src/' + } + } + + stage('Deploy Image') { + steps{ + script { + withCredentials([usernamePassword(credentialsId: 'dockerhub_jcabillot', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh 'docker login --username ${DOCKER_USER} --password ${DOCKER_PASS}' + sh 'docker push ${dockerImage}' + } + } + } + } + } +} diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 0000000..3ba6be7 --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,26 @@ +.git +.gitignore +node_modules +**/node_modules +dist +**/dist +build +**/build +.DS_Store +.idea +.vscode +coverage +tmp +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.env +.env.* +.opencode +data +workspaces +packages/desktop/src-tauri +packages/desktop/target +packages/intellij diff --git a/src/.github/CODEOWNERS b/src/.github/CODEOWNERS new file mode 100644 index 0000000..af8017b --- /dev/null +++ b/src/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# .github/CODEOWNERS +* @btriapitsyn diff --git a/src/.github/ISSUE_TEMPLATE/bug_report.yml b/src/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6a62a81 --- /dev/null +++ b/src/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Report something broken +title: "[Bug] " +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What's wrong? Maybe some steps to reproduce. + description: What happened and what you expected. + placeholder: "Expected … but got …" + validations: + required: true + + - type: dropdown + id: runtime + attributes: + label: Where does it happen? + options: + - Desktop (macOS) + - Desktop Web + - Mobile (Web/PWA) + - VS Code extension + - Not sure + validations: + required: true + + - type: input + id: version + attributes: + label: Version (if known) + placeholder: "e.g. 1.2.3" + + - type: textarea + id: screenshots + attributes: + label: Screenshots / recordings (optional) + description: "Anything visual that helps." + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: "Paste relevant logs." + render: shell diff --git a/src/.github/ISSUE_TEMPLATE/config.yml b/src/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/src/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/src/.github/ISSUE_TEMPLATE/feature_request.yml b/src/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..d58d57e --- /dev/null +++ b/src/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest an improvement +title: "[Feature Request] " +labels: [enhancement] +body: + - type: textarea + id: request + attributes: + label: What should we add/change? + description: What you're trying to do + what you'd like to happen. + placeholder: | + I'm trying to … + + It would be great if OpenChamber … + validations: + required: true + + - type: textarea + id: context + attributes: + label: Extra context (optional) + description: Links, screenshots, mockups, constraints, etc. diff --git a/src/.github/workflows/build-macos-arm64-dmg.yml b/src/.github/workflows/build-macos-arm64-dmg.yml new file mode 100644 index 0000000..551ccca --- /dev/null +++ b/src/.github/workflows/build-macos-arm64-dmg.yml @@ -0,0 +1,125 @@ +name: Build macOS DMG (arm64) + +on: + workflow_dispatch: + inputs: + macos_version: + description: macOS runner version + required: true + type: choice + options: + - "macos-15" + - "macos-26" + default: "macos-15" + ref: + description: Git ref to build (branch, tag, or sha) + required: false + default: "" + +env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: short + +jobs: + build-macos-dmg-arm64: + name: Build DMG (arm64, ${{ inputs.macos_version }}) + runs-on: ${{ inputs.macos_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: packages/desktop/src-tauri + key: aarch64-apple-darwin + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Apple Certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Set up notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + run: | + if [ -z "$APPLE_ID" ] || [ -z "$APPLE_TEAM_ID" ] || [ -z "$APPLE_PASSWORD" ]; then + echo "Error: Missing Apple notarization credentials" + exit 1 + fi + + xcrun notarytool store-credentials "openchamber-notarize" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_PASSWORD" + + - name: Build UI package + run: bun run --cwd packages/ui build + + - name: Build Desktop app (arm64) + run: bun run --cwd packages/desktop build && bun run --cwd packages/desktop tauri build --target aarch64-apple-darwin + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Prepare DMG artifact + run: | + set -euo pipefail + mkdir -p artifacts + DMG_PATH="packages/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg" + if ls $DMG_PATH 1> /dev/null 2>&1; then + DMG_FILE=$(ls $DMG_PATH | head -n 1) + DMG_NAME="OpenChamber_${{ inputs.macos_version }}_arm64.dmg" + cp "$DMG_FILE" "artifacts/$DMG_NAME" + else + echo "Error: DMG file not found at $DMG_PATH" + exit 1 + fi + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: dmg-${{ inputs.macos_version }}-arm64 + path: artifacts/*.dmg + retention-days: 7 diff --git a/src/.github/workflows/docs-source.yml b/src/.github/workflows/docs-source.yml new file mode 100644 index 0000000..05feca6 --- /dev/null +++ b/src/.github/workflows/docs-source.yml @@ -0,0 +1,86 @@ +name: Docs Source + +on: + push: + branches: [main] + paths: + - "packages/docs/**" + - "scripts/docs/**" + - "package.json" + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Optional existing tag to upload docs source archive" + required: false + type: string + +permissions: + contents: write + +jobs: + validate-and-package: + runs-on: ubuntu-latest + outputs: + archive_name: ${{ steps.archive.outputs.archive_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Validate docs source + run: bun run docs:validate + + - name: Build docs source archive + id: archive + run: | + mkdir -p artifacts + ARCHIVE_NAME="openchamber-docs-source-${GITHUB_SHA::8}.tar.gz" + tar -czf "artifacts/${ARCHIVE_NAME}" -C packages/docs . + echo "archive_name=${ARCHIVE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: docs-source + path: artifacts/${{ steps.archive.outputs.archive_name }} + retention-days: 14 + + - name: Upload archive to release tag + if: ${{ github.event_name == 'release' || github.event.inputs.release_tag != '' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }} + files: artifacts/${{ steps.archive.outputs.archive_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger openchamber-website docs sync (optional) + if: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} + env: + WEBSITE_REPO: openchamber/openchamber-website + WEBSITE_TOKEN: ${{ secrets.OPENCHAMBER_WEBSITE_REPO_TOKEN }} + SOURCE_REF: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref_name }} + run: | + if [ -z "$WEBSITE_TOKEN" ]; then + echo "OPENCHAMBER_WEBSITE_REPO_TOKEN not set; skip dispatch." + exit 0 + fi + + curl -sS -X POST \ + -H "Authorization: Bearer $WEBSITE_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/$WEBSITE_REPO/dispatches \ + -d @- <> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + else + echo "version=0.0.0-dev" >> $GITHUB_OUTPUT + fi + + - name: Extract changelog for release + env: + VERSION: ${{ steps.get_version.outputs.version }} + run: | + node - <<'NODE' + const fs = require('fs'); + const version = process.env.VERSION; + const changelogPath = 'CHANGELOG.md'; + if (!fs.existsSync(changelogPath)) { + throw new Error('CHANGELOG.md not found; add it before releasing.'); + } + const changelog = fs.readFileSync(changelogPath, 'utf8'); + const sections = changelog.split(/^## /m); + const section = sections.find(s => s.startsWith('[' + version + ']')); + if (!section) { + throw new Error('Changelog section [' + version + '] not found. Add a section like "## [' + version + '] - YYYY-MM-DD".'); + } + const content = ('## ' + section).trim(); + fs.mkdirSync('artifacts', { recursive: true }); + fs.writeFileSync('artifacts/release-notes.md', content + '\n'); + NODE + + - name: Create GitHub Release + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.get_version.outputs.version }} + draft: true + generate_release_notes: false + body_path: artifacts/release-notes.md + name: OpenChamber v${{ steps.get_version.outputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-desktop-macos: + needs: create-release + runs-on: macos-26 + strategy: + fail-fast: false + matrix: + target: [aarch64-apple-darwin, x86_64-apple-darwin] + include: + - target: aarch64-apple-darwin + arch: aarch64 + platform: darwin-aarch64 + - target: x86_64-apple-darwin + arch: x86_64 + platform: darwin-x86_64 + outputs: + version: ${{ needs.create-release.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: packages/desktop/src-tauri + key: ${{ matrix.target }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Apple Certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Set up notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + run: | + # Validate secrets are set + if [ -z "$APPLE_ID" ] || [ -z "$APPLE_TEAM_ID" ] || [ -z "$APPLE_PASSWORD" ]; then + echo "Error: Missing Apple notarization credentials" + exit 1 + fi + + xcrun notarytool store-credentials "openchamber-notarize" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_PASSWORD" + + - name: Build UI package + run: bun run --cwd packages/ui build + + - name: Build Desktop app + # Note: We use inline commands instead of desktop:build to pass architecture-specific --target flag + # This enables cross-compilation for both arm64 and x86_64 from the same runner + run: | + export TAURI_ENV_TARGET_TRIPLE=${{ matrix.target }} + bun run --cwd packages/desktop build + bun run --cwd packages/desktop tauri build --target ${{ matrix.target }} + env: + TAURI_ENV_TARGET_TRIPLE: ${{ matrix.target }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Verify binary architectures + run: | + set -euo pipefail + + BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos" + + if [ ! -d "$BUNDLE_DIR" ]; then + echo "❌ Error: bundle directory not found: $BUNDLE_DIR" + exit 1 + fi + + APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit) + if [ -z "$APP_PATH" ]; then + echo "❌ Error: .app bundle not found under $BUNDLE_DIR" + exit 1 + fi + + echo "🔍 Verifying binary architectures in $APP_PATH" + + # Extract raw architecture names (macOS file command reports ARM as "arm64") + MAIN_ARCH_RAW=$(file "$APP_PATH/Contents/MacOS/openchamber-desktop" | grep -oE 'arm64|x86_64|aarch64' | head -1) + SIDEARCH_ARCH_RAW=$(file "$APP_PATH/Contents/MacOS/openchamber-server" | grep -oE 'arm64|x86_64|aarch64' | head -1) + + # Normalize architecture names (arm64 -> aarch64 for consistency with Rust/Tauri) + normalize_arch() { + case "$1" in + arm64) echo "aarch64" ;; + aarch64|x86_64) echo "$1" ;; + *) echo "unknown" ;; + esac + } + + MAIN_ARCH=$(normalize_arch "$MAIN_ARCH_RAW") + SIDEARCH_ARCH=$(normalize_arch "$SIDEARCH_ARCH_RAW") + EXPECTED_ARCH=$(echo "${{ matrix.target }}" | grep -oE 'aarch64|x86_64' | head -1) + + echo " Main: $MAIN_ARCH_RAW → $MAIN_ARCH" + echo " Sidecar: $SIDEARCH_ARCH_RAW → $SIDEARCH_ARCH" + echo " Expected: $EXPECTED_ARCH" + + if [ "$MAIN_ARCH" != "$EXPECTED_ARCH" ]; then + echo "❌ ERROR: Main binary architecture mismatch!" + echo " Expected: $EXPECTED_ARCH" + echo " Got: $MAIN_ARCH (raw: $MAIN_ARCH_RAW)" + exit 1 + fi + + if [ "$SIDEARCH_ARCH" != "$EXPECTED_ARCH" ]; then + echo "❌ ERROR: Sidecar binary architecture mismatch!" + echo " Expected: $EXPECTED_ARCH" + echo " Got: $SIDEARCH_ARCH (raw: $SIDEARCH_ARCH_RAW)" + exit 1 + fi + + echo "✅ Architecture verification passed: both binaries match $EXPECTED_ARCH" + + - name: Verify macOS entitlements + run: | + set -euo pipefail + + BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos" + + if [ ! -d "$BUNDLE_DIR" ]; then + echo "Error: bundle directory not found: $BUNDLE_DIR" + exit 1 + fi + + APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit) + if [ -z "$APP_PATH" ]; then + echo "Error: .app bundle not found under $BUNDLE_DIR" + echo "Contents:"; ls -la "$BUNDLE_DIR" + exit 1 + fi + + echo "Verifying app bundle: $APP_PATH" + codesign -vv "$APP_PATH" + + ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true) + echo "$ENTITLEMENTS" + + if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then + echo "Error: app sandbox entitlement is present" + exit 1 + fi + + for key in \ + com.apple.security.cs.allow-jit \ + com.apple.security.cs.allow-unsigned-executable-memory \ + com.apple.security.cs.disable-executable-page-protection \ + com.apple.security.cs.disable-library-validation + do + if ! echo "$ENTITLEMENTS" | grep -q "$key"; then + echo "Error: required entitlement missing: $key" + exit 1 + fi + done + + - name: Prepare release artifacts + run: | + mkdir -p artifacts + VERSION="${{ needs.create-release.outputs.version }}" + + # Copy DMG (Tauri names it with the target triple in the path) + DMG_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg" + if ls $DMG_PATH 1> /dev/null 2>&1; then + DMG_FILE=$(ls $DMG_PATH | head -n 1) + DMG_NAME="OpenChamber_${VERSION}_${{ matrix.platform }}.dmg" + cp "$DMG_FILE" "artifacts/$DMG_NAME" + else + echo "Error: DMG file not found at $DMG_PATH" + exit 1 + fi + + # Copy tar.gz and signature for updater + TAR_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz" + SIG_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz.sig" + + if ls $TAR_PATH 1> /dev/null 2>&1; then + TAR_FILE=$(ls $TAR_PATH | head -n 1) + TAR_BASE=$(basename "$TAR_FILE") + TAR_NAME="${TAR_BASE%.tar.gz}-${{ matrix.platform }}.tar.gz" + cp "$TAR_FILE" "artifacts/$TAR_NAME" + else + echo "Error: tar.gz file not found at $TAR_PATH" + exit 1 + fi + + if ls $SIG_PATH 1> /dev/null 2>&1; then + SIG_FILE=$(ls $SIG_PATH | head -n 1) + SIG_BASE=$(basename "$SIG_FILE") + SIG_NAME="${SIG_BASE%.tar.gz.sig}-${{ matrix.platform }}.tar.gz.sig" + cp "$SIG_FILE" "artifacts/$SIG_NAME" + else + echo "Error: signature file not found at $SIG_PATH" + exit 1 + fi + + echo "Successfully prepared artifacts:" + ls -lh artifacts/ + + - name: Generate update manifest + run: | + VERSION="${{ needs.create-release.outputs.version }}" + + # Find the signature file for this platform + SIG_FILE=$(find artifacts -name "*-${{ matrix.platform }}.tar.gz.sig" | head -1) + if [ -f "$SIG_FILE" ]; then + SIGNATURE=$(cat "$SIG_FILE") + else + SIGNATURE="" + fi + + # Find the tar.gz file name for this platform + TAR_FILE=$(find artifacts -name "*-${{ matrix.platform }}.tar.gz" ! -name "*.sig" | head -1) + TAR_NAME=$(basename "$TAR_FILE" 2>/dev/null || echo "OpenChamber-${{ matrix.platform }}.app.tar.gz") + + cat > artifacts/latest-${{ matrix.platform }}.json << EOF + { + "version": "${VERSION}", + "notes": "See release notes at https://github.com/${{ github.repository }}/releases/tag/v${VERSION}", + "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "platforms": { + "${{ matrix.platform }}": { + "signature": "${SIGNATURE}", + "url": "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${TAR_NAME}" + } + } + } + EOF + + echo "Generated latest-${{ matrix.platform }}.json:" + cat artifacts/latest-${{ matrix.platform }}.json + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + files: | + artifacts/*.dmg + artifacts/*.tar.gz + artifacts/*.tar.gz.sig + artifacts/latest-${{ matrix.platform }}.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload manifest as artifact + uses: actions/upload-artifact@v4 + with: + name: manifest-${{ matrix.platform }} + path: artifacts/latest-${{ matrix.platform }}.json + retention-days: 1 + + publish-npm: + needs: create-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build packages + run: bun run build + + - name: Create npm tarball + working-directory: packages/web + run: npm pack + + - name: Upload npm tarball to release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + files: packages/web/*.tgz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to npm + if: ${{ github.event.inputs.dry_run != 'true' }} + working-directory: packages/web + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + combine-manifests: + needs: [create-release, build-desktop-macos] + runs-on: ubuntu-latest + steps: + - name: Download aarch64 manifest + uses: actions/download-artifact@v4 + with: + name: manifest-darwin-aarch64 + path: artifacts + + - name: Download x86_64 manifest + uses: actions/download-artifact@v4 + with: + name: manifest-darwin-x86_64 + path: artifacts + + - name: Combine manifests + run: | + VERSION="${{ needs.create-release.outputs.version }}" + PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + REPO="${{ github.repository }}" + + # Validate that both manifest files exist and are valid JSON + if [ ! -f artifacts/latest-darwin-aarch64.json ]; then + echo "Error: aarch64 manifest not found" + exit 1 + fi + + if [ ! -f artifacts/latest-darwin-x86_64.json ]; then + echo "Error: x86_64 manifest not found" + exit 1 + fi + + # Validate JSON structure + if ! jq empty artifacts/latest-darwin-aarch64.json 2>/dev/null; then + echo "Error: aarch64 manifest is not valid JSON" + exit 1 + fi + + if ! jq empty artifacts/latest-darwin-x86_64.json 2>/dev/null; then + echo "Error: x86_64 manifest is not valid JSON" + exit 1 + fi + + # Validate platform data exists in manifests + if ! jq -e '.platforms["darwin-aarch64"]' artifacts/latest-darwin-aarch64.json > /dev/null; then + echo "Error: darwin-aarch64 platform data not found in manifest" + exit 1 + fi + + if ! jq -e '.platforms["darwin-x86_64"]' artifacts/latest-darwin-x86_64.json > /dev/null; then + echo "Error: darwin-x86_64 platform data not found in manifest" + exit 1 + fi + + # Use jq to properly combine the manifests + jq -n \ + --arg version "$VERSION" \ + --arg notes "See release notes at https://github.com/${REPO}/releases/tag/v${VERSION}" \ + --arg pub_date "$PUB_DATE" \ + --slurpfile aarch64 artifacts/latest-darwin-aarch64.json \ + --slurpfile x86_64 artifacts/latest-darwin-x86_64.json \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: { + "darwin-aarch64": $aarch64[0].platforms["darwin-aarch64"], + "darwin-x86_64": $x86_64[0].platforms["darwin-x86_64"] + } + }' > artifacts/latest.json + + echo "Generated combined latest.json:" + cat artifacts/latest.json + + - name: Upload combined manifest + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + files: artifacts/latest.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + finalize-release: + needs: [create-release, build-desktop-macos, publish-npm, combine-manifests] + runs-on: ubuntu-latest + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + steps: + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + draft: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Send release to Discord + if: ${{ env.DISCORD_WEBHOOK_URL != '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.create-release.outputs.version }} + REPOSITORY: ${{ github.repository }} + run: | + node - <<'NODE' + (async () => { + const tag = `v${process.env.VERSION}`; + const repo = process.env.REPOSITORY; + + const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + }); + + if (!releaseRes.ok) { + const body = await releaseRes.text(); + throw new Error(`Failed to fetch release ${tag}: ${releaseRes.status} ${body}`); + } + + const release = await releaseRes.json(); + const description = (release.body || `OpenChamber ${tag} released.`).slice(0, 4096); + + const payload = { + username: 'OpenChamber Releases', + embeds: [ + { + title: release.name || `OpenChamber ${tag}`, + url: release.html_url, + description, + color: 2105893, + footer: { text: 'OpenChamber Changelog' }, + }, + ], + }; + + const discordRes = await fetch(process.env.DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!discordRes.ok) { + const body = await discordRes.text(); + throw new Error(`Failed to send Discord release: ${discordRes.status} ${body}`); + } + })().catch((error) => { + console.error(error); + process.exit(1); + }); + NODE + + - name: Trigger openchamber-website site refresh (optional) + env: + WEBSITE_REPO: openchamber/openchamber-website + WEBSITE_TOKEN: ${{ secrets.OPENCHAMBER_WEBSITE_REPO_TOKEN }} + VERSION: ${{ needs.create-release.outputs.version }} + run: | + if [ -z "$WEBSITE_TOKEN" ]; then + echo "OPENCHAMBER_WEBSITE_REPO_TOKEN not set; skip site refresh dispatch." + exit 0 + fi + + curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer $WEBSITE_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/$WEBSITE_REPO/dispatches \ + -d @- </dev/null || git rev-list --max-parents=0 HEAD); echo "Base: $BASE"; echo "Commits since base: $(git rev-list --count "$BASE"..HEAD)"; echo "Diff stats: $(git diff --shortstat "$BASE"..HEAD)"; echo; echo "=== Top 30 commits ==="; git log --oneline -30 "$BASE"..HEAD; echo; echo "=== Changed files ==="; git diff --stat "$BASE"..HEAD` + +Additional hints (optional, use only if needed): +- If there are breaking changes or user-visible behavior changes, call them out first. +- If changes are mostly internal refactors, summarize them as reliability/performance improvements. + +Now: +1) Propose the new `[Unreleased]` bullet list for the main @CHANGELOG.md. +2) Propose the VS Code-specific `[Unreleased]` list for @packages/vscode/CHANGELOG.md. +3) Edit both files to update their respective `[Unreleased]` sections. diff --git a/src/.opencode/skills/clack-cli-patterns/SKILL.md b/src/.opencode/skills/clack-cli-patterns/SKILL.md new file mode 100644 index 0000000..23e9468 --- /dev/null +++ b/src/.opencode/skills/clack-cli-patterns/SKILL.md @@ -0,0 +1,216 @@ +--- +name: clack-cli-patterns +description: Use when creating or modifying terminal CLI commands, prompts, or output formatting in OpenChamber. Enforces Clack UX standards with strict parity and safety across TTY/non-TTY, --quiet, and --json modes. +license: MIT +compatibility: opencode +--- + +## Overview + +OpenChamber terminal CLI uses `@clack/prompts` for interactive UX, but command policy and validation must be mode-agnostic. + +**Core principle:** policy-first, UX-second. Clack is presentation, not enforcement. + +## Scope + +Use this skill for terminal CLI work only (for example `packages/web/bin/*`). + +Do not use this skill for web UI or VS Code webview styling work. + +## Mandatory Rules + +1. **Validation first** + - Safety and correctness checks must run in all modes. + - Prompts may help collect input, but cannot be the only guard. + +2. **Mode parity is required** + - Behavior must be equivalent in: + - Interactive TTY + - Non-interactive shells + - `--quiet` + - `--json` + - Fully pre-specified flags + - Invalid operations must fail deterministically with non-zero exit code. + +3. **Prompt guard contract** + - Only prompt when all are true: + - stdout is TTY + - not `--quiet` + - not `--json` + - not automated/non-interactive context + +4. **Output contract** + - `--json`: machine-readable output only. + - `--quiet`: suppress non-essential output only. + - Neither mode weakens policy enforcement. + +5. **Cancellation contract** + - Handle prompt cancellation with `isCancel` + `cancel(...)`. + - Handle SIGINT cleanly and use consistent exit semantics. + +## Clack Primitive Standard + +- **Flow framing:** `intro`, `outro`, `cancel` +- **Status lines:** `log.info`, `log.success`, `log.warn`, `log.error`, `log.step` +- **Guidance blocks:** + - default: `note` + - high-severity warnings only: `box` +- **Prompts:** `select`, `confirm`, `text`, `password` +- **Long-running feedback:** + - unknown duration: `spinner` + - known duration: `progress` + - multi-stage: `tasks` + +## Preferred Pattern + +Centralize Clack imports and formatting helpers in one adapter module (for example `cli-output.js`) so command logic stays focused on behavior and policy. + +### Thin framework (recommended) + +Use a small shared helper surface rather than command-specific formatting logic. + +- `isJsonMode(options)` +- `isQuietMode(options)` +- `shouldRenderHumanOutput(options)` +- `canPrompt(options)` +- `createSpinner(options)` +- `createProgress(options, config)` +- `printJson(payload)` + +Keep this layer minimal. Do not hide core validation or command semantics inside output helpers. + +## Output Contracts by Mode + +### `--quiet` contract + +`--quiet` should still return essential result data. + +- Read/list commands: emit concise machine-friendly lines (not framed Clack blocks). +- Action commands: emit one minimal success line and concise errors. +- Do not suppress required outcomes entirely. + +Quiet output should still be complete enough for scripts and quick human scanning. + +- Status-like commands should list all active items, not only `running`/`ok`. +- Prefer compact stable key tokens in quiet lines (for example `port 3000 pass:yes`). + +### `--json` contract (strict) + +- Output must be JSON only (no extra text before/after payload). +- Warnings/info should be represented in JSON fields (for example `status`, `messages`). +- Preserve non-zero exit codes for failures. + +## Human UX Consistency + +### Framing completeness + +- If human flow uses `intro`, close with `outro` (or `outro('')` when you want structure without text). +- Avoid orphan frame/spinner artifacts (prefer `spinner.clear()` when a trailing spinner line is not wanted). +- If a structured summary section immediately follows a spinner, prefer `spinner.clear()` to avoid duplicate success lines. + +### Progress feedback for visible operations + +- For operations users wait on (start/stop/restart/tunnel lifecycle), show in-progress spinner in interactive mode. +- Resolve each spinner explicitly to done/error so users can see completion state at the same visual location. +- Keep quiet/json modes non-animated. + +### Prompt flow design + +- Ask required inputs in dependency order (for example hostname before token when token depends on chosen host/mode context). +- When offering save-vs-run flows, ask intent before collecting optional metadata (for example profile name only if user chooses save). +- Prefill editable values with `initialValue` (not only `placeholder`) so users can accept or edit quickly. +- Reuse latest relevant values when safe (for example last managed-local config path, last managed-remote hostname). + +### Readability on narrow terminals + +- Prefer short lines. +- Split long guidance into multiple detail lines. +- Use warning/info codes (`[CODE]`) when the message has follow-up docs or repeat use. + +### Guidance tone + +- Use `Optional Tips` for non-required next actions. +- Avoid wording that implies mandatory follow-up unless it is truly required. + +### Guidance rendering style (preferred) + +- Prefer structured status lines for reusable hints: + - `logStatus('info', '[CODE]', '')` +- Use short, stable codes (for example `[START_PROFILE]`, `[PORT_MISMATCH]`) so users can quickly scan and recognize repeated guidance. +- Prefer this style over boxed notes for routine follow-up actions. +- Reserve `note`/boxed callouts for rare, high-context guidance where a long paragraph is truly necessary. + +## Parity Verification Matrix + +For each command/subcommand, manually verify: + +1. default interactive TTY output +2. `--quiet` output (minimal but informative) +3. `--json` output (JSON-only) +4. non-TTY behavior (e.g. piped) +5. error path in both human and json modes + +## Copy/Paste Snippets + +### Prompt Guard + +```js +if (canPrompt(options)) { + const value = await select({ + message: 'Choose an option', + options: [{ value: 'a', label: 'Option A' }], + }); + if (isCancel(value)) { + cancel('Operation cancelled.'); + return; + } +} +``` + +### Non-Interactive Fallback + +```js +if (!resolvedValue) { + if (canPrompt(options)) { + // prompt path + } else { + throw new Error('Missing required value. Provide --flag .'); + } +} +``` + +### Spinner Guard + +```js +const spin = createSpinner(options); +spin?.start('Running operation...'); +// ...work... +spin?.stop('Done'); +``` + +### JSON vs Human Output + +```js +if (options.json) { + printJson({ ok: true, data }); + return; +} + +intro('Operation'); +log.success('Completed'); +outro('done'); +``` + +## Implementation Checklist + +1. Add or update core validators first. +2. Ensure validators execute in all modes. +3. Add interactive Clack UX only as enhancement. +4. Verify parity between interactive and non-interactive flows. +5. Ensure script-safe deterministic failure behavior. + +## References + +- Policy source: `AGENTS.md` (CLI Parity and Safety Policy) +- Terminal CLI precedent: `packages/web/bin/cli.js` +- Output adapter precedent: `packages/web/bin/cli-output.js` diff --git a/src/.opencode/skills/settings-ui-patterns/SKILL.md b/src/.opencode/skills/settings-ui-patterns/SKILL.md new file mode 100644 index 0000000..0fc9276 --- /dev/null +++ b/src/.opencode/skills/settings-ui-patterns/SKILL.md @@ -0,0 +1,238 @@ +--- +name: settings-ui-patterns +description: Use when creating or modifying UI components, styling, or visual elements related to Settings in OpenChamber. +license: MIT +compatibility: opencode +--- + +# Settings UI Patterns Skill + +## Purpose +This skill provides instructions for creating or redesigning Settings pages, informational panels, and configuration interfaces within the OpenChamber application. + +## Current Canonical Look (2026) +Use this as source of truth for new settings UI work. + +- **Flat hierarchy first**: Prefer spacing + typography hierarchy over boxed backgrounds. +- **No unnecessary wrappers**: Avoid extra section wrappers that mix unrelated controls. +- **No redundant section titles**: Do not add headers like `Theme Preferences` or `Scaling & Layout` when controls are already self-explanatory. +- **Compact controls**: Option chips and radio rows should be dense, not tall. +- **Left-leading state icon**: Radio/checkbox state icon appears before text. +- **Subtle state contrast**: Inactive radio labels should be visibly dimmer than active labels. +- **Minimal row chrome**: Avoid row hover/background highlighting by default; keep only where explicitly needed. + +## Typography Guidelines +Always utilize the standard OpenChamber typography classes defined in `packages/ui/src/lib/typography.ts`. + +- **Page Title**: Use `typography-ui-header font-semibold text-foreground` for the top-most title of a settings page/dialog. +- **Section Header**: Use `typography-ui-header font-medium text-foreground` for settings sections (e.g. `Notification Events`, `Session Defaults`). +- **Control Group Header**: Use `typography-ui-header font-medium text-foreground` (or `font-normal` if it reads too loud) for grouped controls inside a section (e.g. `Default Tool Output`, `Diff Layout`). +- **Values / Primary Text**: Use `typography-ui-label text-foreground`. Add `tabular-nums` if displaying numbers or stats to ensure vertical alignment. +- **Option Labels**: Use non-bold label text in compact option controls (`font-normal` when needed to override). +- **Meta / Helper Text**: Use `typography-meta text-muted-foreground` or `typography-small text-muted-foreground` for supplemental text. + +## Layout and Spacing Patterns + +### 1. Main Backgrounds +Main wrappers should generally use `bg-background` or `bg-[var(--surface-background)]`. Ensure adequate padding (e.g., `px-5 py-6` or `p-6`). + +### 2. Subsection Grouping +Group related controls with vertical spacing, not mandatory cards. + +- Use `space-y-3` between logical subsections. +- Use `p-2` for subsection internal padding. +- Avoid adding `bg-[var(--surface-elevated)]` unless there is a clear reason. +- Avoid extra row decorations (`rounded-md`, hover fills) unless there is explicit UX value. + +### 3. Header-to-Content Hierarchy (critical) +When removing cards/background wrappers, spacing must be rebalanced so header ownership stays clear. + +- Keep **section-to-section spacing larger** than **header-to-own-content spacing**. +- Typical pattern: + - header wrapper `mb-1 px-1` + - content wrapper `pt-0 pb-2 px-2` + - outer section spacing `mb-8` +- Do not leave legacy `mb-3` style gaps after flattening a section; it makes headers look detached. + +### 4. Headerless Blocks (when context is obvious) +If the page title already provides enough context, remove redundant local headers and place controls directly below the title. + +- Example: project page identity controls can sit directly under project name/path. +- Tighten top gap for this pattern (e.g. top header `mb-4` instead of larger section spacing). + +```tsx +
+
...
+
...
+
+``` + +## Structural Patterns + +### 1. Segmented Option Buttons (compact) +Use for short option sets where button-style segmented choice reads best (e.g. Default Tool Output). + +```tsx +
+ + Collapsed + +
+``` + +### 2. Radio Option Lists (compact rows) +Use for mutually exclusive mode/layout settings (e.g. Diff Layout, Diff View Mode). + +- Use shared `Radio` component from `@/components/ui/radio`. +- Icon first, label second. +- Row container compact: `py-0.5`. +- Inactive label can use `text-foreground/50`. + +```tsx +
+
+ + Dynamic +
+
+``` + +### 3. Checkbox Setting Rows +Use shared `Checkbox` component from `@/components/ui/checkbox` for boolean toggles. + +- Icon first, text immediately after (`gap-2`). +- Typical row spacing for checkbox rows: `py-1.5`. +- Keep row click and keyboard toggle support. +- Prefer checkbox over binary show/hide button pairs for pure boolean state. + +```tsx +
+ + Show Dotfiles +
+``` + +### 4. Invisible Two-Column Alignment +Use consistent label/control columns across settings rows so controls align on a shared vertical line. + +- Desktop row pattern: `flex items-center gap-8` +- Label column width: `w-56 shrink-0` +- Control cluster: `w-fit` + +```tsx +
+ Interface Font Size +
...
+
+``` + +#### Disabled control rule +If a control is unavailable, disable the control only. Do not dim the label row by default. + +#### Width-matching rule +When matching visual widths across different rows, compare full row footprint (control + adjacent action buttons), not just input width. + +### 5. Theme Row Composition +For theme controls in Appearance: + +- `Color Mode` header on first line; option chips below it. +- `Light Theme` and `Dark Theme` on one row where possible, wrapping on small widths. +- Keep selectors near labels and aligned to existing column rhythm. +- Replace persistent helper text with an info tooltip icon near the related action. + +```tsx +
+
Light Theme ...
+
Dark Theme ...
+
+``` + +### 6. Numeric Controls in Settings +Use compact stepper input (`- value +`) plus reset button. + +- Prefer shared `NumberInput` stepper style over slider + numeric combo in dense settings pages. +- Keep reset button adjacent to control (`gap-2`). +- Avoid using Tailwind `overflow-hidden` on mobile for controls; `packages/ui/src/styles/mobile.css` forces `.overflow-hidden { overflow-y: auto !important; }`. + Use `overflow-x-hidden overflow-y-hidden` if you truly need clipping. +- Touch devices: `packages/ui/src/styles/mobile.css` enforces `min-height: 36px` on `button`. If you build custom segmented controls with `