diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 2275886..4643e33 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -3,20 +3,22 @@ name: Create release branch on: workflow_dispatch: inputs: - bump_type: - description: "Version bump type (applies to all affected libs)" + major_version: + description: "Major version (X in release/X.Y.x)" + required: true + type: string + minor_version: + description: "Minor version (Y in release/X.Y.x)" + required: true + type: string + base_branch: + description: "Base branch to create from" required: false - type: choice - default: "auto" - options: - - auto # Use Codex AI (existing behavior) - - patch - - minor - - major + type: string + default: "main" permissions: contents: write - pull-requests: write jobs: create: @@ -24,24 +26,35 @@ jobs: environment: release env: NX_DAEMON: "false" - CODEX_OUTPUT: .codex-release/release-output.json - # Core libs that always release together with unified versioning - CORE_LIBS: "ast-guard,enclave-vm" steps: + - name: Validate inputs + shell: bash + run: | + set -euo pipefail + + MAJOR="${{ inputs.major_version }}" + MINOR="${{ inputs.minor_version }}" + + # Validate major version is a number + if ! [[ "$MAJOR" =~ ^[0-9]+$ ]]; then + echo "::error::Major version must be a positive integer. Got: $MAJOR" + exit 1 + fi + + # Validate minor version is a number + if ! [[ "$MINOR" =~ ^[0-9]+$ ]]; then + echo "::error::Minor version must be a positive integer. Got: $MINOR" + exit 1 + fi + + echo "Creating release branch for version $MAJOR.$MINOR.x" + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - # Fail fast if env secret is not available (only for auto mode) - - name: Ensure CODEX_OPENAI_KEY secret is set - if: github.event.inputs.bump_type == 'auto' || github.event.inputs.bump_type == '' - run: | - if [ -z "${{ secrets.CODEX_OPENAI_KEY }}" ]; then - echo "::error::CODEX_OPENAI_KEY (env: release) is not set. Add it under Settings → Environments → release → Secrets." >&2 - exit 1 - fi + ref: ${{ inputs.base_branch }} - name: Setup Node uses: actions/setup-node@v6 @@ -51,533 +64,179 @@ jobs: registry-url: "https://registry.npmjs.org/" - name: Install deps - run: yarn + run: yarn install --frozen-lockfile - name: Configure git user run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Find affected projects with unified versioning for core libs - id: affected + - name: Check if branch already exists + id: check_branch shell: bash run: | set -euo pipefail - FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) - - # Core libs use unified versioning - if any has changes, all are released together - CORE_LIBS="${{ env.CORE_LIBS }}" - echo "Core libs (unified versioning): $CORE_LIBS" - - # Get all publishable libs - ALL_LIBS=$(node -e " - const { execSync } = require('child_process'); - try { - const out = execSync('npx nx show projects -p tag:scope:publishable --type lib --json', { encoding: 'utf8' }); - const arr = JSON.parse(out); - process.stdout.write(arr.join(',')); - } catch (e) { - process.stdout.write(''); - } - ") - - if [ -z "$ALL_LIBS" ]; then - echo "projects=" >> "$GITHUB_OUTPUT" - echo "project_versions=" >> "$GITHUB_OUTPUT" - echo "unified_version=" >> "$GITHUB_OUTPUT" - echo "No publishable libraries found" - exit 0 - fi - - echo "All publishable libs: $ALL_LIBS" - - # Track versions and changes for all libs - CORE_HAS_CHANGES=false - CORE_HIGHEST_VERSION="0.0.0" - AFFECTED_LIBS="" - PROJECT_VERSIONS="" - - IFS=',' read -ra LIBS <<< "$ALL_LIBS" - for lib in "${LIBS[@]}"; do - # Find the last release tag for this specific package - LAST_TAG=$(git tag --list "${lib}@*" --sort=-version:refname | head -n1 || echo "") - - if [ -n "$LAST_TAG" ]; then - BASE_REF="$LAST_TAG" - LAST_VERSION="${LAST_TAG#*@}" - echo " $lib: last release tag=$LAST_TAG (v$LAST_VERSION)" - else - BASE_REF="$FIRST_COMMIT" - LAST_VERSION="0.0.0" - echo " $lib: no release tag found, using first commit (first release)" - fi + MAJOR="${{ inputs.major_version }}" + MINOR="${{ inputs.minor_version }}" + BRANCH_NAME="release/${MAJOR}.${MINOR}.x" - # Check if this is a core lib - IS_CORE=false - IFS=',' read -ra CORE_ARR <<< "$CORE_LIBS" - for core in "${CORE_ARR[@]}"; do - if [ "$lib" = "$core" ]; then - IS_CORE=true - # Track highest version among core libs for unified versioning - if [ "$(printf '%s\n%s' "$LAST_VERSION" "$CORE_HIGHEST_VERSION" | sort -V | tail -n1)" = "$LAST_VERSION" ]; then - CORE_HIGHEST_VERSION="$LAST_VERSION" - fi - break - fi - done - - # Check if this lib has changes since its last release - CHANGES=$(git diff --name-only "$BASE_REF"...HEAD -- "libs/$lib/" 2>/dev/null | head -1 || echo "") - - if [ -n "$CHANGES" ]; then - echo " $lib: has changes since $BASE_REF" - if [ "$IS_CORE" = true ]; then - CORE_HAS_CHANGES=true - else - # Non-core libs are tracked independently - if [ -n "$AFFECTED_LIBS" ]; then - AFFECTED_LIBS="${AFFECTED_LIBS},$lib" - PROJECT_VERSIONS="${PROJECT_VERSIONS},$lib=$LAST_VERSION" - else - AFFECTED_LIBS="$lib" - PROJECT_VERSIONS="$lib=$LAST_VERSION" - fi - fi - else - echo " $lib: no changes since $BASE_REF" - fi - done - - # If any core lib has changes, include ALL core libs with unified version - if [ "$CORE_HAS_CHANGES" = true ]; then - echo "" - echo "Core libs have changes - including all core libs with unified versioning" - echo "Unified version base: $CORE_HIGHEST_VERSION" - - # Add all core libs to affected list - IFS=',' read -ra CORE_ARR <<< "$CORE_LIBS" - for core in "${CORE_ARR[@]}"; do - if [ -n "$AFFECTED_LIBS" ]; then - AFFECTED_LIBS="${AFFECTED_LIBS},$core" - PROJECT_VERSIONS="${PROJECT_VERSIONS},$core=$CORE_HIGHEST_VERSION" - else - AFFECTED_LIBS="$core" - PROJECT_VERSIONS="$core=$CORE_HIGHEST_VERSION" - fi - done + # Check if branch exists on remote + if git ls-remote --heads origin "$BRANCH_NAME" | grep -q .; then + echo "::error::Branch $BRANCH_NAME already exists on remote!" + exit 1 fi - echo "projects=$AFFECTED_LIBS" >> "$GITHUB_OUTPUT" - echo "project_versions=$PROJECT_VERSIONS" >> "$GITHUB_OUTPUT" - echo "unified_version=$CORE_HIGHEST_VERSION" >> "$GITHUB_OUTPUT" - echo "core_has_changes=$CORE_HAS_CHANGES" >> "$GITHUB_OUTPUT" - - if [ -n "$AFFECTED_LIBS" ]; then - echo "" - echo "Projects to release: $AFFECTED_LIBS" - echo "Version info: $PROJECT_VERSIONS" - if [ "$CORE_HAS_CHANGES" = true ]; then - echo "Core libs unified version: $CORE_HIGHEST_VERSION" - fi - else - echo "No affected publishable libraries" - fi + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - - name: Stop if no affected projects - if: steps.affected.outputs.projects == '' - run: | - echo "No affected projects to release." - exit 0 - - # ======================================== - # MCP Integration: Configure Codex home with MCP servers - # Only needed for auto mode (Codex) - # ======================================== - - name: Prepare Codex home with MCP config - if: github.event.inputs.bump_type == 'auto' || github.event.inputs.bump_type == '' - id: mcp + - name: Calculate initial version + id: version shell: bash run: | set -euo pipefail - CODEX_HOME="${RUNNER_TEMP}/codex-home" - mkdir -p "$CODEX_HOME" - - echo "Setting up Codex home with MCP servers..." - - cat > "$CODEX_HOME/config.toml" << 'EOF' - [features] - # Enable the Rust MCP client (needed for HTTP/OAuth MCP support) - rmcp_client = true - - # --- Mintlify Documentation Server --- - # Provides access to Mintlify documentation best practices - [mcp_servers.mintlify_docs] - url = "https://mintlify.com/docs/mcp" - http_headers = { "X-MCP-Readonly" = "true" } - startup_timeout_sec = 30 - tool_timeout_sec = 60 - EOF - - echo "✓ Codex home configured with MCP servers" - echo "codex_home=${CODEX_HOME}" >> "$GITHUB_OUTPUT" - env: - RUNNER_TEMP: ${{ runner.temp }} - - - name: Prepare diff context for Codex - if: github.event.inputs.bump_type == 'auto' || github.event.inputs.bump_type == '' - id: ctx - shell: bash - run: | - set -euo pipefail - mkdir -p .github/codex/prompts .codex-release - - PROJECTS="${{ steps.affected.outputs.projects }}" - PROJECT_VERSIONS="${{ steps.affected.outputs.project_versions }}" - FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) - - echo "Preparing diff context..." - echo "Projects: $PROJECTS" - echo "Last released versions: $PROJECT_VERSIONS" - - # Save context files for Codex - echo "$PROJECTS" > .github/codex/prompts/projects.txt - - # Save last released versions (from tags) - this is what Codex should bump FROM - echo "Last released versions (bump from these):" > .github/codex/prompts/last-released-versions.txt - echo "$PROJECT_VERSIONS" | tr ',' '\n' >> .github/codex/prompts/last-released-versions.txt - # Generate per-project diffs and combined diff - > .github/codex/prompts/diff.patch - > .github/codex/prompts/commits.txt + MAJOR="${{ inputs.major_version }}" + MINOR="${{ inputs.minor_version }}" + INITIAL_VERSION="${MAJOR}.${MINOR}.0" - IFS=',' read -ra LIBS <<< "$PROJECTS" - for lib in "${LIBS[@]}"; do - # Find the last release tag for this package - LAST_TAG=$(git tag --list "${lib}@*" --sort=-version:refname | head -n1 || echo "") - - if [ -n "$LAST_TAG" ]; then - BASE_REF="$LAST_TAG" - else - BASE_REF="$FIRST_COMMIT" - fi + echo "initial_version=$INITIAL_VERSION" >> "$GITHUB_OUTPUT" + echo "Initial version: $INITIAL_VERSION" - echo "=== $lib (since $BASE_REF) ===" >> .github/codex/prompts/diff.patch - - # Generate diff for this specific lib - git diff "$BASE_REF"...HEAD --unified=3 \ - -- \ - "libs/$lib/**/*.ts" \ - "libs/$lib/package.json" \ - ":!**/*.spec.ts" \ - ":!**/*.test.ts" \ - ":!**/__tests__/**" \ - >> .github/codex/prompts/diff.patch 2>/dev/null || true - - echo "" >> .github/codex/prompts/diff.patch + - name: Create release branch + id: branch + shell: bash + run: | + set -euo pipefail - # Collect commits for this lib - echo "=== $lib commits (since $BASE_REF) ===" >> .github/codex/prompts/commits.txt - git log "$BASE_REF"...HEAD --pretty=format:'%H%x09%s' -n 20 -- "libs/$lib/" >> .github/codex/prompts/commits.txt 2>/dev/null || true - echo "" >> .github/codex/prompts/commits.txt - done + BRANCH_NAME="${{ steps.check_branch.outputs.branch_name }}" + BASE_BRANCH="${{ inputs.base_branch }}" - DIFF_SIZE=$(wc -c < .github/codex/prompts/diff.patch || echo "0") - echo "Diff size: $DIFF_SIZE bytes" + git fetch origin "$BASE_BRANCH" --tags + git switch -c "$BRANCH_NAME" "origin/$BASE_BRANCH" - # ISO date - date -u +"%Y-%m-%d" > .github/codex/prompts/date.txt + echo "Created branch: $BRANCH_NAME" - # ======================================== - # Manual version bump (when bump_type != 'auto') - # Generates mock Codex output with unified versioning for core libs - # ======================================== - - name: Generate manual version bumps - if: steps.affected.outputs.projects != '' && github.event.inputs.bump_type != 'auto' && github.event.inputs.bump_type != '' - id: manual_bump + - name: Find publishable projects + id: projects shell: bash run: | set -euo pipefail - BUMP_TYPE="${{ github.event.inputs.bump_type }}" - PROJECTS="${{ steps.affected.outputs.projects }}" - UNIFIED_VERSION="${{ steps.affected.outputs.unified_version }}" - CORE_LIBS="${{ env.CORE_LIBS }}" - - echo "Manual bump type: $BUMP_TYPE" - echo "Projects to release: $PROJECTS" - echo "Core libs: $CORE_LIBS" - echo "Current unified version: $UNIFIED_VERSION" - - # Parse version components from unified version - IFS='.' read -r MAJOR MINOR PATCH <<< "$UNIFIED_VERSION" - - # Calculate new version based on bump type - case "$BUMP_TYPE" in - major) - NEW_VERSION="$((MAJOR + 1)).0.0" - ;; - minor) - NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" - ;; - patch) - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - ;; - esac - - echo "New unified version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - - # Generate mock Codex output JSON for nx-release.mjs - mkdir -p .codex-release - - # Build projects array - core libs get unified version - PROJECTS_JSON="[" - FIRST=true - IFS=',' read -ra LIBS <<< "$PROJECTS" - for lib in "${LIBS[@]}"; do - # Check if this is a core lib - IS_CORE=false - IFS=',' read -ra CORE_ARR <<< "$CORE_LIBS" - for core in "${CORE_ARR[@]}"; do - if [ "$lib" = "$core" ]; then - IS_CORE=true - break - fi - done - - # Core libs use unified version, others use their own - if [ "$IS_CORE" = true ]; then - LIB_VERSION="$NEW_VERSION" - else - LIB_VERSION="$NEW_VERSION" # For simplicity, use same version for all in manual mode - fi - - if [ "$FIRST" = true ]; then - FIRST=false - else - PROJECTS_JSON+="," - fi - PROJECTS_JSON+="{\"name\":\"$lib\",\"bump\":\"$BUMP_TYPE\",\"newVersion\":\"$LIB_VERSION\",\"reason\":\"Manual $BUMP_TYPE bump\",\"changelog\":{\"added\":[],\"changed\":[],\"deprecated\":[],\"removed\":[],\"fixed\":[],\"security\":[]}}" - done - PROJECTS_JSON+="]" - - # Write mock Codex output - cat > "${{ env.CODEX_OUTPUT }}" << EOF - { - "projects": $PROJECTS_JSON, - "globalChangelog": { - "summary": "Manual $BUMP_TYPE release", - "projects": [] - }, - "docs": { - "updated": false, - "files": [], - "summary": "" - } + # Get all publishable libs + PROJECTS=$(node -e " + const { execSync } = require('child_process'); + try { + const out = execSync('npx nx show projects -p tag:scope:publishable --type lib --json', { encoding: 'utf8' }); + const arr = JSON.parse(out); + process.stdout.write(arr.join(',')); + } catch (e) { + process.stdout.write(''); } - EOF - - echo "Generated manual Codex output:" - cat "${{ env.CODEX_OUTPUT }}" - - # ======================================== - # Single Codex call for version analysis + docs update - # (Cannot run sequential Codex actions due to sudo restrictions) - # Only runs in 'auto' mode - # ======================================== - - name: Run Codex for release analysis - if: steps.affected.outputs.projects != '' && (github.event.inputs.bump_type == 'auto' || github.event.inputs.bump_type == '') - id: codex - uses: openai/codex-action@v1 - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_KEY }} - codex-home: ${{ steps.mcp.outputs.codex_home }} - prompt-file: .github/codex/prompts/analyze-release.md - output-file: ${{ env.CODEX_OUTPUT }} - model: "gpt-5.1-codex" - output-schema-file: .github/codex/schemas/release-output.json - codex-args: "--full-auto" - - - name: Apply version bumps and changelogs via Nx Release - id: versions - if: steps.affected.outputs.projects != '' + ") + + echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" + echo "Publishable projects: $PROJECTS" + + - name: Apply version via Nx Release shell: bash run: | set -euo pipefail - echo "Applying version bumps via Nx Release..." + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + PROJECTS="${{ steps.projects.outputs.projects }}" - if [ ! -f "${{ env.CODEX_OUTPUT }}" ]; then - echo "::error::Codex output file not found" + if [ -z "$PROJECTS" ]; then + echo "::error::No publishable projects found" exit 1 fi - echo "Codex output:" - cat "${{ env.CODEX_OUTPUT }}" - - # Run Nx Release script to bump versions and update changelogs - node scripts/nx-release.mjs - - # Extract max version and bumped projects from Codex output - RESULT=$(node -e " - const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('${{ env.CODEX_OUTPUT }}', 'utf8')); - const bumped = output.projects.filter(p => p.bump !== 'none'); - const maxVersion = bumped.map(p => p.newVersion) - .sort((a,b) => b.localeCompare(a, undefined, {numeric: true}))[0] || '0.0.0'; - const bumpedProjects = bumped.map(p => p.name).join(','); - console.log(JSON.stringify({ maxVersion, bumpedProjects })); - ") - - MAX_VERSION=$(echo "$RESULT" | jq -r '.maxVersion') - BUMPED=$(echo "$RESULT" | jq -r '.bumpedProjects') + echo "Setting version to $INITIAL_VERSION for projects: $PROJECTS" - echo "max_version=$MAX_VERSION" >> "$GITHUB_OUTPUT" - echo "bumped_projects=$BUMPED" >> "$GITHUB_OUTPUT" + # Use Nx Release to set version (without git operations) + npx nx release version "$INITIAL_VERSION" --projects="$PROJECTS" --git-commit=false --git-tag=false - echo "Max version: $MAX_VERSION" - echo "Bumped projects: $BUMPED" - env: - CODEX_OUTPUT: ${{ env.CODEX_OUTPUT }} - - - name: Log release analysis result - if: steps.affected.outputs.projects != '' + - name: Create release docs base marker shell: bash run: | - if [ -f "${{ env.CODEX_OUTPUT }}" ]; then - echo "Release analysis result:" - cat "${{ env.CODEX_OUTPUT }}" - - # Extract and display docs summary - DOCS_UPDATED=$(node -e "const o = require('./${{ env.CODEX_OUTPUT }}'); console.log(o.docs?.updated || false)") - if [ "$DOCS_UPDATED" = "true" ]; then - echo "" - echo "Documentation was updated:" - node -e "const o = require('./${{ env.CODEX_OUTPUT }}'); console.log(' Summary: ' + o.docs.summary); console.log(' Files: ' + o.docs.files.join(', '))" - else - echo "No documentation updates were made" - fi - fi + set -euo pipefail - - name: Create release branch - id: branch - if: steps.versions.outputs.bumped_projects != '' + # Create marker file for docs diffing + # This file records the commit SHA from which docs changes should be computed + MARKER_FILE=".release-docs-base" + BASE_SHA=$(git rev-parse HEAD) + + echo "$BASE_SHA" > "$MARKER_FILE" + echo "Created $MARKER_FILE with base SHA: $BASE_SHA" + + - name: Update CHANGELOG.md files shell: bash run: | set -euo pipefail - MAX_VERSION="${{ steps.versions.outputs.max_version }}" - BRANCH_NAME="next/$MAX_VERSION" + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + PROJECTS="${{ steps.projects.outputs.projects }}" + TODAY=$(date -u +"%Y-%m-%d") - git fetch origin main --tags - git switch -c "$BRANCH_NAME" origin/main + # Update per-library CHANGELOGs + IFS=',' read -ra LIBS <<< "$PROJECTS" + for lib in "${LIBS[@]}"; do + CHANGELOG_PATH="libs/$lib/CHANGELOG.md" + if [ -f "$CHANGELOG_PATH" ]; then + # Add new version entry after [Unreleased] + sed -i.bak "s/## \[Unreleased\]/## [Unreleased]\n\n## [$INITIAL_VERSION] - $TODAY/" "$CHANGELOG_PATH" + rm -f "${CHANGELOG_PATH}.bak" + echo "Updated $CHANGELOG_PATH" + fi + done - echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - echo "Created branch: $BRANCH_NAME" + # Update root CHANGELOG.md + ROOT_CHANGELOG="CHANGELOG.md" + if [ -f "$ROOT_CHANGELOG" ]; then + sed -i.bak "s/## \[Unreleased\]/## [Unreleased]\n\n## [$INITIAL_VERSION] - $TODAY/" "$ROOT_CHANGELOG" + rm -f "${ROOT_CHANGELOG}.bak" + echo "Updated $ROOT_CHANGELOG" + fi - - name: Commit version bumps and docs - if: steps.versions.outputs.bumped_projects != '' + - name: Commit changes shell: bash run: | set -euo pipefail - MAX_VERSION="${{ steps.versions.outputs.max_version }}" - BUMPED="${{ steps.versions.outputs.bumped_projects }}" + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + BRANCH_NAME="${{ steps.check_branch.outputs.branch_name }}" if [ -n "$(git status --porcelain)" ]; then git add -A - git commit -m "chore(release): prepare release v$MAX_VERSION" \ - -m "Bumped versions for: $BUMPED" \ - -m "Version analysis and docs updates by Codex AI." - echo "Created release preparation commit" + git commit -m "chore(release): initialize $BRANCH_NAME at v$INITIAL_VERSION" \ + -m "- Set package version to $INITIAL_VERSION" \ + -m "- Add .release-docs-base marker for docs diffing" + echo "Created initialization commit" else echo "No changes to commit" - git commit --allow-empty -m "chore(release): prepare release v$MAX_VERSION" fi - name: Push branch - if: steps.versions.outputs.bumped_projects != '' shell: bash run: | set -euo pipefail - BRANCH_NAME="${{ steps.branch.outputs.branch_name }}" - git push --set-upstream origin "$BRANCH_NAME" - - name: Create PR - if: steps.versions.outputs.bumped_projects != '' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - MAX_VERSION="${{ steps.versions.outputs.max_version }}" - BUMPED="${{ steps.versions.outputs.bumped_projects }}" - BRANCH_NAME="${{ steps.branch.outputs.branch_name }}" - BASE="main" - TITLE="Release v$MAX_VERSION" - - # Check for existing PR - EXISTING_PR=$(gh pr list \ - --base "$BASE" \ - --head "$BRANCH_NAME" \ - --state open \ - --json number \ - --jq '.[0].number' || echo "") - - if [ -n "$EXISTING_PR" ]; then - echo "PR #$EXISTING_PR already exists" - exit 0 - fi - - # Create PR body - BODY=$(cat <> $GITHUB_STEP_SUMMARY + echo "## Release Branch Created" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** ${{ steps.branch.outputs.branch_name }}" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ steps.versions.outputs.max_version }}" >> $GITHUB_STEP_SUMMARY - echo "**Projects:** ${{ steps.versions.outputs.bumped_projects }}" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | \`${{ steps.check_branch.outputs.branch_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Initial Version** | \`${{ steps.version.outputs.initial_version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Base Branch** | \`${{ inputs.base_branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Projects** | ${{ steps.projects.outputs.projects }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Versioning Strategy" >> $GITHUB_STEP_SUMMARY - echo "**Core libs (unified):** ${{ env.CORE_LIBS }}" >> $GITHUB_STEP_SUMMARY - echo "**Previous unified version:** ${{ steps.affected.outputs.unified_version }}" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - BUMP_TYPE="${{ github.event.inputs.bump_type }}" - if [ "$BUMP_TYPE" = "auto" ] || [ -z "$BUMP_TYPE" ]; then - echo "**Analysis:** Codex AI (gpt-5.1-codex)" >> $GITHUB_STEP_SUMMARY - else - echo "**Analysis:** Manual ($BUMP_TYPE bump)" >> $GITHUB_STEP_SUMMARY - fi + echo "1. Create PRs targeting \`${{ steps.check_branch.outputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "2. When ready to publish, run the **Publish Release** workflow from this branch" >> $GITHUB_STEP_SUMMARY + echo "3. Subsequent releases from this branch will increment: ${{ steps.version.outputs.initial_version }} -> x.y.1 -> x.y.2 ..." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/publish-on-next-close.yml b/.github/workflows/publish-on-next-close.yml deleted file mode 100644 index ea32b1b..0000000 --- a/.github/workflows/publish-on-next-close.yml +++ /dev/null @@ -1,269 +0,0 @@ -name: Publish libs on next/* merge - -on: - pull_request: - types: [closed] - -permissions: - contents: write - packages: write - id-token: write - -concurrency: - group: publish-next-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: false - -jobs: - publish: - if: | - github.event_name == 'pull_request' && - github.event.action == 'closed' && - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'next/') && - github.event.pull_request.base.ref == github.event.repository.default_branch - runs-on: ubuntu-latest - environment: release - env: - NX_DAEMON: "false" - - steps: - - name: Determine release ref - id: release_ref - shell: bash - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - - if [ -n "${MERGE_SHA:-}" ] && [ "$MERGE_SHA" != "null" ]; then - REF="$MERGE_SHA" - else - REF="$BASE_REF" - fi - - echo "ref=$REF" >> "$GITHUB_OUTPUT" - - - name: Checkout release commit - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ steps.release_ref.outputs.ref }} - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - cache: "yarn" - registry-url: "https://registry.npmjs.org/" - - - name: Update npm CLI for trusted publishing - run: npm install -g npm@latest - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Set Nx SHAs - uses: nrwl/nx-set-shas@v4 - - - name: Find affected publishable libs - id: to_publish - shell: bash - run: | - set -euo pipefail - - # Get all publishable libs - ALL_PUBLISHABLE=$(node -e " - const { execSync } = require('child_process'); - try { - const out = execSync('npx nx show projects -p tag:scope:publishable --type lib --json', { encoding: 'utf8' }); - const arr = JSON.parse(out); - process.stdout.write(arr.join(',')); - } catch (e) { - process.stdout.write(''); - } - ") - - if [ -z "$ALL_PUBLISHABLE" ]; then - echo "projects=" >> "$GITHUB_OUTPUT" - echo "No publishable libraries found" - exit 0 - fi - - # Get affected libs - AFFECTED=$(node -e " - const { execSync } = require('child_process'); - try { - const out = execSync('npx nx show projects --affected --type lib --json', { encoding: 'utf8' }); - const arr = JSON.parse(out); - process.stdout.write(arr.join(',')); - } catch (e) { - process.stdout.write(''); - } - ") - - if [ -z "$AFFECTED" ]; then - echo "projects=" >> "$GITHUB_OUTPUT" - echo "No affected libraries" - exit 0 - fi - - # Intersection of publishable and affected - PROJECTS=$(node -e " - const all = '$ALL_PUBLISHABLE'.split(',').filter(Boolean); - const affected = '$AFFECTED'.split(',').filter(Boolean); - const intersection = all.filter(lib => affected.includes(lib)); - process.stdout.write(intersection.join(',')); - ") - - echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" - - if [ -n "$PROJECTS" ]; then - echo "Projects to publish: $PROJECTS" - else - echo "No projects to publish" - fi - - - name: Stop if nothing to publish - if: steps.to_publish.outputs.projects == '' - run: echo "Nothing to publish." - - - name: Determine release SHA - id: release_sha - shell: bash - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - set -euo pipefail - - if [ -n "${MERGE_SHA:-}" ] && [ "$MERGE_SHA" != "null" ]; then - SHA="$MERGE_SHA" - else - SHA=$(git rev-parse HEAD) - fi - - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - - - name: Configure git user - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Create git tags (per-project) - if: steps.to_publish.outputs.projects != '' - shell: bash - run: | - set -euo pipefail - - TARGET_SHA="${{ steps.release_sha.outputs.sha }}" - PROJECTS="${{ steps.to_publish.outputs.projects }}" - - git fetch --tags - - # Create per-project tags (independent versioning) - IFS=',' read -ra LIBS <<< "$PROJECTS" - for lib in "${LIBS[@]}"; do - # Read project version from package.json with error handling - LIB_VERSION=$(./scripts/get-lib-version.sh "$lib") - - PROJECT_TAG="${lib}@${LIB_VERSION}" - - if git rev-parse "$PROJECT_TAG" >/dev/null 2>&1; then - echo "Tag $PROJECT_TAG already exists." - else - echo "Creating project tag $PROJECT_TAG at $TARGET_SHA" - git tag -a "$PROJECT_TAG" "$TARGET_SHA" -m "Release $PROJECT_TAG" - git push origin "$PROJECT_TAG" - fi - done - - - name: Build packages - if: steps.to_publish.outputs.projects != '' - run: yarn nx run-many --targets=build --projects="${{ steps.to_publish.outputs.projects }}" --parallel - - - name: Publish to npm via Nx Release - if: steps.to_publish.outputs.projects != '' - shell: bash - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - set -euo pipefail - echo "Publishing selected projects via Nx Release..." - npx nx release publish --projects="${{ steps.to_publish.outputs.projects }}" - - - name: Create GitHub Releases (per-project) - if: steps.to_publish.outputs.projects != '' - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - PROJECTS="${{ steps.to_publish.outputs.projects }}" - - IFS=',' read -ra LIBS <<< "$PROJECTS" - for lib in "${LIBS[@]}"; do - if [ -f "libs/$lib/package.json" ]; then - LIB_VERSION=$(./scripts/get-lib-version.sh "$lib") - TAG_NAME="${lib}@${LIB_VERSION}" - - echo "Creating GitHub Release for $TAG_NAME..." - - # Extract changelog entry for this version - CHANGELOG_PATH="libs/$lib/CHANGELOG.md" - CHANGELOG_ENTRY="" - if [ -f "$CHANGELOG_PATH" ]; then - # Extract content between ## [version] and next ## [ - CHANGELOG_ENTRY=$(node -e " - const fs = require('fs'); - const content = fs.readFileSync('$CHANGELOG_PATH', 'utf8'); - const version = '$LIB_VERSION'; - const pattern = new RegExp('## \\\\[' + version.replace(/\\./g, '\\\\.') + '\\\\][^\\n]*\\n([\\\\s\\\\S]*?)(?=\\n## \\\\[|$)'); - const match = content.match(pattern); - if (match) { - console.log(match[1].trim()); - } - " 2>/dev/null || echo "") - fi - - # Build release body - BODY="## ${lib} v${LIB_VERSION}" - BODY+=$'\n\n' - BODY+="📦 **npm:** [\`${lib}@${LIB_VERSION}\`](https://www.npmjs.com/package/${lib}/v/${LIB_VERSION})" - - if [ -n "$CHANGELOG_ENTRY" ]; then - BODY+=$'\n\n---\n\n' - BODY+="$CHANGELOG_ENTRY" - fi - - # Create release using gh CLI (check if exists first) - if gh release view "$TAG_NAME" >/dev/null 2>&1; then - echo "Release $TAG_NAME already exists, skipping..." - else - gh release create "$TAG_NAME" \ - --title "$TAG_NAME" \ - --notes "$BODY" - fi - fi - done - - - name: Summary - if: steps.to_publish.outputs.projects != '' - shell: bash - run: | - set -euo pipefail - - echo "## Publish Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Published projects:** ${{ steps.to_publish.outputs.projects }}" >> $GITHUB_STEP_SUMMARY - echo "**Release commit:** ${{ steps.release_sha.outputs.sha }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Release Tags" >> $GITHUB_STEP_SUMMARY - IFS=',' read -ra LIBS <<< "${{ steps.to_publish.outputs.projects }}" - for lib in "${LIBS[@]}"; do - if [ -f "libs/$lib/package.json" ]; then - LIB_VERSION=$(./scripts/get-lib-version.sh "$lib") - echo "- ${lib}@${LIB_VERSION}" >> $GITHUB_STEP_SUMMARY - fi - done diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..b30f2fd --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,424 @@ +name: Publish Release + +on: + workflow_dispatch: + inputs: + release_type: + description: "Release type" + required: true + type: choice + options: + - stable + - rc + - beta + default: stable + pre_release_number: + description: "Pre-release number (for rc/beta, leave empty for auto-increment)" + required: false + type: string + dry_run: + description: "Dry run (skip actual publish)" + required: false + type: boolean + default: false + +permissions: + contents: write + packages: write + id-token: write + +concurrency: + group: publish-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + env: + NX_DAEMON: "false" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate branch + id: context + shell: bash + run: | + set -euo pipefail + + # Get current branch + BRANCH="${GITHUB_REF#refs/heads/}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + # Validate branch is a release branch + if [[ ! "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.x$ ]]; then + echo "::error::This workflow must be run from a release/X.Y.x branch. Current branch: $BRANCH" + exit 1 + fi + + # Extract release line (X.Y) from release/X.Y.x + RELEASE_LINE=$(echo "$BRANCH" | sed 's/release\/\([0-9]*\.[0-9]*\).*/\1/') + echo "release_line=$RELEASE_LINE" >> "$GITHUB_OUTPUT" + + echo "Branch: $BRANCH" + echo "Release line: $RELEASE_LINE" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: "yarn" + registry-url: "https://registry.npmjs.org/" + + - name: Update npm CLI for trusted publishing + run: npm install -g npm@latest + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Find publishable projects + id: projects + shell: bash + run: | + set -euo pipefail + + # Get all publishable libs + PROJECTS=$(node -e " + const { execSync } = require('child_process'); + const out = execSync('npx nx show projects -p tag:scope:publishable --type lib --json', { encoding: 'utf8' }); + const arr = JSON.parse(out); + process.stdout.write(arr.join(',')); + ") + + echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" + echo "Projects to publish: $PROJECTS" + + - name: Compute version + id: version + shell: bash + run: | + set -euo pipefail + + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + RELEASE_TYPE="${{ inputs.release_type }}" + PRE_RELEASE_NUM="${{ inputs.pre_release_number }}" + + # Fetch all tags + git fetch --tags + + # Use compute-next-patch script + if [ -n "$PRE_RELEASE_NUM" ]; then + VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE" "$PRE_RELEASE_NUM") + else + VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE") + fi + + # Determine if this is a pre-release + IS_PRERELEASE="false" + NPM_TAG="latest" + if [[ "$VERSION" == *"-rc."* ]]; then + IS_PRERELEASE="true" + NPM_TAG="rc" + elif [[ "$VERSION" == *"-beta."* ]]; then + IS_PRERELEASE="true" + NPM_TAG="beta" + fi + + # Check if unified tag already exists + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "::error::Tag v$VERSION already exists!" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "npm_tag=$NPM_TAG" >> "$GITHUB_OUTPUT" + + echo "Version: $VERSION" + echo "Release type: $RELEASE_TYPE" + echo "Is prerelease: $IS_PRERELEASE" + echo "NPM tag: $NPM_TAG" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Get previous version + id: prev_version + run: | + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + # Get the latest stable tag for this release line + PREV_TAG=$(git tag --list "v${RELEASE_LINE}.*" --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$PREV_TAG" ]; then + # No previous tag in this line, try previous minor + PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + fi + echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" + echo "Previous tag: $PREV_TAG" + + - name: Generate diff + id: diff + run: | + PREV_TAG="${{ steps.prev_version.outputs.prev_tag }}" + if [ -n "$PREV_TAG" ]; then + DIFF=$(git diff "$PREV_TAG"..HEAD \ + --stat --patch \ + -- '*.ts' '*.js' '*.json' ':!package-lock.json' ':!*.test.ts' ':!*.spec.ts' \ + | head -c 50000) + else + DIFF="Initial release - no previous version to compare" + fi + # Use file to avoid shell escaping issues + echo "$DIFF" > /tmp/diff.txt + + - name: Generate AI changelog + id: ai_changelog + if: ${{ inputs.dry_run != true && inputs.release_type == 'stable' }} + continue-on-error: true + uses: actions/github-script@v7 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} + VERSION: v${{ steps.version.outputs.version }} + VERSION_MINOR: ${{ steps.context.outputs.release_line }} + with: + script: | + const fs = require('fs'); + + // Skip if API key is missing + if (!process.env.OPENAI_API_KEY) { + core.warning('OPENAI_API_KEY missing; skipping AI changelog'); + core.setOutput('changelog', ''); + core.setOutput('has_card_mdx', 'false'); + return; + } + + try { + const diff = fs.readFileSync('/tmp/diff.txt', 'utf8'); + const releaseDate = new Date().toISOString().split('T')[0]; + const version = process.env.VERSION; + const versionNum = version.replace('v', ''); + + const prompt = `You are a technical writer for Enclave, a production-ready JavaScript sandbox for AI agent code execution. + + The Enclave ecosystem includes: + - ast-guard: AST security guard with CVE protection + - enclave-vm: Secure AgentScript execution environment + - @enclavejs/types: Protocol types and Zod schemas + - @enclavejs/stream: NDJSON streaming with encryption + - @enclavejs/broker: Tool broker with session management + - @enclavejs/client: Browser and Node.js client SDK + - @enclavejs/react: React hooks and components + - @enclavejs/runtime: Standalone deployable runtime + + Version: ${version} + Release Date: ${releaseDate} + + Git diff: + \`\`\` + ${diff.substring(0, 40000)} + \`\`\` + + Generate two outputs: + + 1. CHANGELOG entry (Keep a Changelog format): + ## [${versionNum}] - ${releaseDate} + ### Added/Changed/Fixed/Security (only include relevant sections) + - Concise description of changes + + 2. A SINGLE Mintlify component (NOT the full file, just the Card): + + **Feature** – Description. + - Details if needed + + + IMPORTANT: For cardMdx, output ONLY the ... component, nothing else. + + Output ONLY valid JSON: {"changelog": "...", "cardMdx": "..."}`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: process.env.OPENAI_MODEL || 'gpt-4o', + messages: [{ role: 'user', content: prompt }], + response_format: { type: 'json_object' } + }) + }); + + if (!response.ok) throw new Error(`OpenAI API error: ${response.status}`); + const data = await response.json(); + const result = JSON.parse(data.choices[0].message.content); + + // Write card MDX to file to avoid shell escaping issues + fs.writeFileSync('/tmp/card-mdx.txt', result.cardMdx); + + core.setOutput('changelog', result.changelog); + core.setOutput('has_card_mdx', result.cardMdx ? 'true' : 'false'); + } catch (err) { + core.warning(`AI changelog skipped: ${err.message}`); + core.setOutput('changelog', ''); + core.setOutput('has_card_mdx', 'false'); + } + + - name: Update package versions + if: ${{ inputs.dry_run != true && steps.projects.outputs.projects != '' }} + run: | + VERSION="${{ steps.version.outputs.version }}" + PROJECTS="${{ steps.projects.outputs.projects }}" + + echo "Setting version $VERSION for projects: $PROJECTS" + + # Use Nx Release to set version (without git operations) + npx nx release version "$VERSION" --projects="$PROJECTS" --git-commit=false --git-tag=false + + - name: Commit version bump + if: ${{ inputs.dry_run != true }} + run: | + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "chore(release): v${{ steps.version.outputs.version }}" + git push origin HEAD + fi + + - name: Build packages + if: steps.projects.outputs.projects != '' + run: | + PROJECTS="${{ steps.projects.outputs.projects }}" + yarn nx run-many --targets=build --projects="$PROJECTS" --parallel + + - name: Publish to npm + if: ${{ inputs.dry_run != true && steps.projects.outputs.projects != '' }} + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + + NPM_TAG="${{ steps.version.outputs.npm_tag }}" + PROJECTS="${{ steps.projects.outputs.projects }}" + + echo "Publishing projects via Nx Release with tag $NPM_TAG..." + npx nx release publish --projects="$PROJECTS" --tag="$NPM_TAG" + + echo "Successfully published version ${{ steps.version.outputs.version }}" + + - name: Create and push git tag + if: ${{ inputs.dry_run != true && steps.projects.outputs.projects != '' }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + BRANCH="${{ steps.context.outputs.branch }}" + + # Fetch latest to ensure we tag the committed version + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git pull origin "$BRANCH" + + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + echo "Created and pushed tag: $TAG" + + - name: Prepare release body + id: release_body + env: + CHANGELOG: ${{ steps.ai_changelog.outputs.changelog }} + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_TYPE="${{ steps.version.outputs.release_type }}" + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + BRANCH="${{ steps.context.outputs.branch }}" + IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" + PROJECTS="${{ steps.projects.outputs.projects }}" + + # Start building the release body + { + echo "## Release v${VERSION}" + echo "" + echo "**Release type:** ${RELEASE_TYPE}" + echo "**Release line:** ${RELEASE_LINE}.x" + echo "**Branch:** ${BRANCH}" + echo "" + echo "### Published Packages" + echo "" + } > /tmp/release-body.md + + # List published packages with npm links + IFS=',' read -ra LIBS <<< "$PROJECTS" + for lib in "${LIBS[@]}"; do + # Get npm package name from package.json + if [ -f "libs/$lib/package.json" ]; then + NPM_NAME=$(node -p "require('./libs/$lib/package.json').name") + echo "- [\`${NPM_NAME}@${VERSION}\`](https://www.npmjs.com/package/${NPM_NAME}/v/${VERSION})" >> /tmp/release-body.md + fi + done + + # Add AI-generated changelog if available + if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ] && [ -n "$CHANGELOG" ]; then + echo "" >> /tmp/release-body.md + echo "$CHANGELOG" >> /tmp/release-body.md + fi + + # Add pre-release note if applicable + if [ "$IS_PRERELEASE" = "true" ]; then + echo "" >> /tmp/release-body.md + echo "> **Note:** This is a pre-release version." >> /tmp/release-body.md + fi + + # Add Card MDX as hidden comment for docs sync (only for stable releases) + # NOTE: Content is sanitized to prevent --> from breaking the HTML comment. + # Consumer must reverse: replace "-->" with "-->" after extraction. + if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ]; then + echo "" >> /tmp/release-body.md + echo "/--\>/g' /tmp/card-mdx.txt >> /tmp/release-body.md + echo "CARD_MDX_END" >> /tmp/release-body.md + echo "-->" >> /tmp/release-body.md + fi + + - name: Create GitHub Release + if: ${{ inputs.dry_run != true && steps.projects.outputs.projects != '' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.is_prerelease }} + generate_release_notes: false + body_path: /tmp/release-body.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "## Dry Run Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "> **This was a dry run. No packages were published.**" >> "$GITHUB_STEP_SUMMARY" + else + echo "## Release Complete" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Property | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Version | \`${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Tag | \`v${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Release type | ${{ steps.version.outputs.release_type }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| NPM tag | \`${{ steps.version.outputs.npm_tag }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Pre-release | ${{ steps.version.outputs.is_prerelease }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Branch | \`${{ steps.context.outputs.branch }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Projects | ${{ steps.projects.outputs.projects }} |" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/changelog.mdx b/docs/changelog.mdx deleted file mode 100644 index db7909a..0000000 --- a/docs/changelog.mdx +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: 'Updates' -slug: 'updates' -icon: 'sparkles' -mode: 'center' ---- - - - - **EnclaveJS Streaming Runtime** – A complete streaming execution environment for AI agents with real-time output, tool orchestration, and React integration. - - **New Packages:** - - - **@enclavejs/types** – Protocol types and Zod schemas for the streaming runtime - - **@enclavejs/stream** – NDJSON streaming with ECDH + AES-256-GCM encryption - - **@enclavejs/broker** – Tool broker with session management and HTTP API - - **@enclavejs/client** – Browser and Node.js client SDK - - **@enclavejs/react** – React hooks (`useEnclaveSession`) and `EnclaveProvider` - - **@enclavejs/runtime** – Standalone deployable runtime worker - - **Key Features:** - - - Real-time streaming with NDJSON protocol - - Tool orchestration with Zod schema validation - - End-to-end encryption support - - Automatic reconnection with event buffering - - Event filtering (type-based and content-based) - - Distributed deployment with extracted runtime - - - - - - **Session API** – New `Session` class for long-lived execution contexts with streaming events. - - **SessionEmitter** – Event emission for stdout, logs, tool calls, and heartbeats. - - **Event Filtering** – Filter streaming events by type or content patterns. - - **Security Enhancements:** - - JSON tool bridge with serialized size enforcement - - Stack trace sanitization controls - - Policy-violation reporting - - Safe error handling preventing prototype chain escapes - - - - - - **Security-Level-Aware Globals** – Different global availability by security level. - - **ReDoS Detection** – Improved regex analysis for denial-of-service patterns. - - **Browser Primitive Protection** – Blocks structuredClone, messaging APIs, and import(). - - - - - - **Enclave Monorepo** – Initial release of the Enclave monorepo containing security-focused libraries for AI agents. - - **ast-guard v1.0.0** – Production-ready AST security guard with 100% CVE coverage for vm2/isolated-vm/node-vm exploits. - - **enclave-vm v1.0.0** – Secure AgentScript execution environment with defense-in-depth architecture. - - diff --git a/docs/docs.json b/docs/docs.json index a8a42bd..b4f1f00 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -68,110 +68,131 @@ "group": "Get Started", "icon": "rocket", "pages": [ - "getting-started/welcome", - "getting-started/installation", - "getting-started/quickstart" + "enclave/getting-started/welcome", + "enclave/getting-started/installation", + "enclave/getting-started/quickstart" ] }, { "group": "Concepts", "icon": "lightbulb", "pages": [ - "concepts/architecture", - "concepts/security-model", - "concepts/agentscript", - "concepts/streaming-protocol" + "enclave/concepts/architecture", + "enclave/concepts/security-model", + "enclave/concepts/agentscript", + "enclave/concepts/streaming-protocol" ] }, { "group": "enclave-vm", "icon": "lock", "pages": [ - "core-libraries/enclave-vm/overview", - "core-libraries/enclave-vm/security-levels", - "core-libraries/enclave-vm/tool-system", - "core-libraries/enclave-vm/worker-pool", - "core-libraries/enclave-vm/double-vm", - "core-libraries/enclave-vm/ai-scoring", - "core-libraries/enclave-vm/reference-sidecar", - "core-libraries/enclave-vm/configuration" + "enclave/core-libraries/enclave-vm/overview", + "enclave/core-libraries/enclave-vm/security-levels", + "enclave/core-libraries/enclave-vm/tool-system", + "enclave/core-libraries/enclave-vm/babel-transform", + "enclave/core-libraries/enclave-vm/worker-pool", + "enclave/core-libraries/enclave-vm/double-vm", + "enclave/core-libraries/enclave-vm/ai-scoring", + "enclave/core-libraries/enclave-vm/reference-sidecar", + "enclave/core-libraries/enclave-vm/configuration" ] }, { "group": "ast-guard", "icon": "shield-check", "pages": [ - "core-libraries/ast-guard/overview", - "core-libraries/ast-guard/pre-scanner", - "core-libraries/ast-guard/agentscript-preset", - "core-libraries/ast-guard/security-rules", - "core-libraries/ast-guard/code-transform", - "core-libraries/ast-guard/custom-rules" + "enclave/core-libraries/ast-guard/overview", + "enclave/core-libraries/ast-guard/pre-scanner", + "enclave/core-libraries/ast-guard/agentscript-preset", + "enclave/core-libraries/ast-guard/babel-preset", + "enclave/core-libraries/ast-guard/security-rules", + "enclave/core-libraries/ast-guard/code-transform", + "enclave/core-libraries/ast-guard/custom-rules" ] }, { "group": "EnclaveJS Streaming", "icon": "wave-pulse", "pages": [ - "enclavejs/overview", - "enclavejs/types", - "enclavejs/stream", - "enclavejs/broker", - "enclavejs/client", - "enclavejs/react", - "enclavejs/runtime" + "enclave/enclavejs/overview", + "enclave/enclavejs/types", + "enclave/enclavejs/stream", + "enclave/enclavejs/broker", + "enclave/enclavejs/client", + "enclave/enclavejs/react", + "enclave/enclavejs/runtime" ] }, { "group": "Guides", "icon": "map", "pages": [ - "guides/first-agent", - "guides/tool-integration", - "guides/react-code-editor", - "guides/production-deployment", - "guides/scaling", - "guides/security-hardening" - ] - }, - { - "group": "API Reference", - "icon": "code", - "pages": [ - "api-reference/enclave-vm", - "api-reference/ast-guard", - "api-reference/enclavejs-types", - "api-reference/enclavejs-broker", - "api-reference/enclavejs-client", - "api-reference/enclavejs-react", - "api-reference/enclavejs-runtime" + "enclave/guides/first-agent", + "enclave/guides/tool-integration", + "enclave/guides/react-code-editor", + "enclave/guides/production-deployment", + "enclave/guides/scaling", + "enclave/guides/security-hardening" ] }, { "group": "Examples", "icon": "flask", "pages": [ - "examples/hello-world", - "examples/tool-calling", - "examples/streaming-ui", - "examples/multi-tenant", - "examples/encrypted-sessions" + "enclave/examples/hello-world", + "enclave/examples/tool-calling", + "enclave/examples/streaming-ui", + "enclave/examples/multi-tenant", + "enclave/examples/encrypted-sessions" ] }, { "group": "Troubleshooting", "icon": "wrench", "pages": [ - "troubleshooting/common-errors", - "troubleshooting/debugging", - "troubleshooting/faq" + "enclave/troubleshooting/common-errors", + "enclave/troubleshooting/debugging", + "enclave/troubleshooting/faq" ] }, { "group": "Resources", "icon": "circle-info", "pages": [ - "changelog" + "enclave/updates" + ] + } + ] + } + ] + }, + { + "dropdown": "API Reference", + "icon": "code", + "versions": [ + { + "version": "v2.0 (latest)", + "default": true, + "groups": [ + { + "group": "Core Libraries", + "icon": "cube", + "pages": [ + "enclave/api-reference/enclave-vm", + "enclave/api-reference/ast-guard" + ] + }, + { + "group": "EnclaveJS Packages", + "icon": "boxes-stacked", + "pages": [ + "enclave/api-reference/enclavejs-types", + "enclave/api-reference/enclavejs-stream", + "enclave/api-reference/enclavejs-broker", + "enclave/api-reference/enclavejs-client", + "enclave/api-reference/enclavejs-react", + "enclave/api-reference/enclavejs-runtime" ] } ] diff --git a/docs/api-reference/ast-guard.mdx b/docs/enclave/api-reference/ast-guard.mdx similarity index 98% rename from docs/api-reference/ast-guard.mdx rename to docs/enclave/api-reference/ast-guard.mdx index 4a62dcd..b5693a8 100644 --- a/docs/api-reference/ast-guard.mdx +++ b/docs/enclave/api-reference/ast-guard.mdx @@ -1,9 +1,9 @@ --- -title: 'ast-guard API' -description: 'Complete API reference for the ast-guard package' +title: 'ast-guard' +description: 'API reference for the ast-guard package' --- -Complete API reference for the `ast-guard` package. +API reference for the `ast-guard` package. ## Installation diff --git a/docs/api-reference/enclave-vm.mdx b/docs/enclave/api-reference/enclave-vm.mdx similarity index 98% rename from docs/api-reference/enclave-vm.mdx rename to docs/enclave/api-reference/enclave-vm.mdx index 54c8f98..5b9ed77 100644 --- a/docs/api-reference/enclave-vm.mdx +++ b/docs/enclave/api-reference/enclave-vm.mdx @@ -1,9 +1,9 @@ --- -title: 'enclave-vm API' -description: 'Complete API reference for the enclave-vm package' +title: 'enclave-vm' +description: 'API reference for the enclave-vm package' --- -Complete API reference for the `enclave-vm` package. +API reference for the `enclave-vm` package. ## Installation diff --git a/docs/api-reference/enclavejs-broker.mdx b/docs/enclave/api-reference/enclavejs-broker.mdx similarity index 99% rename from docs/api-reference/enclavejs-broker.mdx rename to docs/enclave/api-reference/enclavejs-broker.mdx index 8fb8635..5df9bc9 100644 --- a/docs/api-reference/enclavejs-broker.mdx +++ b/docs/enclave/api-reference/enclavejs-broker.mdx @@ -1,5 +1,5 @@ --- -title: '@enclavejs/broker API' +title: '@enclavejs/broker' description: 'API reference for the EnclaveJS broker package' --- diff --git a/docs/api-reference/enclavejs-client.mdx b/docs/enclave/api-reference/enclavejs-client.mdx similarity index 99% rename from docs/api-reference/enclavejs-client.mdx rename to docs/enclave/api-reference/enclavejs-client.mdx index 1629eff..fed619d 100644 --- a/docs/api-reference/enclavejs-client.mdx +++ b/docs/enclave/api-reference/enclavejs-client.mdx @@ -1,5 +1,5 @@ --- -title: '@enclavejs/client API' +title: '@enclavejs/client' description: 'API reference for the EnclaveJS client SDK' --- diff --git a/docs/api-reference/enclavejs-react.mdx b/docs/enclave/api-reference/enclavejs-react.mdx similarity index 99% rename from docs/api-reference/enclavejs-react.mdx rename to docs/enclave/api-reference/enclavejs-react.mdx index 92ce440..443bb71 100644 --- a/docs/api-reference/enclavejs-react.mdx +++ b/docs/enclave/api-reference/enclavejs-react.mdx @@ -1,5 +1,5 @@ --- -title: '@enclavejs/react API' +title: '@enclavejs/react' description: 'API reference for the EnclaveJS React SDK' --- diff --git a/docs/api-reference/enclavejs-runtime.mdx b/docs/enclave/api-reference/enclavejs-runtime.mdx similarity index 99% rename from docs/api-reference/enclavejs-runtime.mdx rename to docs/enclave/api-reference/enclavejs-runtime.mdx index f9d2369..e4e2bc6 100644 --- a/docs/api-reference/enclavejs-runtime.mdx +++ b/docs/enclave/api-reference/enclavejs-runtime.mdx @@ -1,5 +1,5 @@ --- -title: '@enclavejs/runtime API' +title: '@enclavejs/runtime' description: 'API reference for the EnclaveJS runtime package' --- diff --git a/docs/enclave/api-reference/enclavejs-stream.mdx b/docs/enclave/api-reference/enclavejs-stream.mdx new file mode 100644 index 0000000..82f7ab0 --- /dev/null +++ b/docs/enclave/api-reference/enclavejs-stream.mdx @@ -0,0 +1,763 @@ +--- +title: '@enclavejs/stream' +description: 'API reference for the EnclaveJS streaming protocol implementation' +--- + +API reference for the `@enclavejs/stream` package - streaming protocol implementation including NDJSON parsing, encryption, and reconnection handling. + +## Installation + +```bash +npm install @enclavejs/stream +``` + +## NDJSON Parsing + +### serializeEvent(event) + +Serialize an event to NDJSON format (single line). + +```ts +function serializeEvent(event: MaybeEncrypted): string +``` + +**Example:** +```ts +import { serializeEvent } from '@enclavejs/stream'; + +const line = serializeEvent({ + type: 'stdout', + sessionId: 'sess_123', + seq: 1, + payload: { data: 'Hello' } +}); +// '{"type":"stdout","sessionId":"sess_123","seq":1,"payload":{"data":"Hello"}}' +``` + +### serializeEvents(events) + +Serialize multiple events to NDJSON format. + +```ts +function serializeEvents(events: MaybeEncrypted[]): string +``` + +**Example:** +```ts +import { serializeEvents } from '@enclavejs/stream'; + +const ndjson = serializeEvents([event1, event2, event3]); +// Each event on its own line +``` + +### parseLine(line) + +Parse a single NDJSON line into an event. + +```ts +function parseLine(line: string): ParseResult + +type ParseResult = + | { success: true; data: T } + | { success: false; error: string; line: string }; +``` + +**Example:** +```ts +import { parseLine } from '@enclavejs/stream'; + +const result = parseLine('{"type":"stdout","sessionId":"sess_123","seq":1,"payload":{"data":"Hi"}}'); +if (result.success) { + console.log(result.data.type); // 'stdout' +} +``` + +### parseLines(data) + +Parse multiple NDJSON lines into events. + +```ts +function parseLines(data: string): { + events: (ParsedStreamEvent | ParsedEncryptedEnvelope)[]; + errors: Array<{ line: number; error: string; content: string }>; +} +``` + +**Example:** +```ts +import { parseLines } from '@enclavejs/stream'; + +const { events, errors } = parseLines(ndjsonData); +console.log(`Parsed ${events.length} events, ${errors.length} errors`); +``` + +### NdjsonStreamParser + +Incremental NDJSON parser for streaming data. Handles partial lines across chunks. + +```ts +class NdjsonStreamParser { + constructor(options: { + onEvent: (event: ParsedStreamEvent | ParsedEncryptedEnvelope) => void; + onError: (error: { line: number; error: string; content: string }) => void; + }); + + feed(chunk: string): void; + flush(): void; + reset(): void; + getLineNumber(): number; + hasPendingData(): boolean; +} +``` + +**Example:** +```ts +import { NdjsonStreamParser } from '@enclavejs/stream'; + +const parser = new NdjsonStreamParser({ + onEvent: (event) => console.log('Event:', event.type), + onError: (error) => console.error('Parse error:', error.error), +}); + +// Feed chunks as they arrive +parser.feed('{"type":"stdout"'); +parser.feed(',"sessionId":"sess_123","seq":1,"payload":{"data":"Hi"}}\n'); + +// Flush any remaining data when stream ends +parser.flush(); +``` + +### createNdjsonParseStream() + +Create a transform stream that parses NDJSON. Works with browser `fetch()` and Node.js streams. + +```ts +function createNdjsonParseStream(): TransformStream +``` + +**Example:** +```ts +import { createNdjsonParseStream } from '@enclavejs/stream'; + +const response = await fetch('/api/stream'); +const reader = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(createNdjsonParseStream()) + .getReader(); + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + console.log('Event:', value.type); +} +``` + +### createNdjsonSerializeStream() + +Create a transform stream that serializes events to NDJSON. + +```ts +function createNdjsonSerializeStream(): TransformStream, string> +``` + +### parseNdjsonStream(stream) + +Async generator that parses NDJSON from a ReadableStream. + +```ts +async function* parseNdjsonStream( + stream: ReadableStream +): AsyncGenerator +``` + +**Example:** +```ts +import { parseNdjsonStream } from '@enclavejs/stream'; + +const response = await fetch('/api/stream'); + +for await (const event of parseNdjsonStream(response.body)) { + console.log('Event:', event.type); +} +``` + +## ECDH Key Exchange + +### generateKeyPair(curve?) + +Generate an ephemeral ECDH key pair. + +```ts +async function generateKeyPair(curve?: SupportedCurve): Promise + +interface EcdhKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `curve` | `SupportedCurve` | `'P-256'` | Elliptic curve to use | + +**Example:** +```ts +import { generateKeyPair } from '@enclavejs/stream'; + +const keyPair = await generateKeyPair(); +// Or with specific curve +const keyPair384 = await generateKeyPair('P-384'); +``` + +### exportPublicKey(publicKey) + +Export a public key to base64 format. + +```ts +async function exportPublicKey(publicKey: CryptoKey): Promise + +interface SerializedPublicKey { + publicKeyB64: string; + curve: SupportedCurve; +} +``` + +### importPublicKey(publicKeyB64, curve?) + +Import a public key from base64 format. + +```ts +async function importPublicKey( + publicKeyB64: string, + curve?: SupportedCurve +): Promise +``` + +### deriveSharedSecret(privateKey, peerPublicKey) + +Derive shared secret from private key and peer's public key. + +```ts +async function deriveSharedSecret( + privateKey: CryptoKey, + peerPublicKey: CryptoKey +): Promise +``` + +**Example:** +```ts +import { generateKeyPair, deriveSharedSecret, importPublicKey } from '@enclavejs/stream'; + +// Client side +const clientKeyPair = await generateKeyPair(); +const serverPubKey = await importPublicKey(serverPublicKeyB64); +const sharedSecret = await deriveSharedSecret(clientKeyPair.privateKey, serverPubKey); +``` + +### createClientHello(keyPair) + +Create a client hello message for the encryption handshake. + +```ts +async function createClientHello(keyPair: EcdhKeyPair): Promise +``` + +### createServerHello(keyPair, keyId) + +Create a server hello message for the encryption handshake. + +```ts +async function createServerHello(keyPair: EcdhKeyPair, keyId: string): Promise +``` + +### processClientHello(clientHello) + +Process a client hello and generate server response. + +```ts +async function processClientHello(clientHello: ClientHello): Promise<{ + serverKeyPair: EcdhKeyPair; + peerPublicKey: CryptoKey; + serverHello: ServerHello; + keyId: string; +}> +``` + +### processServerHello(serverHello) + +Process a server hello and extract peer's public key. + +```ts +async function processServerHello(serverHello: ServerHello): Promise<{ + peerPublicKey: CryptoKey; + keyId: string; +}> +``` + +### EcdhError + +Error class for ECDH operations. + +```ts +class EcdhError extends Error { + readonly code: string; + constructor(message: string, code: string); +} +``` + +## Key Derivation (HKDF) + +### deriveKey(sharedSecret, salt, info, keyLength?) + +Derive a key using HKDF-SHA256. + +```ts +async function deriveKey( + sharedSecret: Uint8Array, + salt: Uint8Array | null, + info: string, + keyLength?: number +): Promise +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sharedSecret` | `Uint8Array` | Required | Shared secret from ECDH | +| `salt` | `Uint8Array \| null` | `null` | Optional salt (defaults to zeros) | +| `info` | `string` | Required | Context info string | +| `keyLength` | `number` | `32` | Output key length in bytes | + +### deriveSessionKeys(sharedSecret, sessionId) + +Derive session keys for bidirectional communication. + +```ts +async function deriveSessionKeys( + sharedSecret: Uint8Array, + sessionId: string +): Promise<{ + clientToServerKey: Uint8Array; + serverToClientKey: Uint8Array; +}> +``` + +### importAesGcmKey(keyBytes) + +Import raw key bytes as a CryptoKey for AES-GCM. + +```ts +async function importAesGcmKey(keyBytes: Uint8Array): Promise +``` + +### deriveSessionCryptoKeys(sharedSecret, sessionId) + +Derive and import session keys as CryptoKeys. + +```ts +async function deriveSessionCryptoKeys( + sharedSecret: Uint8Array, + sessionId: string +): Promise<{ + clientToServerKey: CryptoKey; + serverToClientKey: CryptoKey; +}> +``` + +**Example:** +```ts +import { deriveSessionCryptoKeys, deriveSharedSecret } from '@enclavejs/stream'; + +const sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey); +const { clientToServerKey, serverToClientKey } = await deriveSessionCryptoKeys( + sharedSecret, + 'sess_123' +); +``` + +### HkdfError + +Error class for HKDF operations. + +```ts +class HkdfError extends Error { + readonly code: string; + constructor(message: string, code: string); +} +``` + +## AES-GCM Encryption + +### encrypt(key, plaintext, nonce, additionalData?) + +Encrypt data using AES-GCM. + +```ts +async function encrypt( + key: CryptoKey, + plaintext: Uint8Array, + nonce: Uint8Array, + additionalData?: Uint8Array +): Promise +``` + +### decrypt(key, ciphertext, nonce, additionalData?) + +Decrypt data using AES-GCM. + +```ts +async function decrypt( + key: CryptoKey, + ciphertext: Uint8Array, + nonce: Uint8Array, + additionalData?: Uint8Array +): Promise +``` + +### encryptJson(key, keyId, data, nonce?) + +Encrypt a JSON object and create an encrypted envelope payload. + +```ts +async function encryptJson( + key: CryptoKey, + keyId: string, + data: unknown, + nonce?: Uint8Array +): Promise +``` + +### decryptJson(key, payload) + +Decrypt an encrypted envelope payload and parse as JSON. + +```ts +async function decryptJson( + key: CryptoKey, + payload: EncryptedEnvelopePayload +): Promise +``` + +### createEncryptedEnvelope(key, keyId, sessionId, seq, innerEvent, nonce?) + +Create an encrypted envelope from an event. + +```ts +async function createEncryptedEnvelope( + key: CryptoKey, + keyId: string, + sessionId: SessionId, + seq: number, + innerEvent: unknown, + nonce?: Uint8Array +): Promise +``` + +### generateNonce() + +Generate a random 12-byte nonce for AES-GCM. + +```ts +function generateNonce(): Uint8Array +``` + +### generateCounterNonce(prefix, counter) + +Generate a counter-based nonce (8 bytes prefix + 4 bytes counter). + +```ts +function generateCounterNonce(prefix: Uint8Array, counter: bigint): Uint8Array +``` + +### toBase64(bytes) + +Encode bytes to base64. + +```ts +function toBase64(bytes: Uint8Array): string +``` + +### fromBase64(base64) + +Decode base64 to bytes. + +```ts +function fromBase64(base64: string): Uint8Array +``` + +### AesGcmError + +Error class for AES-GCM operations. + +```ts +class AesGcmError extends Error { + readonly code: string; + constructor(message: string, code: string); +} +``` + +## SessionEncryptionContext + +Manages encryption key state for a session. + +```ts +class SessionEncryptionContext { + constructor(key: CryptoKey, keyInfo: SessionKeyInfo); + + // Properties + readonly keyId: string; + + // Methods + needsRotation(): boolean; + encrypt(plaintext: Uint8Array): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array }>; + encryptJson(data: unknown): Promise; + createEnvelope(sessionId: SessionId, seq: number, innerEvent: unknown): Promise; + decrypt(payload: EncryptedEnvelopePayload): Promise; + decryptJson(payload: EncryptedEnvelopePayload): Promise; + getNonceCounter(): bigint; + toKeyInfo(): SessionKeyInfo; + + // Static methods + static fromKeyBytes(keyBytes: Uint8Array, keyId: string): Promise; +} +``` + +**Example:** +```ts +import { SessionEncryptionContext } from '@enclavejs/stream'; + +// Create from key bytes +const ctx = await SessionEncryptionContext.fromKeyBytes(keyBytes, 'key_123'); + +// Encrypt an event +const envelope = await ctx.createEnvelope('sess_123', 1, { + type: 'stdout', + payload: { data: 'Hello' } +}); + +// Check if key rotation is needed +if (ctx.needsRotation()) { + // Perform key rotation +} + +// Decrypt +const decrypted = await ctx.decryptJson(envelope.payload); +``` + +## Reconnection + +### ConnectionState + +Connection state enumeration. + +```ts +const ConnectionState = { + Disconnected: 'disconnected', + Connecting: 'connecting', + Connected: 'connected', + Reconnecting: 'reconnecting', + Failed: 'failed', + Closed: 'closed', +} as const; + +type ConnectionState = (typeof ConnectionState)[keyof typeof ConnectionState]; +``` + +### DEFAULT_RECONNECTION_CONFIG + +Default reconnection configuration. + +```ts +const DEFAULT_RECONNECTION_CONFIG: ReconnectionConfig = { + maxRetries: 5, + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + jitter: true, + jitterFactor: 0.3, +}; + +interface ReconnectionConfig { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; + jitter: boolean; + jitterFactor: number; +} +``` + +### ReconnectionStateMachine + +Manages connection state and automatic reconnection. + +```ts +class ReconnectionStateMachine { + constructor(options: { + config?: Partial; + onEvent: (event: ReconnectionEvent) => void; + }); + + getState(): ConnectionState; + getRetryCount(): number; + connect(): void; + onConnected(): void; + onDisconnected(reason?: string): void; + onFatalError(reason: string): void; + close(): void; + reset(): void; + canReconnect(): boolean; +} + +type ReconnectionEvent = + | { type: 'state_change'; state: ConnectionState; previousState: ConnectionState } + | { type: 'retry_scheduled'; attempt: number; delayMs: number } + | { type: 'retry_started'; attempt: number } + | { type: 'connected' } + | { type: 'disconnected'; reason?: string } + | { type: 'failed'; reason: string }; +``` + +**Example:** +```ts +import { ReconnectionStateMachine, ConnectionState } from '@enclavejs/stream'; + +const reconnect = new ReconnectionStateMachine({ + config: { maxRetries: 3 }, + onEvent: (event) => { + switch (event.type) { + case 'state_change': + console.log(`State: ${event.previousState} -> ${event.state}`); + break; + case 'retry_scheduled': + console.log(`Retry ${event.attempt} in ${event.delayMs}ms`); + break; + case 'connected': + console.log('Connected!'); + break; + case 'failed': + console.error('Connection failed:', event.reason); + break; + } + }, +}); + +reconnect.connect(); +// ... when connection succeeds +reconnect.onConnected(); +// ... when connection drops +reconnect.onDisconnected('Network error'); +``` + +### SequenceTracker + +Tracks sequence numbers and detects gaps for replay. + +```ts +class SequenceTracker { + constructor(maxGaps?: number); + + receive(seq: number): { gap: boolean; missingStart?: number; missingEnd?: number }; + getLastSeq(): number; + getGaps(): Array<{ start: number; end: number }>; + clearGap(start: number, end: number): void; + hasGaps(): boolean; + reset(): void; +} +``` + +**Example:** +```ts +import { SequenceTracker } from '@enclavejs/stream'; + +const tracker = new SequenceTracker(); + +tracker.receive(1); // { gap: false } +tracker.receive(2); // { gap: false } +tracker.receive(5); // { gap: true, missingStart: 3, missingEnd: 4 } + +console.log(tracker.getGaps()); // [{ start: 3, end: 4 }] +``` + +### EventBuffer + +Buffer for storing events during reconnection. + +```ts +class EventBuffer { + constructor(maxSize?: number); + + add(event: StreamEvent | EncryptedEnvelope): boolean; + getAll(): (StreamEvent | EncryptedEnvelope)[]; + drain(): (StreamEvent | EncryptedEnvelope)[]; + size(): number; + isFull(): boolean; + clear(): void; +} +``` + +**Example:** +```ts +import { EventBuffer } from '@enclavejs/stream'; + +const buffer = new EventBuffer(100); + +buffer.add(event1); +buffer.add(event2); + +// Get all events and clear buffer +const events = buffer.drain(); +``` + +### HeartbeatMonitor + +Monitors heartbeats to detect stale connections. + +```ts +class HeartbeatMonitor { + constructor(options: { timeoutMs: number; onTimeout: () => void }); + + start(): void; + stop(): void; + reset(): void; + onHeartbeat(): void; + getTimeSinceLastHeartbeat(): number; +} +``` + +**Example:** +```ts +import { HeartbeatMonitor } from '@enclavejs/stream'; + +const monitor = new HeartbeatMonitor({ + timeoutMs: 30000, + onTimeout: () => { + console.log('Connection stale, reconnecting...'); + reconnect(); + }, +}); + +monitor.start(); + +// When heartbeat received +monitor.onHeartbeat(); + +// When done +monitor.stop(); +``` + +## Re-exported Types + +This package re-exports all types from `@enclavejs/types`: + +```ts +export * from '@enclavejs/types'; +``` + +See [@enclavejs/types API](/api-reference/enclavejs-types) for the complete type reference. + +## Related + +- [Stream Overview](/enclavejs/stream) - Usage guide +- [@enclavejs/types](/api-reference/enclavejs-types) - Type definitions +- [@enclavejs/client](/api-reference/enclavejs-client) - Client SDK +- [Streaming Protocol](/concepts/streaming-protocol) - Protocol concepts diff --git a/docs/api-reference/enclavejs-types.mdx b/docs/enclave/api-reference/enclavejs-types.mdx similarity index 99% rename from docs/api-reference/enclavejs-types.mdx rename to docs/enclave/api-reference/enclavejs-types.mdx index ae022b4..a13f5da 100644 --- a/docs/api-reference/enclavejs-types.mdx +++ b/docs/enclave/api-reference/enclavejs-types.mdx @@ -1,5 +1,5 @@ --- -title: '@enclavejs/types API' +title: '@enclavejs/types' description: 'Type definitions and Zod schemas for EnclaveJS' --- diff --git a/docs/concepts/agentscript.mdx b/docs/enclave/concepts/agentscript.mdx similarity index 100% rename from docs/concepts/agentscript.mdx rename to docs/enclave/concepts/agentscript.mdx diff --git a/docs/concepts/architecture.mdx b/docs/enclave/concepts/architecture.mdx similarity index 100% rename from docs/concepts/architecture.mdx rename to docs/enclave/concepts/architecture.mdx diff --git a/docs/concepts/security-model.mdx b/docs/enclave/concepts/security-model.mdx similarity index 100% rename from docs/concepts/security-model.mdx rename to docs/enclave/concepts/security-model.mdx diff --git a/docs/concepts/streaming-protocol.mdx b/docs/enclave/concepts/streaming-protocol.mdx similarity index 100% rename from docs/concepts/streaming-protocol.mdx rename to docs/enclave/concepts/streaming-protocol.mdx diff --git a/docs/core-libraries/ast-guard/agentscript-preset.mdx b/docs/enclave/core-libraries/ast-guard/agentscript-preset.mdx similarity index 100% rename from docs/core-libraries/ast-guard/agentscript-preset.mdx rename to docs/enclave/core-libraries/ast-guard/agentscript-preset.mdx diff --git a/docs/enclave/core-libraries/ast-guard/babel-preset.mdx b/docs/enclave/core-libraries/ast-guard/babel-preset.mdx new file mode 100644 index 0000000..11220ad --- /dev/null +++ b/docs/enclave/core-libraries/ast-guard/babel-preset.mdx @@ -0,0 +1,229 @@ +--- +title: 'Babel Preset' +description: 'Security preset for Babel transforms inside the enclave sandbox' +--- + +The Babel preset extends the [AgentScript preset](/core-libraries/ast-guard/agentscript-preset) to enable secure TSX/JSX transformation inside the enclave. It adds the `Babel` global while maintaining all AgentScript security guarantees. + +## Overview + +The Babel preset provides: + +- **Babel.transform()** - Transform TSX/JSX to JavaScript +- **Security configs** - Per-level limits for input size, output size, timeouts +- **Preset whitelist** - Only allowed Babel presets can be used +- **Full AgentScript validation** - All blocked constructs remain blocked + +## Basic Usage + +```ts +import { createBabelPreset, JSAstValidator } from 'ast-guard'; + +const validator = new JSAstValidator(createBabelPreset({ + securityLevel: 'STANDARD', +})); + +const result = await validator.validate(` + const tsx = '
Hello
'; + const js = Babel.transform(tsx, { presets: ['react'] }); + return js.code; +`); +``` + +## Security Configurations + +The Babel preset defines security limits per security level: + +```ts +import { getBabelConfig, BABEL_SECURITY_CONFIGS } from 'ast-guard'; + +// Get config for a specific level +const config = getBabelConfig('SECURE'); + +// Or access all configs +console.log(BABEL_SECURITY_CONFIGS); +``` + +### Configuration by Security Level + +| Level | Max Input | Max Output | Timeout | Allowed Presets | +|-------|-----------|------------|---------|-----------------| +| `STRICT` | 100 KB | 500 KB | 5s | `react` | +| `SECURE` | 500 KB | 2 MB | 10s | `typescript`, `react` | +| `STANDARD` | 1 MB | 5 MB | 15s | `typescript`, `react` | +| `PERMISSIVE` | 5 MB | 25 MB | 30s | `typescript`, `react`, `env` | + + + Use `STRICT` for untrusted input where you only need JSX transformation. Use `STANDARD` for typical LLM-generated TypeScript+React code. + + +## Configuration Options + +```ts +interface BabelSecurityConfig { + /** Maximum input code size in bytes */ + maxInputSize: number; + + /** Maximum output code size in bytes */ + maxOutputSize: number; + + /** Transform timeout in milliseconds */ + transformTimeout: number; + + /** Allowed Babel preset names */ + allowedPresets: string[]; +} +``` + +### Default Configurations + +```ts +// STRICT - Minimal, JSX only +{ + maxInputSize: 100 * 1024, // 100 KB + maxOutputSize: 500 * 1024, // 500 KB + transformTimeout: 5000, // 5 seconds + allowedPresets: ['react'], +} + +// SECURE - TypeScript + React +{ + maxInputSize: 500 * 1024, // 500 KB + maxOutputSize: 2 * 1024 * 1024, // 2 MB + transformTimeout: 10000, // 10 seconds + allowedPresets: ['typescript', 'react'], +} + +// STANDARD - Default for most use cases +{ + maxInputSize: 1024 * 1024, // 1 MB + maxOutputSize: 5 * 1024 * 1024, // 5 MB + transformTimeout: 15000, // 15 seconds + allowedPresets: ['typescript', 'react'], +} + +// PERMISSIVE - Extended capabilities +{ + maxInputSize: 5 * 1024 * 1024, // 5 MB + maxOutputSize: 25 * 1024 * 1024, // 25 MB + transformTimeout: 30000, // 30 seconds + allowedPresets: ['typescript', 'react', 'env'], +} +``` + +## Allowed Globals + +The Babel preset adds these globals to the AgentScript allowlist: + +| Global | Description | +|--------|-------------| +| `Babel` | The restricted Babel transform API | +| `__safe_Babel` | Internal transformed version | + +All other [AgentScript allowed globals](/core-libraries/ast-guard/agentscript-preset#default-allowed-globals) remain available. + +## What's Blocked + +The Babel preset inherits all AgentScript security rules: + +- **Dangerous Babel options** - `plugins`, `sourceMaps`, `ast`, `babelrc`, `configFile` +- **Disallowed presets** - Any preset not in the security level's allowlist +- **All AgentScript blocked constructs** - `eval`, `Function`, `process`, etc. + + + Babel plugins are completely blocked because they can execute arbitrary code during transformation. Only presets from the allowlist can be used. + + +## Creating the Preset + +```ts +import { createBabelPreset } from 'ast-guard'; + +// Basic usage - inherits from AgentScript +const rules = createBabelPreset({ + securityLevel: 'STANDARD', +}); + +// With custom globals +const rulesWithGlobals = createBabelPreset({ + securityLevel: 'STANDARD', + allowedGlobals: ['customHelper'], +}); + +// With all AgentScript options +const fullRules = createBabelPreset({ + securityLevel: 'SECURE', + allowedGlobals: ['myGlobal'], + requireCallTool: true, + allowedLoops: { + allowFor: true, + allowForOf: true, + allowWhile: false, + }, +}); +``` + +## Using with Enclave + +The enclave automatically uses the Babel preset when configured: + +```ts +import { Enclave } from 'enclave-vm'; + +const enclave = new Enclave({ + preset: 'babel', // Uses createBabelPreset internally + securityLevel: 'STANDARD', // Determines Babel limits +}); + +// Now Babel.transform is available inside the sandbox +await enclave.run(` + const js = Babel.transform('
', { presets: ['react'] }); + return js.code; +`); +``` + +## API Reference + +### `createBabelPreset(options)` + +Creates validation rules for the Babel preset. + +```ts +function createBabelPreset(options?: BabelPresetOptions): ValidationRule[]; + +interface BabelPresetOptions extends AgentScriptOptions { + // All AgentScriptOptions are supported + // Security level determines Babel limits +} +``` + +### `getBabelConfig(level)` + +Gets Babel security configuration for a security level. + +```ts +function getBabelConfig(level?: SecurityLevel): BabelSecurityConfig; + +// Example +const config = getBabelConfig('SECURE'); +// Returns: { maxInputSize, maxOutputSize, transformTimeout, allowedPresets } +``` + +### `BABEL_SECURITY_CONFIGS` + +Direct access to all security configurations. + +```ts +const BABEL_SECURITY_CONFIGS: Record; + +// Example +const strictConfig = BABEL_SECURITY_CONFIGS.STRICT; +const standardConfig = BABEL_SECURITY_CONFIGS.STANDARD; +``` + +## Related + +- [AgentScript Preset](/core-libraries/ast-guard/agentscript-preset) - Base preset for code validation +- [Security Rules](/core-libraries/ast-guard/security-rules) - Rule reference +- [Babel Transform (enclave-vm)](/core-libraries/enclave-vm/babel-transform) - Using Babel in enclave +- [Security Levels](/core-libraries/enclave-vm/security-levels) - Security profiles diff --git a/docs/core-libraries/ast-guard/code-transform.mdx b/docs/enclave/core-libraries/ast-guard/code-transform.mdx similarity index 100% rename from docs/core-libraries/ast-guard/code-transform.mdx rename to docs/enclave/core-libraries/ast-guard/code-transform.mdx diff --git a/docs/core-libraries/ast-guard/custom-rules.mdx b/docs/enclave/core-libraries/ast-guard/custom-rules.mdx similarity index 100% rename from docs/core-libraries/ast-guard/custom-rules.mdx rename to docs/enclave/core-libraries/ast-guard/custom-rules.mdx diff --git a/docs/core-libraries/ast-guard/overview.mdx b/docs/enclave/core-libraries/ast-guard/overview.mdx similarity index 100% rename from docs/core-libraries/ast-guard/overview.mdx rename to docs/enclave/core-libraries/ast-guard/overview.mdx diff --git a/docs/core-libraries/ast-guard/pre-scanner.mdx b/docs/enclave/core-libraries/ast-guard/pre-scanner.mdx similarity index 100% rename from docs/core-libraries/ast-guard/pre-scanner.mdx rename to docs/enclave/core-libraries/ast-guard/pre-scanner.mdx diff --git a/docs/core-libraries/ast-guard/security-rules.mdx b/docs/enclave/core-libraries/ast-guard/security-rules.mdx similarity index 100% rename from docs/core-libraries/ast-guard/security-rules.mdx rename to docs/enclave/core-libraries/ast-guard/security-rules.mdx diff --git a/docs/core-libraries/enclave-vm/ai-scoring.mdx b/docs/enclave/core-libraries/enclave-vm/ai-scoring.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/ai-scoring.mdx rename to docs/enclave/core-libraries/enclave-vm/ai-scoring.mdx diff --git a/docs/enclave/core-libraries/enclave-vm/babel-transform.mdx b/docs/enclave/core-libraries/enclave-vm/babel-transform.mdx new file mode 100644 index 0000000..ccc7baa --- /dev/null +++ b/docs/enclave/core-libraries/enclave-vm/babel-transform.mdx @@ -0,0 +1,324 @@ +--- +title: 'Babel Transform' +description: 'Transform TSX/JSX code to JavaScript inside the secure enclave sandbox' +--- + +The Babel transform feature enables secure TSX/JSX transformation inside the enclave sandbox. This allows LLM-generated React components to be compiled to JavaScript safely, without exposing the host system to code execution risks. + +## Overview + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Enclave Sandbox │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ TSX Input │─────▶│ Babel │─────▶│ JS Output │ │ +│ │ │ │ Transform │ │ │ │ +│ │ │ │ (Isolated) │ │ React.create│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ Security Layer │ │ +│ │ - Preset allow │ │ +│ │ - Size limits │ │ +│ │ - Timeout │ │ +│ │ - No plugins │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```ts +import { Enclave } from 'enclave-vm'; + +// Create enclave with babel preset +const enclave = new Enclave({ + preset: 'babel', + securityLevel: 'STANDARD', +}); + +// Transform TSX inside the sandbox +const result = await enclave.run(` + const tsx = \` + interface Props { name: string; } + const Greeting = ({ name }: Props) =>

Hello, {name}!

; + \`; + + const transformed = Babel.transform(tsx, { + presets: ['typescript', 'react'], + filename: 'Greeting.tsx', + }); + + return transformed.code; +`); + +console.log(result.value); +// Output: const Greeting = ({ name }) => /*#__PURE__*/React.createElement("h1", null, "Hello, ", name, "!"); + +enclave.dispose(); +``` + +## Security Model + +The Babel transform runs in an isolated VM context with multiple security layers: + +| Layer | Protection | Description | +|-------|------------|-------------| +| **Preset Whitelist** | Controlled transforms | Only allowed presets can be used (no arbitrary plugins) | +| **Input Size Limit** | DoS prevention | Maximum source code size varies by security level | +| **Output Size Limit** | Memory protection | Prevents output expansion attacks | +| **Transform Timeout** | Resource control | Prevents infinite compilation loops | +| **Isolated Context** | Sandbox escape | Babel runs without access to fs, process, require | +| **No Plugins** | Code execution | Plugins are completely blocked (they execute arbitrary code) | + +## Configuration + +### Security Levels + +Each security level provides different limits for Babel transforms: + +| Security Level | Max Input | Max Output | Timeout | Allowed Presets | +|----------------|-----------|------------|---------|-----------------| +| `STRICT` | 100 KB | 500 KB | 5s | `react` only | +| `SECURE` | 500 KB | 2 MB | 10s | `typescript`, `react` | +| `STANDARD` | 1 MB | 5 MB | 15s | `typescript`, `react` | +| `PERMISSIVE` | 5 MB | 25 MB | 30s | `typescript`, `react`, `env` | + +```ts +import { Enclave } from 'enclave-vm'; +import { getBabelConfig } from 'ast-guard'; + +// Get config for a security level +const config = getBabelConfig('SECURE'); +console.log(config); +// { +// maxInputSize: 524288, // 500 KB +// maxOutputSize: 2097152, // 2 MB +// transformTimeout: 10000, // 10 seconds +// allowedPresets: ['typescript', 'react'] +// } +``` + +### Creating a Babel Enclave + +```ts +const enclave = new Enclave({ + preset: 'babel', // Enable Babel preset + securityLevel: 'STANDARD', // Choose security level +}); +``` + +## Transform API + +Inside the enclave, the `Babel` global provides a restricted transform API: + +```ts +interface SafeTransformOptions { + filename?: string; // For error messages (sanitized) + presets?: string[]; // Must be in allowed list + sourceType?: 'module' | 'script'; // Default: 'module' +} + +interface SafeTransformResult { + code: string; // Transformed JavaScript +} + +// Usage inside enclave +const result = Babel.transform(code, options); +``` + +### Transform Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `filename` | `string` | `'input.tsx'` | Filename for error messages | +| `presets` | `string[]` | `[]` | Babel presets to apply | +| `sourceType` | `'module' \| 'script'` | `'module'` | How to parse the code | + + + The following Babel options are **blocked** for security: + - `plugins` - Completely blocked (execute arbitrary code) + - `sourceMaps` - Blocked (path leakage) + - `ast` - Blocked (not needed, reduces attack surface) + - `babelrc` / `configFile` - Blocked (no file system access) + + +## Common Use Cases + +### Transform React Components + +```ts +const code = ` + const tsx = \` + const Button = ({ onClick, children }) => ( + + ); + \`; + + return Babel.transform(tsx, { + presets: ['react'], + filename: 'Button.jsx', + }).code; +`; + +const result = await enclave.run(code); +// Result: React.createElement("button", { className: "btn", onClick }, children) +``` + +### Transform TypeScript + JSX + +```ts +const code = ` + const tsx = \` + interface UserCardProps { + user: { name: string; email: string; }; + onEdit?: () => void; + } + + const UserCard = ({ user, onEdit }: UserCardProps) => ( +
+

{user.name}

+

{user.email}

+ {onEdit && } +
+ ); + \`; + + return Babel.transform(tsx, { + presets: ['typescript', 'react'], + filename: 'UserCard.tsx', + }).code; +`; + +const result = await enclave.run(code); +// TypeScript types are stripped, JSX is transformed +``` + +### Tool Integration for Dynamic Components + +```ts +const enclave = new Enclave({ + preset: 'babel', + securityLevel: 'STANDARD', + toolHandler: async (toolName, args) => { + if (toolName === 'component:fetch') { + // Fetch TSX from database, API, or LLM + return `

Content

`; + } + return null; + }, +}); + +const code = ` + // Fetch component TSX from external source + const tsx = await callTool('component:fetch', { title: 'Welcome' }); + + // Transform to JavaScript + const js = Babel.transform(tsx, { + presets: ['react'], + filename: 'Card.jsx', + }).code; + + return js; +`; + +const result = await enclave.run(code); +``` + +## Error Handling + +Babel transform errors are sanitized to prevent path leakage: + +```ts +const code = ` + try { + // Invalid JSX + Babel.transform(' + The Babel context is cached between transforms. The first transform (cold start) takes ~20-50ms, subsequent transforms are much faster. + + +### Performance Tips + +1. **Batch transforms** - Transform multiple components in a single enclave run +2. **Reuse enclave** - Don't create/dispose for each transform +3. **Minimize types** - Complex TypeScript types increase transform time +4. **Use STANDARD level** - Good balance of security and performance + +```ts +// Good: Reuse enclave for multiple transforms +const enclave = new Enclave({ preset: 'babel' }); + +const components = ['Button', 'Card', 'Modal']; +const results = await enclave.run(` + const components = ${JSON.stringify(componentCode)}; + return components.map(tsx => + Babel.transform(tsx, { presets: ['react'] }).code + ); +`); + +enclave.dispose(); +``` + +## Direct API (Outside Enclave) + +For server-side use without the full enclave sandbox, use `createRestrictedBabel`: + +```ts +import { createRestrictedBabel } from 'enclave-vm'; +import { getBabelConfig } from 'ast-guard'; + +const config = getBabelConfig('STANDARD'); +const babel = createRestrictedBabel(config); + +const result = babel.transform(tsxCode, { + presets: ['typescript', 'react'], + filename: 'Component.tsx', +}); + +console.log(result.code); +``` + + + The direct API still runs Babel in an isolated VM context, but doesn't provide the full enclave sandbox features (tool calls, iteration limits, etc.). Use the full enclave for LLM-generated code. + + +## Related + +- [Overview](/core-libraries/enclave-vm/overview) - Enclave introduction +- [Security Levels](/core-libraries/enclave-vm/security-levels) - Security profiles +- [AgentScript Preset](/core-libraries/ast-guard/agentscript-preset) - AST validation for enclave code +- [Tool System](/core-libraries/enclave-vm/tool-system) - Integrating tools with enclave diff --git a/docs/core-libraries/enclave-vm/configuration.mdx b/docs/enclave/core-libraries/enclave-vm/configuration.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/configuration.mdx rename to docs/enclave/core-libraries/enclave-vm/configuration.mdx diff --git a/docs/core-libraries/enclave-vm/double-vm.mdx b/docs/enclave/core-libraries/enclave-vm/double-vm.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/double-vm.mdx rename to docs/enclave/core-libraries/enclave-vm/double-vm.mdx diff --git a/docs/core-libraries/enclave-vm/overview.mdx b/docs/enclave/core-libraries/enclave-vm/overview.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/overview.mdx rename to docs/enclave/core-libraries/enclave-vm/overview.mdx diff --git a/docs/core-libraries/enclave-vm/reference-sidecar.mdx b/docs/enclave/core-libraries/enclave-vm/reference-sidecar.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/reference-sidecar.mdx rename to docs/enclave/core-libraries/enclave-vm/reference-sidecar.mdx diff --git a/docs/core-libraries/enclave-vm/security-levels.mdx b/docs/enclave/core-libraries/enclave-vm/security-levels.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/security-levels.mdx rename to docs/enclave/core-libraries/enclave-vm/security-levels.mdx diff --git a/docs/core-libraries/enclave-vm/tool-system.mdx b/docs/enclave/core-libraries/enclave-vm/tool-system.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/tool-system.mdx rename to docs/enclave/core-libraries/enclave-vm/tool-system.mdx diff --git a/docs/core-libraries/enclave-vm/worker-pool.mdx b/docs/enclave/core-libraries/enclave-vm/worker-pool.mdx similarity index 100% rename from docs/core-libraries/enclave-vm/worker-pool.mdx rename to docs/enclave/core-libraries/enclave-vm/worker-pool.mdx diff --git a/docs/enclavejs/broker.mdx b/docs/enclave/enclavejs/broker.mdx similarity index 100% rename from docs/enclavejs/broker.mdx rename to docs/enclave/enclavejs/broker.mdx diff --git a/docs/enclavejs/client.mdx b/docs/enclave/enclavejs/client.mdx similarity index 100% rename from docs/enclavejs/client.mdx rename to docs/enclave/enclavejs/client.mdx diff --git a/docs/enclavejs/overview.mdx b/docs/enclave/enclavejs/overview.mdx similarity index 100% rename from docs/enclavejs/overview.mdx rename to docs/enclave/enclavejs/overview.mdx diff --git a/docs/enclavejs/react.mdx b/docs/enclave/enclavejs/react.mdx similarity index 100% rename from docs/enclavejs/react.mdx rename to docs/enclave/enclavejs/react.mdx diff --git a/docs/enclavejs/runtime.mdx b/docs/enclave/enclavejs/runtime.mdx similarity index 100% rename from docs/enclavejs/runtime.mdx rename to docs/enclave/enclavejs/runtime.mdx diff --git a/docs/enclavejs/stream.mdx b/docs/enclave/enclavejs/stream.mdx similarity index 100% rename from docs/enclavejs/stream.mdx rename to docs/enclave/enclavejs/stream.mdx diff --git a/docs/enclavejs/types.mdx b/docs/enclave/enclavejs/types.mdx similarity index 100% rename from docs/enclavejs/types.mdx rename to docs/enclave/enclavejs/types.mdx diff --git a/docs/examples/encrypted-sessions.mdx b/docs/enclave/examples/encrypted-sessions.mdx similarity index 100% rename from docs/examples/encrypted-sessions.mdx rename to docs/enclave/examples/encrypted-sessions.mdx diff --git a/docs/examples/hello-world.mdx b/docs/enclave/examples/hello-world.mdx similarity index 100% rename from docs/examples/hello-world.mdx rename to docs/enclave/examples/hello-world.mdx diff --git a/docs/examples/multi-tenant.mdx b/docs/enclave/examples/multi-tenant.mdx similarity index 100% rename from docs/examples/multi-tenant.mdx rename to docs/enclave/examples/multi-tenant.mdx diff --git a/docs/examples/streaming-ui.mdx b/docs/enclave/examples/streaming-ui.mdx similarity index 100% rename from docs/examples/streaming-ui.mdx rename to docs/enclave/examples/streaming-ui.mdx diff --git a/docs/examples/tool-calling.mdx b/docs/enclave/examples/tool-calling.mdx similarity index 100% rename from docs/examples/tool-calling.mdx rename to docs/enclave/examples/tool-calling.mdx diff --git a/docs/getting-started/installation.mdx b/docs/enclave/getting-started/installation.mdx similarity index 100% rename from docs/getting-started/installation.mdx rename to docs/enclave/getting-started/installation.mdx diff --git a/docs/getting-started/quickstart.mdx b/docs/enclave/getting-started/quickstart.mdx similarity index 100% rename from docs/getting-started/quickstart.mdx rename to docs/enclave/getting-started/quickstart.mdx diff --git a/docs/getting-started/welcome.mdx b/docs/enclave/getting-started/welcome.mdx similarity index 100% rename from docs/getting-started/welcome.mdx rename to docs/enclave/getting-started/welcome.mdx diff --git a/docs/guides/first-agent.mdx b/docs/enclave/guides/first-agent.mdx similarity index 100% rename from docs/guides/first-agent.mdx rename to docs/enclave/guides/first-agent.mdx diff --git a/docs/guides/production-deployment.mdx b/docs/enclave/guides/production-deployment.mdx similarity index 100% rename from docs/guides/production-deployment.mdx rename to docs/enclave/guides/production-deployment.mdx diff --git a/docs/guides/react-code-editor.mdx b/docs/enclave/guides/react-code-editor.mdx similarity index 100% rename from docs/guides/react-code-editor.mdx rename to docs/enclave/guides/react-code-editor.mdx diff --git a/docs/guides/scaling.mdx b/docs/enclave/guides/scaling.mdx similarity index 100% rename from docs/guides/scaling.mdx rename to docs/enclave/guides/scaling.mdx diff --git a/docs/guides/security-hardening.mdx b/docs/enclave/guides/security-hardening.mdx similarity index 100% rename from docs/guides/security-hardening.mdx rename to docs/enclave/guides/security-hardening.mdx diff --git a/docs/guides/tool-integration.mdx b/docs/enclave/guides/tool-integration.mdx similarity index 100% rename from docs/guides/tool-integration.mdx rename to docs/enclave/guides/tool-integration.mdx diff --git a/docs/libraries/ast-guard.mdx b/docs/enclave/libraries/ast-guard.mdx similarity index 100% rename from docs/libraries/ast-guard.mdx rename to docs/enclave/libraries/ast-guard.mdx diff --git a/docs/libraries/enclave.mdx b/docs/enclave/libraries/enclave.mdx similarity index 100% rename from docs/libraries/enclave.mdx rename to docs/enclave/libraries/enclave.mdx diff --git a/docs/troubleshooting/common-errors.mdx b/docs/enclave/troubleshooting/common-errors.mdx similarity index 100% rename from docs/troubleshooting/common-errors.mdx rename to docs/enclave/troubleshooting/common-errors.mdx diff --git a/docs/troubleshooting/debugging.mdx b/docs/enclave/troubleshooting/debugging.mdx similarity index 100% rename from docs/troubleshooting/debugging.mdx rename to docs/enclave/troubleshooting/debugging.mdx diff --git a/docs/troubleshooting/faq.mdx b/docs/enclave/troubleshooting/faq.mdx similarity index 100% rename from docs/troubleshooting/faq.mdx rename to docs/enclave/troubleshooting/faq.mdx diff --git a/docs/enclave/updates.mdx b/docs/enclave/updates.mdx new file mode 100644 index 0000000..15e0abc --- /dev/null +++ b/docs/enclave/updates.mdx @@ -0,0 +1,7 @@ +--- +title: "Updates" +description: "Changelog and release history for Enclave" +icon: "clock-rotate-left" +--- + +{/* This file is auto-generated by docs sync from GitHub releases */} diff --git a/libs/ast-guard/src/index.ts b/libs/ast-guard/src/index.ts index 0eb4db9..7f46918 100644 --- a/libs/ast-guard/src/index.ts +++ b/libs/ast-guard/src/index.ts @@ -97,9 +97,19 @@ export { AGENTSCRIPT_PERMISSIVE_GLOBALS, AGENTSCRIPT_BASE_GLOBALS, // Legacy alias for STRICT getAgentScriptGlobals, + // Babel preset for TSX/JSX transformation + createBabelPreset, + getBabelConfig, + BABEL_SECURITY_CONFIGS, } from './presets'; -export type { PresetOptions, AgentScriptOptions, SecurityLevel } from './presets'; +export type { + PresetOptions, + AgentScriptOptions, + SecurityLevel, + BabelPresetOptions, + BabelSecurityConfig, +} from './presets'; // AgentScript tool descriptions for AI agents export { @@ -117,6 +127,10 @@ export { // Concatenation transformation transformConcatenation, transformTemplateLiterals, + // Import rewriting + rewriteImports, + isValidPackageName, + isValidSubpath, } from './transforms'; export type { @@ -124,6 +138,8 @@ export type { StringExtractionResult, ConcatTransformConfig, ConcatTransformResult, + ImportRewriteConfig, + ImportRewriteResult, } from './transforms'; // Pre-Scanner (Layer 0 Defense) diff --git a/libs/ast-guard/src/presets/babel.preset.ts b/libs/ast-guard/src/presets/babel.preset.ts new file mode 100644 index 0000000..0a18f5a --- /dev/null +++ b/libs/ast-guard/src/presets/babel.preset.ts @@ -0,0 +1,231 @@ +/** + * Babel Preset for Enclave + * + * Extends the AgentScript preset with Babel.transform() support. + * Security limits are determined by the security level. + * + * @packageDocumentation + */ + +import { ValidationRule } from '../interfaces'; +import { createAgentScriptPreset, AgentScriptOptions, SecurityLevel } from './agentscript.preset'; + +/** + * Babel configuration per security level + * + * These limits control resource usage for Babel transforms: + * - maxInputSize: Maximum source code size to transform + * - maxOutputSize: Maximum transformed output size + * - transformTimeout: Maximum time for transformation (reserved) + * - allowedPresets: Which Babel presets can be used + * - Multi-file limits (maxFiles, maxTotalInputSize, maxTotalOutputSize) + */ +export interface BabelSecurityConfig { + /** + * Maximum input code size in bytes (single file) + */ + maxInputSize: number; + + /** + * Maximum output code size in bytes (single file) + */ + maxOutputSize: number; + + /** + * Transform timeout in milliseconds + */ + transformTimeout: number; + + /** + * Allowed Babel preset names + */ + allowedPresets: string[]; + + /** + * Maximum number of files for multi-file transform + */ + maxFiles: number; + + /** + * Maximum total input size across all files (bytes) + */ + maxTotalInputSize: number; + + /** + * Maximum total output size across all files (bytes) + */ + maxTotalOutputSize: number; +} + +/** + * Babel security configurations per security level + * + * STRICT: Minimal - only JSX transformation (react preset) + * SECURE: Standard - TypeScript + React + * STANDARD: Standard - TypeScript + React + * PERMISSIVE: Extended - TypeScript + React + env + * + * Multi-file limits per level: + * | Level | Max Files | Max Total Input | Max Total Output | + * |------------|-----------|-----------------|------------------| + * | STRICT | 3 | 200 KB | 1 MB | + * | SECURE | 10 | 1 MB | 5 MB | + * | STANDARD | 25 | 5 MB | 25 MB | + * | PERMISSIVE | 100 | 25 MB | 125 MB | + */ +export const BABEL_SECURITY_CONFIGS: Record = { + /** + * STRICT: Minimal Babel access + * - Small input limit (100KB) + * - Only react preset (JSX transformation) + * - No TypeScript (reduces attack surface) + * - Max 3 files for multi-file transform + */ + STRICT: { + maxInputSize: 100 * 1024, // 100KB + maxOutputSize: 500 * 1024, // 500KB + transformTimeout: 5000, // 5s + allowedPresets: ['react'], // Minimal - JSX only + maxFiles: 3, + maxTotalInputSize: 200 * 1024, // 200KB + maxTotalOutputSize: 1024 * 1024, // 1MB + }, + + /** + * SECURE: Standard Babel access + * - Medium input limit (500KB) + * - TypeScript + React presets + * - Max 10 files for multi-file transform + */ + SECURE: { + maxInputSize: 500 * 1024, // 500KB + maxOutputSize: 2 * 1024 * 1024, // 2MB + transformTimeout: 10000, // 10s + allowedPresets: ['typescript', 'react'], + maxFiles: 10, + maxTotalInputSize: 1024 * 1024, // 1MB + maxTotalOutputSize: 5 * 1024 * 1024, // 5MB + }, + + /** + * STANDARD: Standard Babel access (same as SECURE) + * - Medium input limit (1MB) + * - TypeScript + React presets + * - Max 25 files for multi-file transform + */ + STANDARD: { + maxInputSize: 1024 * 1024, // 1MB + maxOutputSize: 5 * 1024 * 1024, // 5MB + transformTimeout: 15000, // 15s + allowedPresets: ['typescript', 'react'], + maxFiles: 25, + maxTotalInputSize: 5 * 1024 * 1024, // 5MB + maxTotalOutputSize: 25 * 1024 * 1024, // 25MB + }, + + /** + * PERMISSIVE: Extended Babel access + * - Large input limit (5MB) + * - TypeScript + React + env presets + * - Max 100 files for multi-file transform + */ + PERMISSIVE: { + maxInputSize: 5 * 1024 * 1024, // 5MB + maxOutputSize: 25 * 1024 * 1024, // 25MB + transformTimeout: 30000, // 30s + allowedPresets: ['typescript', 'react', 'env'], + maxFiles: 100, + maxTotalInputSize: 25 * 1024 * 1024, // 25MB + maxTotalOutputSize: 125 * 1024 * 1024, // 125MB + }, +}; + +/** + * Options for Babel preset + * + * Extends AgentScriptOptions with Babel-specific options. + * The security level controls both AST validation and Babel limits. + */ +export type BabelPresetOptions = AgentScriptOptions; + +/** + * Get Babel configuration for a security level + * + * Use this to retrieve the Babel limits (input/output size, allowed presets) + * for a given security level. + * + * @param securityLevel - The security level (default: STANDARD) + * @returns Babel security configuration + * + * @example + * ```typescript + * import { getBabelConfig } from 'ast-guard'; + * + * const config = getBabelConfig('SECURE'); + * console.log(config.allowedPresets); // ['typescript', 'react'] + * console.log(config.maxInputSize); // 524288 (500KB) + * ``` + */ +export function getBabelConfig(securityLevel: SecurityLevel = 'STANDARD'): BabelSecurityConfig { + const config = BABEL_SECURITY_CONFIGS[securityLevel]; + return { ...config, allowedPresets: [...config.allowedPresets] }; +} + +/** + * Create a Babel preset for AST validation + * + * This preset extends the AgentScript preset with: + * - `Babel` global (the restricted Babel.transform API) + * - `__safe_Babel` (transformed version) + * + * The Babel global provides: + * - `Babel.transform(code, options)` - Transform TSX/JSX code + * + * Security measures: + * - Preset whitelist per security level + * - Input/output size limits per security level + * - No plugins allowed (they execute arbitrary code) + * - No source maps (path leakage) + * - No AST output + * + * @param options - Babel preset options + * @returns Array of validation rules + * + * @example + * ```typescript + * import { createBabelPreset, JSAstValidator } from 'ast-guard'; + * + * const rules = createBabelPreset({ + * securityLevel: 'SECURE', + * }); + * + * const validator = new JSAstValidator(rules); + * const result = await validator.validate(code); + * ``` + * + * @example + * ```javascript + * // Inside enclave with babel preset: + * const js = Babel.transform(tsx, { + * filename: 'App.tsx', + * presets: ['typescript', 'react'], + * sourceType: 'module', + * }).code; + * + * return js; + * ``` + */ +export function createBabelPreset(options: BabelPresetOptions = {}): ValidationRule[] { + const securityLevel = options.securityLevel ?? 'STANDARD'; + const baseGlobals = options.allowedGlobals ?? []; + + return createAgentScriptPreset({ + ...options, + securityLevel, + allowedGlobals: [ + ...baseGlobals, + 'Babel', // The Babel global + '__safe_Babel', // Transformed version (for consistency) + ], + }); +} diff --git a/libs/ast-guard/src/presets/index.ts b/libs/ast-guard/src/presets/index.ts index 383fb81..e74485c 100644 --- a/libs/ast-guard/src/presets/index.ts +++ b/libs/ast-guard/src/presets/index.ts @@ -31,6 +31,15 @@ export { type SecurityLevel, } from './agentscript.preset'; +// Babel preset for TSX/JSX transformation +export { + createBabelPreset, + getBabelConfig, + BABEL_SECURITY_CONFIGS, + type BabelPresetOptions, + type BabelSecurityConfig, +} from './babel.preset'; + // Re-export for convenience import { ValidationRule } from '../interfaces'; import { ConfigurationError } from '../errors'; diff --git a/libs/ast-guard/src/transforms/import-rewrite.transform.ts b/libs/ast-guard/src/transforms/import-rewrite.transform.ts new file mode 100644 index 0000000..f361d55 --- /dev/null +++ b/libs/ast-guard/src/transforms/import-rewrite.transform.ts @@ -0,0 +1,330 @@ +/** + * Import Rewrite Transform + * + * Transforms npm package imports to ESM CDN URLs with specific versions. + * This enables sandboxed code to import npm packages from a trusted CDN + * without needing a bundler or package manager. + * + * SECURITY CONSIDERATIONS: + * - Only packages explicitly listed in packageVersions can be imported + * - Package names and subpaths are validated against strict regex patterns + * - CDN base URL must be HTTPS + * - Local imports (./foo, ../bar) are skipped by default + * + * @packageDocumentation + */ + +import * as acorn from 'acorn'; +import { generate } from 'astring'; +import type { ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, Program, Literal } from 'estree'; + +/** + * Configuration for import rewriting + */ +export interface ImportRewriteConfig { + /** + * Whether import rewriting is enabled + */ + enabled: boolean; + + /** + * Base URL for the ESM CDN (must be HTTPS) + * @example 'https://esm.agentfront.dev' + */ + cdnBaseUrl: string; + + /** + * Package versions to use for each npm package. + * - Specify a version string to pin: `'react': '18.2.0'` → `/react@18.2.0` + * - Use empty string for latest: `'react': ''` → `/react` (no @version) + * + * Only packages listed here (or in allowedPackages) can be imported. + * @example { 'react': '18.2.0', '@mui/material': '' } + */ + packageVersions: Record; + + /** + * Optional allowlist of packages that can be imported without specifying a version. + * Packages in this list but not in packageVersions will use latest (no @version in URL). + * @example ['react', 'react-dom', '@mui/material'] + */ + allowedPackages?: string[]; + + /** + * Whether to skip local imports (./foo, ../bar) + * @default true + */ + skipLocalImports?: boolean; +} + +/** + * Result of import rewriting + */ +export interface ImportRewriteResult { + /** + * The rewritten code + */ + code: string; + + /** + * List of imports that were rewritten + */ + rewrittenImports: Array<{ + /** + * Original import source + * @example '@mui/material/Button' + */ + original: string; + + /** + * Rewritten CDN URL + * @example 'https://esm.agentfront.dev/@mui/material@5.15.0/Button' + */ + rewritten: string; + }>; + + /** + * List of imports that were not rewritten (e.g., local imports) + */ + skippedImports: string[]; +} + +/** + * Regex to validate npm package names + * Matches: 'react', '@mui/material', '@scope/package-name' + * Based on npm package naming rules + */ +const PACKAGE_NAME_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i; + +/** + * Regex to validate subpaths (no path traversal) + * Matches: 'Button', 'components/Button', 'esm/index' + * Rejects: '../foo', './bar', paths with '..' + */ +const SUBPATH_REGEX = /^[a-zA-Z0-9\-_./]+$/; + +/** + * Check if an import is a local import (starts with ./ or ../) + */ +function isLocalImport(source: string): boolean { + return source.startsWith('./') || source.startsWith('../'); +} + +/** + * Parse an import source into package name and subpath + * + * @example + * parseImportSource('react') => { packageName: 'react', subpath: undefined } + * parseImportSource('@mui/material/Button') => { packageName: '@mui/material', subpath: 'Button' } + * parseImportSource('lodash/debounce') => { packageName: 'lodash', subpath: 'debounce' } + */ +function parseImportSource(source: string): { packageName: string; subpath?: string } { + // Handle scoped packages (@scope/name) + if (source.startsWith('@')) { + const parts = source.split('/'); + if (parts.length >= 2) { + const packageName = `${parts[0]}/${parts[1]}`; + const subpath = parts.length > 2 ? parts.slice(2).join('/') : undefined; + return { packageName, subpath }; + } + } + + // Handle regular packages + const parts = source.split('/'); + const packageName = parts[0]; + const subpath = parts.length > 1 ? parts.slice(1).join('/') : undefined; + + return { packageName, subpath }; +} + +/** + * Validate a package name against security rules + */ +function validatePackageName(packageName: string): void { + if (!PACKAGE_NAME_REGEX.test(packageName)) { + throw new Error(`Invalid package name: "${packageName}". Package names must follow npm naming conventions.`); + } +} + +/** + * Validate a subpath against security rules + */ +function validateSubpath(subpath: string): void { + if (!SUBPATH_REGEX.test(subpath)) { + throw new Error(`Invalid subpath: "${subpath}". Subpaths must not contain path traversal sequences.`); + } + + // Additional check for path traversal + if (subpath.includes('..')) { + throw new Error(`Invalid subpath: "${subpath}". Path traversal is not allowed.`); + } +} + +/** + * Validate the CDN base URL + */ +function validateCdnUrl(cdnBaseUrl: string): void { + try { + const url = new URL(cdnBaseUrl); + if (url.protocol !== 'https:') { + throw new Error(`CDN URL must use HTTPS: "${cdnBaseUrl}"`); + } + } catch (error) { + if (error instanceof Error && error.message.includes('HTTPS')) { + throw error; + } + throw new Error(`Invalid CDN URL: "${cdnBaseUrl}"`); + } +} + +/** + * Rewrite imports in JavaScript/TypeScript code to use CDN URLs + * + * @param code - The source code to transform + * @param config - Import rewrite configuration + * @returns The transformed code and list of rewritten imports + * + * @example + * ```typescript + * const result = rewriteImports( + * `import React from 'react'; + * import Button from '@mui/material/Button';`, + * { + * enabled: true, + * cdnBaseUrl: 'https://esm.agentfront.dev', + * packageVersions: { + * 'react': '18.2.0', + * '@mui/material': '5.15.0' + * } + * } + * ); + * + * // result.code: + * // import React from 'https://esm.agentfront.dev/react@18.2.0'; + * // import Button from 'https://esm.agentfront.dev/@mui/material@5.15.0/Button'; + * ``` + */ +export function rewriteImports(code: string, config: ImportRewriteConfig): ImportRewriteResult { + // Return early if disabled + if (!config.enabled) { + return { + code, + rewrittenImports: [], + skippedImports: [], + }; + } + + // Validate CDN URL + validateCdnUrl(config.cdnBaseUrl); + + const skipLocalImports = config.skipLocalImports ?? true; + + // If allowedPackages is provided, use it as the exclusive allowlist + // Otherwise, use packageVersions keys as the allowlist + const allowedPackages = config.allowedPackages + ? new Set(config.allowedPackages) + : new Set(Object.keys(config.packageVersions)); + + // Parse the code into an AST + // Note: acorn only parses JavaScript. For TypeScript/JSX, run Babel transform first. + let ast: Program; + try { + ast = acorn.parse(code, { + ecmaVersion: 'latest', + sourceType: 'module', + }) as unknown as Program; + } catch (error) { + throw new Error(`Failed to parse JavaScript code for import rewriting: ${(error as Error).message}`); + } + + const rewrittenImports: Array<{ original: string; rewritten: string }> = []; + const skippedImports: string[] = []; + + // Helper function to rewrite a module source + const rewriteSource = (source: string, sourceNode: Literal): void => { + // Skip local imports if configured + if (isLocalImport(source)) { + if (skipLocalImports) { + skippedImports.push(source); + return; + } + } + + // Parse the import source + const { packageName, subpath } = parseImportSource(source); + + // Check if package is allowed + if (!allowedPackages.has(packageName)) { + throw new Error( + `Package "${packageName}" is not allowed. ` + + `Only packages listed in packageVersions or allowedPackages can be imported: ${[...allowedPackages].join(', ')}`, + ); + } + + // Validate package name + validatePackageName(packageName); + + // Validate subpath if present + if (subpath) { + validateSubpath(subpath); + } + + // Get version from config (may be empty string or undefined for latest) + const version = config.packageVersions[packageName]; + + // Construct CDN URL + // If version is empty or undefined, don't include @version (use latest) + const packageWithVersion = version ? `${packageName}@${version}` : packageName; + const cdnUrl = subpath + ? `${config.cdnBaseUrl}/${packageWithVersion}/${subpath}` + : `${config.cdnBaseUrl}/${packageWithVersion}`; + + // Update the source + sourceNode.value = cdnUrl; + sourceNode.raw = JSON.stringify(cdnUrl); + + rewrittenImports.push({ + original: source, + rewritten: cdnUrl, + }); + }; + + // Walk through all import and export declarations + for (const node of ast.body) { + if (node.type === 'ImportDeclaration') { + const importNode = node as ImportDeclaration; + const source = importNode.source.value as string; + rewriteSource(source, importNode.source as Literal); + } else if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { + const exportNode = node as ExportNamedDeclaration | ExportAllDeclaration; + // Only process re-exports (those with a source) + if (exportNode.source) { + const source = exportNode.source.value as string; + rewriteSource(source, exportNode.source as Literal); + } + } + } + + // Generate code from the modified AST + const rewrittenCode = generate(ast); + + return { + code: rewrittenCode, + rewrittenImports, + skippedImports, + }; +} + +/** + * Check if a string is a valid npm package name + */ +export function isValidPackageName(name: string): boolean { + return PACKAGE_NAME_REGEX.test(name); +} + +/** + * Check if a string is a valid subpath (no path traversal) + */ +export function isValidSubpath(subpath: string): boolean { + return SUBPATH_REGEX.test(subpath) && !subpath.includes('..'); +} diff --git a/libs/ast-guard/src/transforms/index.ts b/libs/ast-guard/src/transforms/index.ts index e51812f..8003c29 100644 --- a/libs/ast-guard/src/transforms/index.ts +++ b/libs/ast-guard/src/transforms/index.ts @@ -21,3 +21,12 @@ export { ConcatTransformConfig, ConcatTransformResult, } from './concat.transform'; + +// Import rewriting +export { + rewriteImports, + isValidPackageName, + isValidSubpath, + type ImportRewriteConfig, + type ImportRewriteResult, +} from './import-rewrite.transform'; diff --git a/libs/enclave-vm/package.json b/libs/enclave-vm/package.json index 0ad5204..3dc5119 100644 --- a/libs/enclave-vm/package.json +++ b/libs/enclave-vm/package.json @@ -36,6 +36,7 @@ } }, "dependencies": { + "@babel/standalone": "^7.28.6", "@enclavejs/types": "0.1.0", "ast-guard": "2.4.0", "acorn": "8.15.0", @@ -45,7 +46,7 @@ }, "peerDependencies": { "@huggingface/transformers": "^3.2.2", - "vectoriadb": "^2.0.0" + "vectoriadb": "^2.1.3" }, "peerDependenciesMeta": { "@huggingface/transformers": { diff --git a/libs/enclave-vm/src/__tests__/babel-examples.spec.ts b/libs/enclave-vm/src/__tests__/babel-examples.spec.ts new file mode 100644 index 0000000..c9c8d65 --- /dev/null +++ b/libs/enclave-vm/src/__tests__/babel-examples.spec.ts @@ -0,0 +1,423 @@ +/** + * Babel Transform Examples Tests + * + * Unit tests validating all 50 TSX/JSX examples transform correctly. + * + * @packageDocumentation + */ + +import { createRestrictedBabel, resetBabelContext, BabelWrapperConfig } from '../babel'; +import { + BABEL_EXAMPLES, + COMPLEXITY_LEVELS, + getExamplesByLevel, + getLevelStats, + ComplexityLevel, + ComponentExample, +} from './babel-examples'; + +describe('Babel Transform Examples', () => { + const defaultConfig: BabelWrapperConfig = { + maxInputSize: 1024 * 1024, // 1MB + maxOutputSize: 5 * 1024 * 1024, // 5MB + allowedPresets: ['typescript', 'react'], + transformTimeout: 15000, + }; + + let babel: ReturnType; + + beforeAll(() => { + babel = createRestrictedBabel(defaultConfig); + }); + + afterAll(() => { + resetBabelContext(); + }); + + describe('Example Coverage', () => { + it('should have exactly 50 examples', () => { + expect(BABEL_EXAMPLES.length).toBe(50); + }); + + it('should have 10 examples per complexity level', () => { + const stats = getLevelStats(); + + for (const level of COMPLEXITY_LEVELS) { + expect(stats[level].count).toBe(10); + } + }); + + it('should have unique IDs for all examples', () => { + const ids = BABEL_EXAMPLES.map((e) => e.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(BABEL_EXAMPLES.length); + }); + + it('should have IDs from 1 to 50', () => { + const ids = BABEL_EXAMPLES.map((e) => e.id).sort((a, b) => a - b); + expect(ids).toEqual(Array.from({ length: 50 }, (_, i) => i + 1)); + }); + + it('should have unique names for all examples', () => { + const names = BABEL_EXAMPLES.map((e) => e.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(BABEL_EXAMPLES.length); + }); + }); + + describe.each(COMPLEXITY_LEVELS)('Level: %s', (level: ComplexityLevel) => { + const examples = getExamplesByLevel(level); + + describe('Transform validation', () => { + it.each(examples.map((e) => [e.name, e] as const))( + '%s - transforms without errors', + (_name: string, example: ComponentExample) => { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + expect(result).toBeDefined(); + expect(result.code).toBeDefined(); + expect(typeof result.code).toBe('string'); + expect(result.code.length).toBeGreaterThan(0); + }, + ); + + it.each(examples.map((e) => [e.name, e] as const))( + '%s - contains expected patterns', + (_name: string, example: ComponentExample) => { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + for (const pattern of example.expectedPatterns) { + expect(result.code).toContain(pattern); + } + }, + ); + + it.each(examples.map((e) => [e.name, e] as const))( + '%s - does not contain forbidden patterns', + (_name: string, example: ComponentExample) => { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + for (const pattern of example.forbiddenPatterns ?? []) { + expect(result.code).not.toContain(pattern); + } + }, + ); + }); + }); + + describe('TypeScript Type Stripping', () => { + const examplesWithTypes = BABEL_EXAMPLES.filter((e) => e.forbiddenPatterns && e.forbiddenPatterns.length > 0); + + it('should have examples with TypeScript types to strip', () => { + expect(examplesWithTypes.length).toBeGreaterThan(0); + }); + + it.each(examplesWithTypes.map((e) => [e.name, e] as const))( + '%s - strips all TypeScript types', + (_name: string, example: ComponentExample) => { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // Verify no TypeScript-specific syntax remains + expect(result.code).not.toMatch(/\binterface\s+\w+/); + expect(result.code).not.toMatch(/\btype\s+\w+\s*=/); + expect(result.code).not.toMatch(/:\s*\w+\[\]/); + expect(result.code).not.toMatch(/<\w+>/); // Generic brackets (when not JSX) + }, + ); + }); + + describe('JSX Transformation', () => { + it('should transform JSX to React.createElement calls', () => { + for (const example of BABEL_EXAMPLES) { + // Use explicit classic runtime to ensure React.createElement output + const result = babel.transform(example.code, { + presets: ['typescript', 'react'] as string[], + filename: `${example.name}.tsx`, + }); + + // All examples should produce React.createElement calls (classic runtime) + expect(result.code).toContain('React.createElement'); + } + }); + + it('should not contain JSX syntax in output', () => { + for (const example of BABEL_EXAMPLES) { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'] as string[], + filename: `${example.name}.tsx`, + }); + + // No JSX opening/closing tags should remain + expect(result.code).not.toMatch(/<[A-Z][a-zA-Z]*[^>]*>/); + expect(result.code).not.toMatch(/<\/[A-Z][a-zA-Z]*>/); + expect(result.code).not.toMatch(/<[a-z]+[^>]*>/); + expect(result.code).not.toMatch(/<\/[a-z]+>/); + // Fragment syntax + expect(result.code).not.toMatch(/<>/); + expect(result.code).not.toMatch(/<\/>/); + } + }); + }); + + describe('Output Validation', () => { + it('should produce valid JavaScript for all examples', () => { + for (const example of BABEL_EXAMPLES) { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // Attempting to parse the output should not throw + expect(() => { + // Basic syntax check - if this throws, the output is invalid JS + new Function(result.code); + }).not.toThrow(); + } + }); + + it('should produce non-empty code for all examples', () => { + for (const example of BABEL_EXAMPLES) { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // All examples should produce non-trivial output + expect(result.code.length).toBeGreaterThan(50); + // Should contain at least one const or function declaration + expect(result.code).toMatch(/\b(const|function|class)\s+\w+/); + } + }); + }); + + describe('Complexity Level Characteristics', () => { + describe('L1_MINIMAL', () => { + const l1Examples = getExamplesByLevel('L1_MINIMAL'); + + it('should have simple, single-element components', () => { + for (const example of l1Examples) { + // L1 examples should be relatively short + expect(example.code.length).toBeLessThan(500); + + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // Output should also be concise + expect(result.code.length).toBeLessThan(1000); + } + }); + }); + + describe('L2_SIMPLE', () => { + const l2Examples = getExamplesByLevel('L2_SIMPLE'); + + it('should have function components with parameters', () => { + for (const example of l2Examples) { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // L2 examples should contain React.createElement and function definitions + expect(result.code).toContain('React.createElement'); + // Should have either a const arrow function or function declaration + expect(result.code).toMatch(/const\s+\w+\s*=|function\s+\w+/); + } + }); + }); + + describe('L3_STYLED', () => { + const l3Examples = getExamplesByLevel('L3_STYLED'); + + it('should have style-related code', () => { + for (const example of l3Examples) { + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // L3 examples should have style or className + const hasStyles = + result.code.includes('style:') || result.code.includes('className:') || result.code.includes('styles.'); + expect(hasStyles).toBe(true); + } + }); + }); + + describe('L4_COMPOSITE', () => { + const l4Examples = getExamplesByLevel('L4_COMPOSITE'); + + it('should have multiple component definitions or complex patterns', () => { + for (const example of l4Examples) { + // L4 examples should have more code + expect(example.code.length).toBeGreaterThan(200); + + const result = babel.transform(example.code, { + presets: ['typescript', 'react'], + filename: `${example.name}.tsx`, + }); + + // Multiple React.createElement calls expected + const createElementCount = (result.code.match(/React\.createElement/g) || []).length; + expect(createElementCount).toBeGreaterThan(1); + } + }); + }); + + describe('L5_COMPLEX', () => { + const l5Examples = getExamplesByLevel('L5_COMPLEX'); + + it('should have TypeScript types to strip', () => { + for (const example of l5Examples) { + // All L5 examples should have forbidden patterns (types) + expect(example.forbiddenPatterns).toBeDefined(); + expect(example.forbiddenPatterns!.length).toBeGreaterThan(0); + } + }); + + it('should be the most complex examples', () => { + const l5Stats = getLevelStats()['L5_COMPLEX']; + const l1Stats = getLevelStats()['L1_MINIMAL']; + + // L5 average size should be significantly larger than L1 + expect(l5Stats.avgSize).toBeGreaterThan(l1Stats.avgSize * 3); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty fragment', () => { + const result = babel.transform('const Empty = () => <>;', { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).toContain('React.Fragment'); + }); + + it('should handle deeply nested JSX', () => { + const deeplyNested = ` + const Deep = () => ( +
+
+
+
+
+ Deep +
+
+
+
+
+ ); + `; + const result = babel.transform(deeplyNested, { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + const createElementCount = (result.code.match(/React\.createElement/g) || []).length; + expect(createElementCount).toBe(6); + }); + + it('should handle mixed expressions and elements', () => { + const mixed = ` + const Mixed = ({ items }: { items: string[] }) => ( +
    + {items.length === 0 &&
  • No items
  • } + {items.length > 0 && items.map((item, i) =>
  • {item}
  • )} + {items.length > 10 &&
  • ...and more
  • } +
+ ); + `; + const result = babel.transform(mixed, { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).not.toContain('interface'); + expect(result.code).not.toContain(': string[]'); + }); + + it('should handle class components with lifecycle methods', () => { + const classComponent = ` + class LifecycleComponent extends React.Component<{ name: string }, { mounted: boolean }> { + state = { mounted: false }; + componentDidMount() { this.setState({ mounted: true }); } + componentWillUnmount() { console.log('unmounting'); } + render() { + return
{this.state.mounted ? 'Mounted' : 'Not mounted'}
; + } + } + `; + const result = babel.transform(classComponent, { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).toContain('componentDidMount'); + expect(result.code).toContain('componentWillUnmount'); + expect(result.code).not.toContain('<{ name: string }'); + }); + + it('should handle JSX spread attributes', () => { + const spread = ` + const Spread = (props: { id: string; className: string }) => { + const extra = { 'data-test': 'value', role: 'button' }; + return
Content
; + }; + `; + const result = babel.transform(spread, { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).not.toContain(': { id: string'); + }); + + it('should handle template literals in className', () => { + const template = ` + const Template = ({ active }: { active: boolean }) => ( +
Content
+ ); + `; + const result = babel.transform(template, { + presets: ['typescript', 'react'], + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).toContain('active'); + expect(result.code).not.toContain(': { active: boolean }'); + }); + }); + + describe('React-only transforms (no TypeScript)', () => { + it('should transform pure JSX without TypeScript preset', () => { + const jsx = 'const Pure = () =>
Pure JSX
;'; + const result = babel.transform(jsx, { + presets: ['react'], + filename: 'Pure.jsx', + }); + expect(result.code).toContain('React.createElement'); + expect(result.code).toContain('"div"'); + }); + + it('should fail on TypeScript syntax with only react preset', () => { + const tsx = 'const Typed = ({ name }: { name: string }) =>
{name}
;'; + expect(() => { + babel.transform(tsx, { + presets: ['react'], + filename: 'Typed.jsx', + }); + }).toThrow(); + }); + }); +}); diff --git a/libs/enclave-vm/src/__tests__/babel-examples.ts b/libs/enclave-vm/src/__tests__/babel-examples.ts new file mode 100644 index 0000000..9a9b705 --- /dev/null +++ b/libs/enclave-vm/src/__tests__/babel-examples.ts @@ -0,0 +1,1299 @@ +/** + * Babel Transform Test Examples + * + * 50 TSX/JSX component examples ranging from minimal to complex. + * Used for testing the Babel preset transform capability. + * + * @packageDocumentation + */ + +/** + * Complexity levels for component examples + */ +export type ComplexityLevel = 'L1_MINIMAL' | 'L2_SIMPLE' | 'L3_STYLED' | 'L4_COMPOSITE' | 'L5_COMPLEX'; + +/** + * Array of all complexity levels for iteration + */ +export const COMPLEXITY_LEVELS: ComplexityLevel[] = [ + 'L1_MINIMAL', + 'L2_SIMPLE', + 'L3_STYLED', + 'L4_COMPOSITE', + 'L5_COMPLEX', +]; + +/** + * Component example for testing Babel transforms + */ +export interface ComponentExample { + /** Unique identifier (1-50) */ + id: number; + /** Component name */ + name: string; + /** Complexity level */ + level: ComplexityLevel; + /** Human-readable description */ + description: string; + /** Source TSX/JSX code */ + code: string; + /** Patterns that MUST appear in transformed output */ + expectedPatterns: string[]; + /** Patterns that must NOT appear in output (e.g., TypeScript types) */ + forbiddenPatterns?: string[]; +} + +/** + * 50 TSX/JSX component examples organized by complexity level + */ +export const BABEL_EXAMPLES: ComponentExample[] = [ + // ========================================================================== + // L1: MINIMAL (1-10) - Single elements, no props, basic JSX + // ========================================================================== + { + id: 1, + name: 'PlainText', + level: 'L1_MINIMAL', + description: 'Plain text element', + code: `const PlainText = () =>
Hello World
;`, + expectedPatterns: ['React.createElement', '"div"', '"Hello World"'], + }, + { + id: 2, + name: 'SelfClosing', + level: 'L1_MINIMAL', + description: 'Self-closing element', + code: `const SelfClosing = () => ;`, + expectedPatterns: ['React.createElement', '"input"', 'type:', '"text"'], + }, + { + id: 3, + name: 'WithExpression', + level: 'L1_MINIMAL', + description: 'Element with expression', + code: `const WithExpression = () => {1 + 1};`, + expectedPatterns: ['React.createElement', '"span"', '1 + 1'], + }, + { + id: 4, + name: 'WithFragment', + level: 'L1_MINIMAL', + description: 'Fragment with children', + code: `const WithFragment = () => <>AB;`, + expectedPatterns: ['React.createElement', 'React.Fragment', '"span"'], + }, + { + id: 5, + name: 'Siblings', + level: 'L1_MINIMAL', + description: 'Multiple sibling elements', + code: `const Siblings = () =>
OneTwo
;`, + expectedPatterns: ['React.createElement', '"div"', '"span"', '"One"', '"Two"'], + }, + { + id: 6, + name: 'NestedElements', + level: 'L1_MINIMAL', + description: 'Deeply nested elements', + code: `const NestedElements = () =>

Deep

;`, + expectedPatterns: ['React.createElement', '"div"', '"section"', '"article"', '"p"', '"Deep"'], + }, + { + id: 7, + name: 'WithClassName', + level: 'L1_MINIMAL', + description: 'Element with className', + code: `const WithClassName = () =>
Content
;`, + expectedPatterns: ['React.createElement', '"div"', 'className:', '"container"'], + }, + { + id: 8, + name: 'WithDataAttribute', + level: 'L1_MINIMAL', + description: 'Element with data attribute', + code: `const WithDataAttribute = () =>
Test
;`, + expectedPatterns: ['React.createElement', '"div"', 'data-testid', '"my-element"'], + }, + { + id: 9, + name: 'WithSpreadProps', + level: 'L1_MINIMAL', + description: 'Element with spread props', + code: `const props = { id: 'main', role: 'main' }; +const WithSpreadProps = () =>
Spread
;`, + expectedPatterns: ['React.createElement', '"div"', 'props'], + }, + { + id: 10, + name: 'ArrowComponent', + level: 'L1_MINIMAL', + description: 'Arrow function component', + code: `const ArrowComponent = () => ;`, + expectedPatterns: ['React.createElement', '"button"', '"Click"'], + }, + + // ========================================================================== + // L2: SIMPLE (11-20) - Props, events, basic patterns + // ========================================================================== + { + id: 11, + name: 'PropsDestructuring', + level: 'L2_SIMPLE', + description: 'Props destructuring', + code: `interface ButtonProps { label: string; onClick: () => void; } +const Button = ({ label, onClick }: ButtonProps) => ( + +);`, + expectedPatterns: ['React.createElement', '"button"', 'onClick', 'label'], + forbiddenPatterns: ['interface', 'ButtonProps', ': string', ': void'], + }, + { + id: 12, + name: 'ChildrenProp', + level: 'L2_SIMPLE', + description: 'Children prop usage', + code: `interface WrapperProps { children: React.ReactNode; } +const Wrapper = ({ children }: WrapperProps) => ( +
{children}
+);`, + expectedPatterns: ['React.createElement', '"div"', 'children', '"wrapper"'], + forbiddenPatterns: ['interface', 'WrapperProps', 'ReactNode'], + }, + { + id: 13, + name: 'EventHandler', + level: 'L2_SIMPLE', + description: 'Event handler', + code: `const EventHandler = () => ( + +);`, + expectedPatterns: ['React.createElement', '"button"', 'onClick', 'console.log', 'e.target'], + }, + { + id: 14, + name: 'ConditionalRendering', + level: 'L2_SIMPLE', + description: 'Conditional rendering', + code: `interface ShowProps { show: boolean; } +const Conditional = ({ show }: ShowProps) => ( +
{show ? Visible : Hidden}
+);`, + expectedPatterns: ['React.createElement', '"div"', '"span"', 'show', '"Visible"', '"Hidden"'], + forbiddenPatterns: ['interface', 'ShowProps', ': boolean'], + }, + { + id: 15, + name: 'ArrayMap', + level: 'L2_SIMPLE', + description: 'Array map rendering', + code: `interface ListProps { items: string[]; } +const List = ({ items }: ListProps) => ( +
    {items.map((item, i) =>
  • {item}
  • )}
+);`, + expectedPatterns: ['React.createElement', '"ul"', '"li"', 'map', 'key:'], + forbiddenPatterns: ['interface', 'ListProps', 'string[]'], + }, + { + id: 16, + name: 'OptionalChaining', + level: 'L2_SIMPLE', + description: 'Optional chaining in JSX', + code: `interface UserProps { user?: { name: string; }; } +const UserName = ({ user }: UserProps) => ( + {user?.name ?? 'Anonymous'} +);`, + expectedPatterns: ['React.createElement', '"span"', 'user', 'Anonymous'], + forbiddenPatterns: ['interface', 'UserProps'], + }, + { + id: 17, + name: 'DefaultProps', + level: 'L2_SIMPLE', + description: 'Default props pattern', + code: `interface GreetingProps { name?: string; } +const Greeting = ({ name = 'World' }: GreetingProps) => ( +

Hello, {name}!

+);`, + expectedPatterns: ['React.createElement', '"h1"', 'World', 'name'], + forbiddenPatterns: ['interface', 'GreetingProps'], + }, + { + id: 18, + name: 'ChildrenArray', + level: 'L2_SIMPLE', + description: 'Rendering children array', + code: `const items = [A, B]; +const ChildrenArray = () =>
{items}
;`, + expectedPatterns: ['React.createElement', '"div"', '"span"', 'items'], + }, + { + id: 19, + name: 'KeyPropList', + level: 'L2_SIMPLE', + description: 'Key prop in list', + code: `interface DataItem { id: string; text: string; } +interface DataListProps { items: DataItem[]; } +const DataList = ({ items }: DataListProps) => ( +
    {items.map(item =>
  • {item.text}
  • )}
+);`, + expectedPatterns: ['React.createElement', '"ul"', '"li"', 'key:', 'item.id', 'item.text'], + forbiddenPatterns: ['interface DataItem', 'interface DataListProps', 'DataItem[]'], + }, + { + id: 20, + name: 'ComponentComposition', + level: 'L2_SIMPLE', + description: 'Component composition', + code: `const Header = () =>

Title

; +const Footer = () =>

Footer

; +const Page = () =>
Content
;`, + expectedPatterns: ['React.createElement', 'Header', 'Footer', '"header"', '"footer"', '"main"'], + }, + + // ========================================================================== + // L3: STYLED (21-30) - Inline styles, dynamic styling, CSS patterns + // ========================================================================== + { + id: 21, + name: 'InlineStyle', + level: 'L3_STYLED', + description: 'Inline style object', + code: `const StyledBox = () => ( +
+ Styled content +
+);`, + expectedPatterns: ['React.createElement', '"div"', 'style:', 'padding:', 'backgroundColor:', 'borderRadius:'], + }, + { + id: 22, + name: 'DynamicStyles', + level: 'L3_STYLED', + description: 'Dynamic styles based on props', + code: `interface BoxProps { size: 'sm' | 'md' | 'lg'; } +const DynamicBox = ({ size }: BoxProps) => { + const sizes = { sm: 8, md: 16, lg: 24 }; + return
Content
; +};`, + expectedPatterns: ['React.createElement', '"div"', 'style:', 'sizes', 'size'], + forbiddenPatterns: ['interface', 'BoxProps', "'sm' | 'md' | 'lg'"], + }, + { + id: 23, + name: 'ConditionalClassName', + level: 'L3_STYLED', + description: 'Conditional className', + code: `interface AlertProps { type: 'success' | 'error' | 'warning'; message: string; } +const Alert = ({ type, message }: AlertProps) => ( +
{message}
+);`, + expectedPatterns: ['React.createElement', '"div"', 'className:', 'alert', 'type'], + forbiddenPatterns: ['interface', 'AlertProps'], + }, + { + id: 24, + name: 'CSSModulesPattern', + level: 'L3_STYLED', + description: 'CSS modules pattern', + code: `const styles = { container: 'container_abc123', title: 'title_def456' }; +const CSSModules = () => ( +
+

Styled Title

+
+);`, + expectedPatterns: ['React.createElement', '"div"', '"h1"', 'styles.container', 'styles.title'], + }, + { + id: 25, + name: 'StyleVariables', + level: 'L3_STYLED', + description: 'CSS variables in style', + code: `interface ThemeProps { primaryColor: string; } +const ThemedButton = ({ primaryColor }: ThemeProps) => ( + +);`, + expectedPatterns: ['React.createElement', '"button"', 'style:', '--primary', 'primaryColor'], + forbiddenPatterns: ['interface', 'ThemeProps', 'CSSProperties'], + }, + { + id: 26, + name: 'ResponsiveStyles', + level: 'L3_STYLED', + description: 'Responsive style helper', + code: `interface CardProps { compact?: boolean; } +const ResponsiveCard = ({ compact = false }: CardProps) => ( +
+ Card Content +
+);`, + expectedPatterns: ['React.createElement', '"div"', 'style:', 'compact', 'padding:', 'maxWidth:'], + forbiddenPatterns: ['interface', 'CardProps'], + }, + { + id: 27, + name: 'ThemeAwareStyles', + level: 'L3_STYLED', + description: 'Theme-aware styles', + code: `interface ColorPalette { colors: { primary: string; secondary: string; }; } +interface ColorBoxProps { palette: ColorPalette; } +const ThemeAwareBox = ({ palette }: ColorBoxProps) => ( +
+ Themed Box +
+);`, + expectedPatterns: ['React.createElement', '"div"', 'palette.colors.primary', 'palette.colors.secondary'], + forbiddenPatterns: ['interface ColorPalette', 'interface ColorBoxProps'], + }, + { + id: 28, + name: 'AnimationStyles', + level: 'L3_STYLED', + description: 'Animation styles', + code: `interface AnimatedProps { isVisible: boolean; } +const AnimatedBox = ({ isVisible }: AnimatedProps) => ( +
+ Animated Content +
+);`, + expectedPatterns: ['React.createElement', '"div"', 'opacity:', 'transform:', 'transition:', 'isVisible'], + forbiddenPatterns: ['interface', 'AnimatedProps'], + }, + { + id: 29, + name: 'PseudoClassPatterns', + level: 'L3_STYLED', + description: 'Hover state pattern (inline)', + code: `const HoverButton = () => { + const [isHovered, setIsHovered] = [false, (v: boolean) => {}]; + return ( + + ); +};`, + expectedPatterns: ['React.createElement', '"button"', 'onMouseEnter', 'onMouseLeave', 'isHovered'], + }, + { + id: 30, + name: 'MediaQueryStyles', + level: 'L3_STYLED', + description: 'Media query responsive pattern', + code: `interface ResponsiveProps { isMobile: boolean; } +const ResponsiveLayout = ({ isMobile }: ResponsiveProps) => ( +
+ +
Main Content
+
+);`, + expectedPatterns: ['React.createElement', '"div"', '"aside"', '"main"', 'flexDirection', 'isMobile'], + forbiddenPatterns: ['interface', 'ResponsiveProps'], + }, + + // ========================================================================== + // L4: COMPOSITE (31-40) - Multi-component patterns, render props, HOCs + // ========================================================================== + { + id: 31, + name: 'ParentChildProps', + level: 'L4_COMPOSITE', + description: 'Parent-child prop passing', + code: `interface CardProps { title: string; children: React.ReactNode; } +const Card = ({ title, children }: CardProps) => ( +
+

{title}

+
{children}
+
+); + +const CardExample = () => ( + +

Card content goes here

+
+);`, + expectedPatterns: ['React.createElement', '"div"', '"h2"', 'title', 'children', 'Card'], + forbiddenPatterns: ['interface', 'CardProps', 'ReactNode'], + }, + { + id: 32, + name: 'RenderProps', + level: 'L4_COMPOSITE', + description: 'Render props pattern', + code: `interface MouseTrackerProps { render: (x: number, y: number) => React.ReactNode; } +const MouseTracker = ({ render }: MouseTrackerProps) => { + const position = { x: 0, y: 0 }; + return
{}}>{render(position.x, position.y)}
; +}; + +const TrackerExample = () => ( + Mouse: {x}, {y}} /> +);`, + expectedPatterns: ['React.createElement', 'render', 'position', 'MouseTracker'], + forbiddenPatterns: ['interface', 'MouseTrackerProps', 'ReactNode'], + }, + { + id: 33, + name: 'CompoundComponents', + level: 'L4_COMPOSITE', + description: 'Compound components pattern', + code: `const TabsContext = { activeTab: 0 }; + +interface TabProps { index: number; children: React.ReactNode; } +const Tab = ({ index, children }: TabProps) => ( +
{children}
+); + +interface TabsProps { children: React.ReactNode; } +const Tabs = ({ children }: TabsProps) => ( +
{children}
+); + +const TabsExample = () => ( + + Tab 1 Content + Tab 2 Content + +);`, + expectedPatterns: ['React.createElement', 'Tab', 'Tabs', 'TabsContext', 'activeTab', 'index'], + forbiddenPatterns: ['interface', 'TabProps', 'TabsProps'], + }, + { + id: 34, + name: 'SlotPattern', + level: 'L4_COMPOSITE', + description: 'Slot pattern', + code: `interface LayoutProps { + header?: React.ReactNode; + sidebar?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; +} +const Layout = ({ header, sidebar, children, footer }: LayoutProps) => ( +
+ {header &&
{header}
} +
+ {sidebar && } +
{children}
+
+ {footer &&
{footer}
} +
+);`, + expectedPatterns: ['React.createElement', 'header', 'sidebar', 'children', 'footer', '"layout"'], + forbiddenPatterns: ['interface', 'LayoutProps', 'ReactNode'], + }, + { + id: 35, + name: 'HOCPattern', + level: 'L4_COMPOSITE', + description: 'Higher-order component pattern', + code: `interface WithLoadingProps { isLoading: boolean; } +function withLoading

(Component: React.ComponentType

) { + return ({ isLoading, ...props }: P & WithLoadingProps) => { + if (isLoading) return

Loading...
; + return ; + }; +} + +interface DataProps { data: string; } +const DataDisplay = ({ data }: DataProps) =>
{data}
; +const DataDisplayWithLoading = withLoading(DataDisplay);`, + expectedPatterns: ['React.createElement', 'isLoading', 'Loading', 'Component', 'withLoading'], + forbiddenPatterns: ['interface', 'WithLoadingProps', 'DataProps', 'ComponentType'], + }, + { + id: 36, + name: 'ContextConsumer', + level: 'L4_COMPOSITE', + description: 'Context consumer pattern', + code: `interface ThemeContextType { theme: 'light' | 'dark'; toggleTheme: () => void; } +const ThemeContext = { theme: 'light' as const, toggleTheme: () => {} }; + +const ThemedComponent = () => { + const { theme, toggleTheme } = ThemeContext; + return ( +
+ +

Current theme: {theme}

+
+ ); +};`, + expectedPatterns: ['React.createElement', 'theme', 'toggleTheme', 'ThemeContext'], + forbiddenPatterns: ['interface', 'ThemeContextType'], + }, + { + id: 37, + name: 'ForwardRefPattern', + level: 'L4_COMPOSITE', + description: 'Forward ref pattern', + code: `interface InputProps { + label: string; + placeholder?: string; +} + +const ForwardedInput = React.forwardRef( + ({ label, placeholder }, ref) => ( +
+ + +
+ ) +); + +const InputExample = () => { + const inputRef = { current: null }; + return ; +};`, + expectedPatterns: ['React.createElement', 'forwardRef', 'ref', 'label', 'placeholder'], + forbiddenPatterns: ['interface', 'InputProps', 'HTMLInputElement'], + }, + { + id: 38, + name: 'ControlledComponent', + level: 'L4_COMPOSITE', + description: 'Controlled component pattern', + code: `interface ControlledInputProps { + value: string; + onChange: (value: string) => void; + label: string; +} + +const ControlledInput = ({ value, onChange, label }: ControlledInputProps) => ( +
+ + onChange(e.target.value)} + /> + {value.length} characters +
+);`, + expectedPatterns: ['React.createElement', 'value', 'onChange', 'e.target.value', 'value.length'], + forbiddenPatterns: ['interface', 'ControlledInputProps'], + }, + { + id: 39, + name: 'FormWithFields', + level: 'L4_COMPOSITE', + description: 'Form with multiple fields', + code: `interface ContactFormProps { onSubmit: (data: FormData) => void; } +const ContactForm = ({ onSubmit }: ContactFormProps) => ( +
{ e.preventDefault(); onSubmit(new FormData(e.target as HTMLFormElement)); }}> +
+ + +
+
+ + +
+
+ +