diff --git a/.github/workflows/auto-opencode.yml b/.github/workflows/auto-opencode.yml new file mode 100644 index 00000000..1f4ca012 --- /dev/null +++ b/.github/workflows/auto-opencode.yml @@ -0,0 +1,29 @@ +name: opencode + +on: + issue_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run opencode + uses: sst/opencode/github@latest + env: + ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} + with: + model: zai-coding-plan/glm-4.6 \ No newline at end of file diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 1f4ca012..dd8e46ea 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,29 +1,229 @@ -name: opencode +name: OpenChamber for Actions on: - issue_comment: - types: [created] + workflow_dispatch: + inputs: + tunnel_provider: + description: 'Select Tunnel Provider' + required: true + default: 'cloudflare' + type: choice + options: + - ngrok + - cloudflare + timeout_minutes: + description: 'Auto-shutdown after (minutes)' + required: true + default: '300' + type: string jobs: - opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + serve: runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - pull-requests: write - issues: write + steps: - - name: Checkout repository + - name: Checkout repo uses: actions/checkout@v4 - - name: Run opencode - uses: sst/opencode/github@latest - env: - ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} + # ============================================================ + # PERSISTENCE: Restore previous session (OAuth, chats, config) + # ============================================================ + - name: Restore Session Data (Artifact) + if: ${{ secrets.OPENCODE_SERVER_PASSWORD != '' }} + uses: dawidd6/action-download-artifact@v6 + continue-on-error: true with: - model: zai-coding-plan/glm-4.6 \ No newline at end of file + name: opencode-session + path: /tmp/opencode-restore + workflow: opencode.yml + workflow_conclusion: success + if_no_artifact_found: ignore + repo: ${{ github.repository }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Apply Restored Session Data + if: ${{ secrets.OPENCODE_SERVER_PASSWORD != '' }} + env: + OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }} + run: | + chmod +x scripts/persistence-restore.sh + RESTORE_DIR=/tmp/opencode-restore ./scripts/persistence-restore.sh + + # ============================================================ + # CACHE: Speed up npm installations + # ============================================================ + - name: Cache npm modules + uses: actions/cache@v4 + with: + path: | + ~/.npm + key: npm-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + npm-cache-${{ runner.os }}- + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Tools + run: | + npm install -g opencode-ai @openchamber/web + sudo apt update && sudo apt install jq lsof coreutils openssl -y + + # Install ttyd for OpenCode TTY exposure + wget -q -O ttyd https://github.com/tsl0922/ttyd/releases/download/1.7.3/ttyd.x86_64 + chmod +x ttyd + sudo mv ttyd /usr/local/bin/ + + # ============================================================ + # CONFIGURATION: Setup OpenCode config (respects artifacts) + # ============================================================ + - name: Configure OpenCode + run: | + chmod +x scripts/opencode-config.sh + RESTORE_DIR=/tmp/opencode-restore ./scripts/opencode-config.sh + + # ============================================================ + # STARTUP: Launch services and tunnel + # ============================================================ + - name: Start Services + env: + OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }} + run: | + # Setup Password Protection + TTYD_ARGS="" + if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then + echo "Password protection enabled." + # Export for subsequent steps (Monitor script) and other services + echo "OPENCHAMBER_UI_PASSWORD=$OPENCODE_SERVER_PASSWORD" >> $GITHUB_ENV + echo "OPENCODE_UI_PASSWORD=$OPENCODE_SERVER_PASSWORD" >> $GITHUB_ENV + + # Use for current step + export OPENCHAMBER_UI_PASSWORD="$OPENCODE_SERVER_PASSWORD" + export OPENCODE_UI_PASSWORD="$OPENCODE_SERVER_PASSWORD" + + TTYD_ARGS="-c user:$OPENCODE_SERVER_PASSWORD" + else + echo "Password protection disabled (OPENCODE_SERVER_PASSWORD not set)." + fi + + # Start OpenCode TTY (via ttyd) + echo "Starting OpenCode TTY..." + nohup stdbuf -oL ttyd $TTYD_ARGS -p 7681 opencode > opencode_tty.log 2>&1 & + + # Start OpenChamber + echo "Starting OpenChamber..." + if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then + nohup stdbuf -oL openchamber --port 9090 --ui-password "$OPENCODE_SERVER_PASSWORD" > openchamber.log 2>&1 & + else + nohup stdbuf -oL openchamber --port 9090 > openchamber.log 2>&1 & + fi + + # Start OpenCode Web + echo "Starting OpenCode Web..." + if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then + OPENCODE_SERVER_PASSWORD="$OPENCODE_SERVER_PASSWORD" nohup stdbuf -oL opencode web --port 8080 > opencode_web.log 2>&1 & + else + nohup stdbuf -oL opencode web --port 8080 > opencode_web.log 2>&1 & + fi + + # Wait for services to initialize + sleep 20 + + echo "Services started. Checking status..." + lsof -i :7681 || echo "Warning: OpenCode TTY may not be running" + lsof -i :9090 || echo "Warning: OpenChamber may not be running" + lsof -i :8080 || echo "Warning: OpenCode Web may not be running" + + - name: Setup Tunnel + run: | + if [[ "${{ github.event.inputs.tunnel_provider }}" == "ngrok" ]]; then + echo "Setting up ngrok tunnel..." + curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null + echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list + sudo apt update && sudo apt install ngrok -y + ngrok config add-authtoken "${{ secrets.NGROK_AUTH_TOKEN }}" + + # Note: Multiple ngrok tunnels on free plan might be limited or require config file + # Attempting to start tunnels blindly + nohup ngrok http 127.0.0.1:9090 --log=stdout > tunnel_chamber.log 2>&1 & + nohup ngrok http 127.0.0.1:8080 --log=stdout > tunnel_web.log 2>&1 & + nohup ngrok http 127.0.0.1:7681 --log=stdout > tunnel_tty.log 2>&1 & + sleep 10 + + # Extract URLs + URL_CHAMBER=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("9090")) | .public_url' || true) + URL_WEB=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("8080")) | .public_url' || true) + URL_TTY=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("7681")) | .public_url' || true) + + echo "URL_CHAMBER=$URL_CHAMBER" >> $GITHUB_ENV + echo "URL_WEB=$URL_WEB" >> $GITHUB_ENV + echo "URL_TTY=$URL_TTY" >> $GITHUB_ENV + + else + echo "Setting up Cloudflare tunnel..." + wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb + sudo dpkg -i cloudflared-linux-amd64.deb + + # Start 3 separate tunnels + nohup cloudflared tunnel --url http://127.0.0.1:7681 > tunnel_tty.log 2>&1 & + nohup cloudflared tunnel --url http://127.0.0.1:9090 > tunnel_chamber.log 2>&1 & + nohup cloudflared tunnel --url http://127.0.0.1:8080 > tunnel_web.log 2>&1 & + + echo "Waiting for Cloudflare Tunnel URLs..." + + # Loop to wait for URLs + for i in {1..30}; do + URL_TTY=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_tty.log 2>/dev/null | tail -n 1 || true) + URL_CHAMBER=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_chamber.log 2>/dev/null | tail -n 1 || true) + URL_WEB=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_web.log 2>/dev/null | tail -n 1 || true) + + if [[ -n "$URL_TTY" && -n "$URL_CHAMBER" && -n "$URL_WEB" ]]; then + echo "All tunnels established!" + break + fi + echo "Waiting for tunnels... ($i/30)" + sleep 2 + done + + if [[ -z "$URL_TTY" ]]; then echo "Warning: TTY Tunnel URL missing"; fi + if [[ -z "$URL_CHAMBER" ]]; then echo "Warning: Chamber Tunnel URL missing"; fi + if [[ -z "$URL_WEB" ]]; then echo "Warning: Web Tunnel URL missing"; fi + + echo "URL_TTY=$URL_TTY" >> $GITHUB_ENV + echo "URL_CHAMBER=$URL_CHAMBER" >> $GITHUB_ENV + echo "URL_WEB=$URL_WEB" >> $GITHUB_ENV + fi + + # ============================================================ + # MONITOR: Self-healing loop with status updates + # ============================================================ + - name: Monitor & Self-Heal + env: + OPENCHAMBER_UI_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }} + run: | + chmod +x scripts/monitor.sh + # Pass all 3 URLs to monitor script + ./scripts/monitor.sh "${{ github.event.inputs.tunnel_provider }}" "${{ github.event.inputs.timeout_minutes }}" "${{ env.URL_TTY }}" "${{ env.URL_CHAMBER }}" "${{ env.URL_WEB }}" + + # ============================================================ + # PERSISTENCE: Save all session data (always runs) + # ============================================================ + - name: Prepare Session Data for Save + if: ${{ always() && secrets.OPENCODE_SERVER_PASSWORD != '' }} + env: + OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }} + run: | + chmod +x scripts/persistence-save.sh + SAVE_DIR=/tmp/opencode-save ./scripts/persistence-save.sh + + - name: Upload Session Data (Artifact) + if: ${{ always() && secrets.OPENCODE_SERVER_PASSWORD != '' }} + uses: actions/upload-artifact@v4 + with: + name: opencode-session + path: /tmp/opencode-save/ + retention-days: 90 + overwrite: true + if-no-files-found: warn diff --git a/README.md b/README.md index a6f45fc7..8d698c06 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ The whole project was built entirely with AI coding agents under my supervision. - Editor-integrated file picker and click-to-open from tool output - In-extension Settings access and theme mapping +### GitHub Actions (Cloud Usage) + +Run OpenChamber remotely using GitHub Actions. No local computer required. + +* **Zero Setup:** Runs on GitHub's infrastructure. +* **Persistence:** Optional; enabled when `OPENCODE_SERVER_PASSWORD` is set (encrypted). +* **Remote Access:** Access via secure tunnel (Cloudflare/Ngrok). + +[**Read the Guide: OpenChamber for Actions**](docs/OPENCHAMBER_FOR_ACTIONS.md) + ## Installation ### VS Code Extension @@ -118,7 +128,20 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. ## Tech Stack -React 19, TypeScript, Vite 7, Tailwind CSS v4, Zustand, Radix UI, @opencode-ai/sdk, Express, Tauri (desktop) +### Frontend +![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-7-646CFF?style=flat&logo=vite&logoColor=white) +![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-v4-06B6D4?style=flat&logo=tailwindcss&logoColor=white) + +### State & UI +![Zustand](https://img.shields.io/badge/Zustand-State_Management-FF6B6B?style=flat) +![Radix UI](https://img.shields.io/badge/Radix_UI-Components-8B5CF6?style=flat&logo=radixui&logoColor=white) + +### Backend & Desktop +![Express](https://img.shields.io/badge/Express.js-Server-000000?style=flat&logo=express&logoColor=white) +![Tauri](https://img.shields.io/badge/Tauri-Desktop-FFC131?style=flat&logo=tauri&logoColor=white) +![OpenCode SDK](https://img.shields.io/badge/OpenCode-SDK-4F46E5?style=flat) ## Acknowledgments diff --git a/docs/OPENCHAMBER_FOR_ACTIONS.md b/docs/OPENCHAMBER_FOR_ACTIONS.md new file mode 100644 index 00000000..6962f249 --- /dev/null +++ b/docs/OPENCHAMBER_FOR_ACTIONS.md @@ -0,0 +1,166 @@ +# OpenChamber for Actions + +**Version:** `v0.1.0-preview` + +OpenChamber for Actions allows you to run a full OpenChamber environment remotely using GitHub Actions infrastructure. It provides access to OpenCode, OpenChamber, and a web-based terminal without requiring any local hardware. + +--- + +## Overview + +OpenChamber for Actions is a "computer in the cloud" solution that leverages GitHub Runners to host a temporary development environment. It spins up three key services: +* **OpenCode TTY:** A web-based terminal for command-line access. +* **OpenChamber:** The core environment. +* **OpenCode Web:** The web interface for coding and interaction. + +These services are exposed via secure tunnels (Cloudflare or Ngrok), allowing you to access them from any browser. The session can persist data between runs using GitHub Artifacts. + +--- + +## Key Features + +* **No local hardware required:** Run OpenChamber on GitHub Actions infrastructure. +* **OpenCode integration:** Access the OpenCode suite and "Antigravity" model selection (including Gemini 3 and Claude 4.5). +* **Secure access (optional):** Protect OpenChamber, OpenCode Web, and the TTY with `OPENCODE_SERVER_PASSWORD`. +* **Persistent sessions (optional):** Save login state, OAuth tokens, and configuration as GitHub Artifacts when a password is set (encrypted). +* **Self-healing tunnels:** Background monitoring keeps the tunnel stable. + +--- + +## New to this? (Beginners' Guide) + +
+Click to expand: What is this and how does it help me? + +### 1. What exactly is this? +Think of this as a "computer in the cloud." Instead of running a local AI environment, GitHub Actions provides the compute for you. + +### 2. What do I need to start? +* A GitHub account. +* A web browser (Chrome, Edge, Safari, etc.). +* No special hardware required. + +### 3. Why is Cloudflare recommended? +Cloudflare Quick Tunnels create a secure public URL automatically. You do not need extra accounts or API keys, so it is the simplest option. + +### 4. What is persistence? +When a password is set, the workflow saves your login info and settings as an encrypted GitHub Artifact. The next run restores the session so you can continue without logging in again. +
+ +--- + +
+Usage Limits & Quotas + +To ensure stability and compliance with GitHub’s Terms of Service, be aware of the following limits: + +| Limit Type | Constraint | Explanation | +| :--- | :--- | :--- | +| **Time Limit** | **6 Hours Max** | GitHub stops any job after 360 minutes. | +| **Storage** | **2GB** | Session artifacts cannot exceed 2GB per repository. | +| **Concurrency** | **1 Run Only** | Run a single OpenChamber instance at a time. | +| **Hardware** | **2-Core CPU** | Standard Linux runners (~7GB RAM). | + +
+ +--- + +## Installation Guide + +### Option A: Easy Mode (Recommended) +Uses Cloudflare tunnels. No configuration required unless you want password protection. + +1. Fork this repository to your GitHub account. +2. Open the Actions tab in your fork. +3. Select the **OpenChamber for Actions** workflow. +4. (Optional) **Set a Password (recommended):** + * Go to **Settings** -> **Secrets and variables** -> **Actions**. + * Click **New repository secret**. + * **Name:** `OPENCODE_SERVER_PASSWORD` + * **Secret:** Your desired password. + * *Note: If this secret is set, it protects OpenCode TTY, OpenChamber, and OpenCode Web. It also enables encrypted persistence.* +5. Click **Run workflow**. + * **Tunnel Provider:** Choose `cloudflare` (default) or `ngrok`. + * **Auto-shutdown after (minutes):** Set the duration (default `300`). +6. Wait about 30 seconds for setup to finish. +7. Open the run, then open the `serve` job. +8. Expand the **Monitor & Self-Heal** step to find the URLs and open them. + +> [!TIP] +> Keep the repository visibility `Private` or use `OPENCODE_SERVER_PASSWORD` in Github Actions Secrets. Otherwise, your privacy can be violated. + +--- + +### Option B: Pro Mode (Ngrok) + +
+Click to expand: Step-by-step guide for setting up Ngrok + +If you prefer using Ngrok for a static domain, follow these steps to set up your API key. + +#### Step 1: Get your authtoken +1. Log in or sign up at dashboard.ngrok.com. +2. In the sidebar, click "Your Authtoken." +3. Copy the token string (for example, `21xYz...`). + +#### Step 2: Add the secret to GitHub +1. Open your forked repository on GitHub. +2. Open Settings. +3. In the sidebar, open Secrets and variables and click Actions. +4. Click New repository secret. +5. **Name:** `NGROK_AUTH_TOKEN`. +6. **Secret:** Paste the token. +7. (Optional) Add `OPENCODE_SERVER_PASSWORD` to enable password protection for all services and encrypted persistence. +8. Click Add secret. + +#### Step 3: Run with Ngrok +1. Go to the Actions tab. +2. Select **OpenChamber for Actions**. +3. Click **Run workflow**. +4. Select `ngrok` as the Tunnel Provider. +5. Find the URL in the "Monitor & Self-Heal" step. +
+ +--- + +## Frequently Asked Questions (FAQ) + +
+Q: Is this completely free? +GitHub provides 2,000 minutes for private repositories and unlimited minutes for public repositories. This workflow uses those minutes. +
+ +
+Q: Why did my session stop working after a few hours? +GitHub Actions has a 6-hour job limit. The environment shuts down automatically. If persistence is enabled, your session data is restored on the next run. +
+ +
+Q: Can I use this on my phone or tablet? +Yes. Once you have a public URL (Cloudflare or Ngrok), open it in any mobile browser. +
+ +
+Q: Is my data private? +If you fork this as a public repository, your code may be visible depending on how you save it. For maximum privacy, use a private fork. If you want persistence, set `OPENCODE_SERVER_PASSWORD` so the artifact is encrypted. +
+ +
+Q: I see a "Connection Refused" error. What do I do? +Wait about 30 seconds and refresh the page. If it persists, check the Monitor logs in the Actions run. +
+ +--- + +## Technical Specifications + +| Feature | Detail | +| :--- | :--- | +| **Version** | `v0.1.0-preview` | +| **OS** | Ubuntu 22.04 LTS (GitHub Runner) | +| **Node Version** | v20.x | +| **Tunnel Protocols** | `cloudflared` (Argon) / `ngrok` (HTTP) | +| **Artifact Retention** | 90 Days (Default GitHub Policy) | + +> [!NOTE] +> This project is for educational and development purposes. Do not use this workflow for mining crypto or other activities banned by the GitHub Acceptable Use Policy. diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 5fd5d7d1..a0b832a0 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -352,6 +352,8 @@ export interface FilesAPI { readFile?(path: string): Promise<{ content: string; path: string }>; readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>; writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>; + delete?(path: string): Promise<{ success: boolean }>; + rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>; execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index de9f3817..381cf248 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -64,6 +64,33 @@ const normalizeDirectoryPath = (value) => { return trimmed; }; +const resolveWorkspacePath = (targetPath, baseDirectory) => { + const normalized = normalizeDirectoryPath(targetPath); + if (!normalized || typeof normalized !== 'string') { + return { ok: false, error: 'Path is required' }; + } + + const resolved = path.resolve(normalized); + const resolvedBase = path.resolve(baseDirectory || os.homedir()); + const relative = path.relative(resolvedBase, resolved); + + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return { ok: false, error: 'Path is outside of active workspace' }; + } + + return { ok: true, base: resolvedBase, resolved }; +}; + +const resolveWorkspacePathFromContext = async (req, targetPath) => { + const resolvedProject = await resolveProjectDirectory(req); + if (!resolvedProject.directory) { + return { ok: false, error: resolvedProject.error || 'Active workspace is required' }; + } + + return resolveWorkspacePath(targetPath, resolvedProject.directory); +}; + + const normalizeRelativeSearchPath = (rootPath, targetPath) => { const relative = path.relative(rootPath, targetPath) || path.basename(targetPath); return relative.split(path.sep).join('/') || targetPath; @@ -4643,16 +4670,14 @@ async function main(options = {}) { return res.status(400).json({ error: 'Path is required' }); } - const expandedPath = normalizeDirectoryPath(dirPath); - const normalizedPath = path.normalize(expandedPath); - if (normalizedPath.includes('..')) { - return res.status(400).json({ error: 'Invalid path: path traversal not allowed' }); + const resolved = await resolveWorkspacePathFromContext(req, dirPath); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); } - const resolvedPath = path.resolve(expandedPath); - await fsPromises.mkdir(resolvedPath, { recursive: true }); + await fsPromises.mkdir(resolved.resolved, { recursive: true }); - res.json({ success: true, path: resolvedPath }); + res.json({ success: true, path: resolved.resolved }); } catch (error) { console.error('Failed to create directory:', error); res.status(500).json({ error: error.message || 'Failed to create directory' }); @@ -4751,15 +4776,15 @@ async function main(options = {}) { } try { - const resolvedPath = path.resolve(normalizeDirectoryPath(filePath)); - if (resolvedPath.includes('..')) { - return res.status(400).json({ error: 'Invalid path: path traversal not allowed' }); + const resolved = await resolveWorkspacePathFromContext(req, filePath); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); } // Ensure parent directory exists - await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true }); - await fsPromises.writeFile(resolvedPath, content, 'utf8'); - res.json({ success: true, path: resolvedPath }); + await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); + await fsPromises.writeFile(resolved.resolved, content, 'utf8'); + res.json({ success: true, path: resolved.resolved }); } catch (error) { const err = error; if (err && typeof err === 'object' && err.code === 'EACCES') { @@ -4770,6 +4795,75 @@ async function main(options = {}) { } }); + // Delete file or directory + app.post('/api/fs/delete', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext(req, targetPath); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + await fsPromises.rm(resolved.resolved, { recursive: true, force: true }); + + res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File or directory not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to delete path:', error); + res.status(500).json({ error: (error && error.message) || 'Failed to delete path' }); + } + }); + + // Rename/Move file or directory + app.post('/api/fs/rename', async (req, res) => { + const { oldPath, newPath } = req.body || {}; + if (!oldPath || typeof oldPath !== 'string') { + return res.status(400).json({ error: 'oldPath is required' }); + } + if (!newPath || typeof newPath !== 'string') { + return res.status(400).json({ error: 'newPath is required' }); + } + + try { + const resolvedOld = await resolveWorkspacePathFromContext(req, oldPath); + if (!resolvedOld.ok) { + return res.status(400).json({ error: resolvedOld.error }); + } + const resolvedNew = await resolveWorkspacePathFromContext(req, newPath); + if (!resolvedNew.ok) { + return res.status(400).json({ error: resolvedNew.error }); + } + + if (resolvedOld.base !== resolvedNew.base) { + return res.status(400).json({ error: 'Source and destination must share the same workspace root' }); + } + + await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved); + + res.json({ success: true, path: resolvedNew.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Source path not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to rename path:', error); + res.status(500).json({ error: (error && error.message) || 'Failed to rename path' }); + } + }); + // Execute shell commands in a directory (for worktree setup) // NOTE: This route supports background execution to avoid tying up browser connections. const execJobs = new Map(); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 22a39279..9de32771 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -161,4 +161,40 @@ export const createWebFilesAPI = (): FilesAPI => ({ path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : target, }; }, + + async delete(path: string): Promise<{ success: boolean }> { + const target = normalizePath(path); + const response = await fetch('/api/fs/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: target }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Failed to delete file'); + } + + const result = await response.json().catch(() => ({})); + return { success: Boolean((result as { success?: boolean }).success) }; + }, + + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> { + const response = await fetch('/api/fs/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPath, newPath }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Failed to rename file'); + } + + const result = await response.json().catch(() => ({})); + return { + success: Boolean((result as { success?: boolean }).success), + path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : newPath, + }; + }, }); diff --git a/scripts/monitor.sh b/scripts/monitor.sh new file mode 100755 index 00000000..757d9161 --- /dev/null +++ b/scripts/monitor.sh @@ -0,0 +1,292 @@ +#!/bin/bash +# ============================================================================== +# OpenCode Monitor & Self-Heal Script +# ============================================================================== +# This script monitors OpenCode (TTY), OpenChamber, and OpenCode Web services, +# automatically restarting them if they crash. It also manages the tunnel connections. +# +# Usage: ./monitor.sh +# +# Arguments: +# tunnel_provider: "ngrok" or "cloudflare" +# timeout_minutes: Auto-shutdown timeout in minutes +# url_tty: Initial tunnel URL for OpenCode TTY +# url_chamber: Initial tunnel URL for OpenChamber +# url_web: Initial tunnel URL for OpenCode Web +# ============================================================================== + +set -uo pipefail + +TUNNEL_PROVIDER="${1:-cloudflare}" +TIMEOUT_MINUTES="${2:-300}" +URL_TTY="${3:-}" +URL_CHAMBER="${4:-}" +URL_WEB="${5:-}" + +echo "==============================================" +echo "OpenCode Monitor & Self-Heal" +echo "==============================================" +echo "Tunnel Provider: $TUNNEL_PROVIDER" +echo "Timeout: $TIMEOUT_MINUTES minute(s)" +echo "----------------------------------------------" +echo "OpenCode TTY URL: $URL_TTY" +echo "OpenChamber URL: $URL_CHAMBER" +echo "OpenCode Web URL: $URL_WEB" +echo "==============================================" +echo "" + +# ------------------------------------------------------------------------------ +# Configuration +# ------------------------------------------------------------------------------ +TTY_PORT=7681 +OPENCHAMBER_PORT=9090 +OPENCODE_WEB_PORT=8080 + +START_TIME=$(date +%s) +TIMEOUT_SECONDS=$((TIMEOUT_MINUTES * 60)) + +# ------------------------------------------------------------------------------ +# Helper Functions +# ------------------------------------------------------------------------------ + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +check_port() { + local port=$1 + lsof -i :"$port" > /dev/null 2>&1 +} + +get_remaining_time() { + local elapsed=$(($(date +%s) - START_TIME)) + local remaining=$((TIMEOUT_SECONDS - elapsed)) + echo $remaining +} + +format_time() { + local seconds=$1 + local minutes=$((seconds / 60)) + local hours=$((minutes / 60)) + minutes=$((minutes % 60)) + + if [ $hours -gt 0 ]; then + echo "${hours}h ${minutes}m" + else + echo "${minutes}m" + fi +} + +# ------------------------------------------------------------------------------ +# Service Management +# ------------------------------------------------------------------------------ + +restart_opencode_tty() { + log "Restarting OpenCode TTY on port $TTY_PORT..." + pkill -f "ttyd" 2>/dev/null || true + sleep 2 + + if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then + log "Restarting TTY with password protection." + nohup stdbuf -oL ttyd -c "user:${OPENCHAMBER_UI_PASSWORD}" -p $TTY_PORT bash -c "cd \$HOME && exec opencode" >> opencode_tty.log 2>&1 & + else + nohup stdbuf -oL ttyd -p $TTY_PORT bash -c "cd \$HOME && exec opencode" >> opencode_tty.log 2>&1 & + fi + sleep 5 + + if check_port $TTY_PORT; then + log "OpenCode TTY restarted successfully" + return 0 + else + log "ERROR: Failed to restart OpenCode TTY" + return 1 + fi +} + +restart_openchamber() { + log "Restarting OpenChamber on port $OPENCHAMBER_PORT..." + pkill -f "openchamber" 2>/dev/null || true + sleep 2 + if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then + nohup stdbuf -oL openchamber --port $OPENCHAMBER_PORT --ui-password "$OPENCHAMBER_UI_PASSWORD" >> openchamber.log 2>&1 & + else + nohup stdbuf -oL openchamber --port $OPENCHAMBER_PORT >> openchamber.log 2>&1 & + fi + sleep 5 + + if check_port $OPENCHAMBER_PORT; then + log "OpenChamber restarted successfully" + return 0 + else + log "ERROR: Failed to restart OpenChamber" + return 1 + fi +} + +restart_opencode_web() { + log "Restarting OpenCode Web on port $OPENCODE_WEB_PORT..." + # Note: "opencode web" might be matched by "opencode" so we use full command in pkill if possible or handle order + pkill -f "opencode web" 2>/dev/null || true + sleep 2 + if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then + OPENCODE_SERVER_PASSWORD="$OPENCHAMBER_UI_PASSWORD" nohup stdbuf -oL opencode web --port $OPENCODE_WEB_PORT >> opencode_web.log 2>&1 & + else + nohup stdbuf -oL opencode web --port $OPENCODE_WEB_PORT >> opencode_web.log 2>&1 & + fi + sleep 5 + + if check_port $OPENCODE_WEB_PORT; then + log "OpenCode Web restarted successfully" + return 0 + else + log "ERROR: Failed to restart OpenCode Web" + return 1 + fi +} + +restart_tunnels() { + log "Restarting tunnels ($TUNNEL_PROVIDER)..." + + if [ "$TUNNEL_PROVIDER" = "ngrok" ]; then + pkill -f "ngrok" 2>/dev/null || true + sleep 2 + # Complex to manage multiple ngrok tunnels without config file. + # For now, attempting to restore single tunnel logic or just warn. + log "WARNING: Multi-tunnel restart for ngrok not fully supported in this script version." + # Attempt to start tunnels again (blindly) + nohup ngrok http 127.0.0.1:$OPENCHAMBER_PORT --log=stdout > tunnel_chamber.log 2>&1 & + nohup ngrok http 127.0.0.1:$OPENCODE_WEB_PORT --log=stdout > tunnel_web.log 2>&1 & + nohup ngrok http 127.0.0.1:$TTY_PORT --log=stdout > tunnel_tty.log 2>&1 & + sleep 10 + else + pkill -f "cloudflared" 2>/dev/null || true + sleep 2 + nohup cloudflared tunnel --url http://127.0.0.1:$TTY_PORT > tunnel_tty.log 2>&1 & + nohup cloudflared tunnel --url http://127.0.0.1:$OPENCHAMBER_PORT > tunnel_chamber.log 2>&1 & + nohup cloudflared tunnel --url http://127.0.0.1:$OPENCODE_WEB_PORT > tunnel_web.log 2>&1 & + sleep 15 + + # Get new URLs + URL_TTY=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_tty.log 2>/dev/null | tail -n 1) + URL_CHAMBER=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_chamber.log 2>/dev/null | tail -n 1) + URL_WEB=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_web.log 2>/dev/null | tail -n 1) + fi + + log "Tunnels restarted." + echo "" + echo "==============================================" + echo "NEW ACCESS URLS:" + echo "OpenCode TTY: $URL_TTY" + echo "OpenChamber: $URL_CHAMBER" + echo "OpenCode Web: $URL_WEB" + echo "==============================================" + echo "" +} + +check_tunnels() { + if [ "$TUNNEL_PROVIDER" = "ngrok" ]; then + pgrep -f "ngrok" > /dev/null 2>&1 + else + pgrep -f "cloudflared" > /dev/null 2>&1 + fi +} + +# ------------------------------------------------------------------------------ +# Graceful Shutdown +# ------------------------------------------------------------------------------ + +shutdown() { + log "Initiating graceful shutdown..." + + # Kill all services + pkill -f "ttyd" 2>/dev/null || true + pkill -f "opencode" 2>/dev/null || true + pkill -f "openchamber" 2>/dev/null || true + pkill -f "opencode web" 2>/dev/null || true + pkill -f "ngrok" 2>/dev/null || true + pkill -f "cloudflared" 2>/dev/null || true + + log "All services stopped" + exit 0 +} + +# Trap signals for graceful shutdown +trap shutdown SIGTERM SIGINT + +# ============================================================================== +# Main Monitoring Loop +# ============================================================================== + +log "Starting monitoring loop..." +echo "" + +# Write initial URLs to GitHub Step Summary (if running in GitHub Actions) +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## OpenChamber for Actions" + echo "" + echo "| Service | URL |" + echo "|---------|-----|" + echo "| **OpenCode TTY** | $URL_TTY |" + echo "| **OpenChamber** | $URL_CHAMBER |" + echo "| **OpenCode Web** | $URL_WEB |" + echo "" + echo "_Timeout: ${TIMEOUT_MINUTES} minutes_" + } >> "$GITHUB_STEP_SUMMARY" +fi + +# Status display interval (every 5 minutes = 300 seconds) +LAST_STATUS_TIME=0 +STATUS_INTERVAL=300 + +while true; do + REMAINING=$(get_remaining_time) + + # Check for timeout + if [ "$REMAINING" -le 0 ]; then + log "Timeout reached. Initiating graceful shutdown..." + shutdown + fi + + # Periodic status update (every 5 minutes) + CURRENT_TIME=$(date +%s) + if [ $((CURRENT_TIME - LAST_STATUS_TIME)) -ge $STATUS_INTERVAL ]; then + echo "" + echo "==============================================" + log "Status Update" + echo "Time remaining: $(format_time "$REMAINING")" + echo "OpenCode TTY: $URL_TTY" + echo "OpenChamber: $URL_CHAMBER" + echo "OpenCode Web: $URL_WEB" + echo "==============================================" + echo "" + LAST_STATUS_TIME=$CURRENT_TIME + fi + + # Check OpenCode TTY + if ! check_port $TTY_PORT; then + log "OpenCode TTY not responding on port $TTY_PORT" + restart_opencode_tty + fi + + # Check OpenChamber + if ! check_port $OPENCHAMBER_PORT; then + log "OpenChamber not responding on port $OPENCHAMBER_PORT" + restart_openchamber + fi + + # Check OpenCode Web + if ! check_port $OPENCODE_WEB_PORT; then + log "OpenCode Web not responding on port $OPENCODE_WEB_PORT" + restart_opencode_web + fi + + # Check Tunnel Process (Basic check) + if ! check_tunnels; then + log "Tunnel processes not running" + restart_tunnels + fi + + # Sleep before next check + sleep 5 +done diff --git a/scripts/opencode-config.sh b/scripts/opencode-config.sh new file mode 100755 index 00000000..4e319ca0 --- /dev/null +++ b/scripts/opencode-config.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# ============================================================================== +# OpenCode Configuration Script +# ============================================================================== +# This script manages OpenCode configuration with intelligent detection of +# restored artifacts. If a config file exists from a previous session (restored +# via artifact), it will be preserved. Otherwise, a default config is generated. +# ============================================================================== + +set -euo pipefail + +CONFIG_DIR="$HOME/.config/opencode" +RESTORE_DIR="${RESTORE_DIR:-/tmp/opencode-restore}" + +echo "=== OpenCode Configuration Setup ===" +echo "Config directory: $CONFIG_DIR" +echo "Restore directory: $RESTORE_DIR" + +# Create config directory if it doesn't exist +mkdir -p "$CONFIG_DIR" + +# ------------------------------------------------------------------------------ +# Check for restored configuration (artifact preference) +# ------------------------------------------------------------------------------ +check_restored_config() { + local restored_config_json="$RESTORE_DIR/config/opencode.json" + local restored_config_yml="$RESTORE_DIR/config/opencode.yml" + local restored_config_yaml="$RESTORE_DIR/config/opencode.yaml" + + # Priority: .yml > .yaml > .json from artifacts + if [ -f "$restored_config_yml" ]; then + echo "Found restored opencode.yml from artifact - using it" + cp -v "$restored_config_yml" "$CONFIG_DIR/opencode.yml" + return 0 + elif [ -f "$restored_config_yaml" ]; then + echo "Found restored opencode.yaml from artifact - using it" + cp -v "$restored_config_yaml" "$CONFIG_DIR/opencode.yaml" + return 0 + elif [ -f "$restored_config_json" ]; then + echo "Found restored opencode.json from artifact - using it" + cp -v "$restored_config_json" "$CONFIG_DIR/opencode.json" + return 0 + fi + + return 1 +} + +# ------------------------------------------------------------------------------ +# Check for existing configuration in config directory +# ------------------------------------------------------------------------------ +check_existing_config() { + if [ -f "$CONFIG_DIR/opencode.yml" ] || \ + [ -f "$CONFIG_DIR/opencode.yaml" ] || \ + [ -f "$CONFIG_DIR/opencode.json" ]; then + echo "Existing configuration found in $CONFIG_DIR - keeping it" + ls -la "$CONFIG_DIR"/opencode.* 2>/dev/null || true + return 0 + fi + return 1 +} + +# ------------------------------------------------------------------------------ +# Generate default configuration +# ------------------------------------------------------------------------------ +generate_default_config() { + echo "Generating default OpenCode configuration..." + + cat << 'EOF' > "$CONFIG_DIR/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-antigravity-auth@beta"], + "provider": { + "google": { + "models": { + "antigravity-gemini-3-pro": { + "name": "Gemini 3 Pro (Antigravity)", + "limit": { "context": 1048576, "output": 65535 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "low": { "thinkingLevel": "low" }, + "high": { "thinkingLevel": "high" } + } + }, + "antigravity-gemini-3-flash": { + "name": "Gemini 3 Flash (Antigravity)", + "limit": { "context": 1048576, "output": 65536 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "minimal": { "thinkingLevel": "minimal" }, + "low": { "thinkingLevel": "low" }, + "medium": { "thinkingLevel": "medium" }, + "high": { "thinkingLevel": "high" } + } + }, + "antigravity-claude-sonnet-4-5": { + "name": "Claude Sonnet 4.5 (no thinking) (Antigravity)", + "limit": { "context": 200000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "antigravity-claude-sonnet-4-5-thinking": { + "name": "Claude Sonnet 4.5 Thinking (Antigravity)", + "limit": { "context": 200000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, + "max": { "thinkingConfig": { "thinkingBudget": 32768 } } + } + }, + "antigravity-claude-opus-4-5-thinking": { + "name": "Claude Opus 4.5 Thinking (Antigravity)", + "limit": { "context": 200000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, + "max": { "thinkingConfig": { "thinkingBudget": 32768 } } + } + }, + "gemini-2.5-flash": { + "name": "Gemini 2.5 Flash (Gemini CLI)", + "limit": { "context": 1048576, "output": 65536 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "gemini-2.5-pro": { + "name": "Gemini 2.5 Pro (Gemini CLI)", + "limit": { "context": 1048576, "output": 65536 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "gemini-3-flash-preview": { + "name": "Gemini 3 Flash Preview (Gemini CLI)", + "limit": { "context": 1048576, "output": 65536 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "gemini-3-pro-preview": { + "name": "Gemini 3 Pro Preview (Gemini CLI)", + "limit": { "context": 1048576, "output": 65535 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + } + } + } + } +} +EOF + + echo "Default configuration created at $CONFIG_DIR/opencode.json" +} + +# ============================================================================== +# Main Logic +# ============================================================================== + +# Step 1: Check if config was restored from artifact (highest priority) +if check_restored_config; then + echo "Using restored configuration from artifact" +# Step 2: Check if config already exists (from previous run or manual setup) +elif check_existing_config; then + echo "Using existing configuration" +# Step 3: No config found - generate default +else + generate_default_config +fi + +echo "" +echo "=== Configuration Summary ===" +echo "Active configuration files:" +ls -la "$CONFIG_DIR"/*.json "$CONFIG_DIR"/*.yml "$CONFIG_DIR"/*.yaml 2>/dev/null || echo "No config files found" +echo "" +echo "Configuration setup complete!" diff --git a/scripts/persistence-restore.sh b/scripts/persistence-restore.sh new file mode 100755 index 00000000..5115c5bf --- /dev/null +++ b/scripts/persistence-restore.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# ============================================================================== +# OpenCode Persistence Restore Script +# ============================================================================== +# This script restores session data from GitHub Actions artifacts. +# It handles the restoration of: +# - Configuration files (~/.config/opencode/) +# - Session data, messages, chats (~/.local/share/opencode/storage/) +# - Project snapshots (~/.local/share/opencode/snapshot/) +# - Authentication tokens (~/.local/share/opencode/auth.json) +# +# If the artifact is encrypted (session.enc exists), it will be decrypted +# using OPENCODE_SERVER_PASSWORD. +# ============================================================================== + +set -euo pipefail + +RESTORE_DIR="${RESTORE_DIR:-/tmp/opencode-restore}" +CONFIG_DIR="$HOME/.config/opencode" +SHARE_DIR="$HOME/.local/share/opencode" +ENCRYPTION_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}" + +echo "=== OpenCode Session Restore ===" +echo "Restore directory: $RESTORE_DIR" +echo "Config directory: $CONFIG_DIR" +echo "Share directory: $SHARE_DIR" +echo "" + +# ------------------------------------------------------------------------------ +# Check for encrypted artifact and decrypt if needed +# ------------------------------------------------------------------------------ +if [ -f "$RESTORE_DIR/session.enc" ]; then + echo "=== Encrypted Artifact Detected ===" + + if [ -z "$ENCRYPTION_PASSWORD" ]; then + echo "ERROR: Encrypted artifact found but OPENCODE_SERVER_PASSWORD is not set." + echo "Cannot decrypt session data. Starting fresh." + rm -rf "$RESTORE_DIR" + exit 0 + fi + + echo "Decrypting session data..." + TEMP_ARCHIVE="/tmp/opencode-session-data.tar.gz" + + if openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 \ + -in "$RESTORE_DIR/session.enc" \ + -out "$TEMP_ARCHIVE" \ + -pass pass:"$ENCRYPTION_PASSWORD" 2>/dev/null; then + + rm -rf "$RESTORE_DIR" + mkdir -p "$RESTORE_DIR" + tar -xzf "$TEMP_ARCHIVE" -C "$RESTORE_DIR" + rm -f "$TEMP_ARCHIVE" + echo "Decryption successful." + else + echo "ERROR: Decryption failed. Password may be incorrect." + echo "Starting fresh session." + rm -rf "$RESTORE_DIR" + rm -f "$TEMP_ARCHIVE" + exit 0 + fi + echo "" +fi + +# ------------------------------------------------------------------------------ +# Debug: Show what we're working with +# ------------------------------------------------------------------------------ +echo "=== RESTORE DEBUG ===" +echo "Contents of $RESTORE_DIR:" +if [ -d "$RESTORE_DIR" ]; then + ls -la "$RESTORE_DIR/" 2>/dev/null || echo "Directory exists but empty" + echo "" + echo "Full tree of restore directory:" + find "$RESTORE_DIR" -type f 2>/dev/null | head -50 || echo "No files found" +else + echo "Restore directory does not exist - fresh start" + exit 0 +fi +echo "" + +# ------------------------------------------------------------------------------ +# Create target directories +# ------------------------------------------------------------------------------ +mkdir -p "$CONFIG_DIR" +mkdir -p "$SHARE_DIR" + +# ------------------------------------------------------------------------------ +# Restore configuration files +# ------------------------------------------------------------------------------ +restore_config() { + local src="$RESTORE_DIR/config" + + if [ -d "$src" ] && [ "$(ls -A "$src" 2>/dev/null)" ]; then + echo "Restoring configuration files..." + + # Restore all config files except node_modules (will be reinstalled) + find "$src" -maxdepth 1 -type f -exec cp -v {} "$CONFIG_DIR/" \; 2>/dev/null || true + + # Count restored files + local count=$(find "$src" -maxdepth 1 -type f 2>/dev/null | wc -l) + echo "Restored $count configuration file(s)" + else + echo "No configuration files to restore" + fi +} + +# ------------------------------------------------------------------------------ +# Restore share data (sessions, messages, snapshots, etc.) +# ------------------------------------------------------------------------------ +restore_share() { + local src="$RESTORE_DIR/share" + + if [ -d "$src" ] && [ "$(ls -A "$src" 2>/dev/null)" ]; then + echo "Restoring share data..." + + # Restore auth.json + if [ -f "$src/auth.json" ]; then + cp -v "$src/auth.json" "$SHARE_DIR/" 2>/dev/null || true + echo "Authentication data restored" + fi + + # Restore storage directory (sessions, messages, parts, projects) + if [ -d "$src/storage" ]; then + mkdir -p "$SHARE_DIR/storage" + cp -rv "$src/storage/"* "$SHARE_DIR/storage/" 2>/dev/null || true + local storage_count=$(find "$SHARE_DIR/storage" -type f 2>/dev/null | wc -l) + echo "Storage data restored: $storage_count file(s)" + fi + + # Restore snapshot directory (project snapshots) + if [ -d "$src/snapshot" ]; then + mkdir -p "$SHARE_DIR/snapshot" + cp -rv "$src/snapshot/"* "$SHARE_DIR/snapshot/" 2>/dev/null || true + local snapshot_count=$(find "$SHARE_DIR/snapshot" -type f 2>/dev/null | wc -l) + echo "Snapshot data restored: $snapshot_count file(s)" + fi + + # Restore log directory + if [ -d "$src/log" ]; then + mkdir -p "$SHARE_DIR/log" + cp -rv "$src/log/"* "$SHARE_DIR/log/" 2>/dev/null || true + echo "Log data restored" + fi + else + echo "No share data to restore" + fi +} + +# ============================================================================== +# Main Restoration Process +# ============================================================================== + +echo "=== Starting Restoration ===" + +restore_config +echo "" + +restore_share +echo "" + +# ------------------------------------------------------------------------------ +# Summary +# ------------------------------------------------------------------------------ +echo "=== Restoration Summary ===" + +echo "Config directory contents:" +ls -la "$CONFIG_DIR/" 2>/dev/null | head -10 || echo "Empty" +echo "" + +echo "Share directory structure:" +if [ -d "$SHARE_DIR" ]; then + du -sh "$SHARE_DIR"/* 2>/dev/null || echo "Empty" +fi +echo "" + +# Count total restored items +config_files=$(find "$CONFIG_DIR" -maxdepth 1 -type f 2>/dev/null | wc -l) +share_files=$(find "$SHARE_DIR" -type f 2>/dev/null | wc -l) + +echo "Total restored: $config_files config files, $share_files share files" +echo "" +echo "Session restoration complete!" diff --git a/scripts/persistence-save.sh b/scripts/persistence-save.sh new file mode 100755 index 00000000..35a3cb9d --- /dev/null +++ b/scripts/persistence-save.sh @@ -0,0 +1,233 @@ +#!/bin/bash +# ============================================================================== +# OpenCode Persistence Save Script +# ============================================================================== +# This script prepares all session data for artifact upload. +# It saves: +# - Configuration files (~/.config/opencode/) +# - Session data, messages, chats (~/.local/share/opencode/storage/) +# - Project snapshots (~/.local/share/opencode/snapshot/) +# - Authentication tokens (~/.local/share/opencode/auth.json) +# - Logs (~/.local/share/opencode/log/) +# +# Excludes (to keep artifact size manageable): +# - node_modules/ directories +# - bin/ directory (will be reinstalled) +# - tool-output/ (temporary tool outputs) +# +# If OPENCODE_SERVER_PASSWORD is set, the artifact will be encrypted. +# ============================================================================== + +set -euo pipefail + +SAVE_DIR="${SAVE_DIR:-/tmp/opencode-save}" +CONFIG_DIR="$HOME/.config/opencode" +SHARE_DIR="$HOME/.local/share/opencode" +ENCRYPTION_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}" + +echo "=== OpenCode Session Save ===" +echo "Save directory: $SAVE_DIR" +echo "Config directory: $CONFIG_DIR" +echo "Share directory: $SHARE_DIR" +if [ -n "$ENCRYPTION_PASSWORD" ]; then + echo "Encryption: ENABLED" +else + echo "Encryption: DISABLED (set OPENCODE_SERVER_PASSWORD to enable)" +fi +echo "" + +# ------------------------------------------------------------------------------ +# Clean up and create save directory +# ------------------------------------------------------------------------------ +rm -rf "$SAVE_DIR" +mkdir -p "$SAVE_DIR/config" +mkdir -p "$SAVE_DIR/share" + +# ------------------------------------------------------------------------------ +# Save configuration files +# ------------------------------------------------------------------------------ +save_config() { + echo "=== Saving Configuration Files ===" + + if [ -d "$CONFIG_DIR" ] && [ "$(ls -A "$CONFIG_DIR" 2>/dev/null)" ]; then + # Save all config files (excluding node_modules) + for item in "$CONFIG_DIR"/*; do + if [ -e "$item" ]; then + local basename=$(basename "$item") + + # Skip node_modules and bun.lock (will be reinstalled) + if [ "$basename" = "node_modules" ] || [ "$basename" = "bun.lock" ]; then + echo "Skipping: $basename (will be reinstalled)" + continue + fi + + # Copy file or directory + if [ -f "$item" ]; then + cp -v "$item" "$SAVE_DIR/config/" 2>/dev/null || true + fi + fi + done + + local count=$(find "$SAVE_DIR/config" -type f 2>/dev/null | wc -l) + echo "Config files saved: $count" + else + echo "No config data to save" + fi +} + +# ------------------------------------------------------------------------------ +# Save share data (sessions, messages, snapshots, auth) +# ------------------------------------------------------------------------------ +save_share() { + echo "" + echo "=== Saving Share Data ===" + + if [ ! -d "$SHARE_DIR" ]; then + echo "No share directory found" + return + fi + + # Save auth.json (authentication tokens - critical!) + if [ -f "$SHARE_DIR/auth.json" ]; then + cp -v "$SHARE_DIR/auth.json" "$SAVE_DIR/share/" 2>/dev/null || true + echo "Auth data saved" + fi + + # Save storage directory (sessions, messages, parts, projects) + if [ -d "$SHARE_DIR/storage" ] && [ "$(ls -A "$SHARE_DIR/storage" 2>/dev/null)" ]; then + echo "Saving storage data (sessions, messages, chats)..." + mkdir -p "$SAVE_DIR/share/storage" + + # Copy all storage subdirectories + for subdir in message migration part project session session_diff; do + if [ -d "$SHARE_DIR/storage/$subdir" ]; then + cp -r "$SHARE_DIR/storage/$subdir" "$SAVE_DIR/share/storage/" 2>/dev/null || true + fi + done + + local storage_count=$(find "$SAVE_DIR/share/storage" -type f 2>/dev/null | wc -l) + echo "Storage files saved: $storage_count" + fi + + # Save snapshot directory (project snapshots for undo/rollback) + if [ -d "$SHARE_DIR/snapshot" ] && [ "$(ls -A "$SHARE_DIR/snapshot" 2>/dev/null)" ]; then + echo "Saving snapshot data..." + mkdir -p "$SAVE_DIR/share/snapshot" + cp -r "$SHARE_DIR/snapshot/"* "$SAVE_DIR/share/snapshot/" 2>/dev/null || true + + local snapshot_count=$(find "$SAVE_DIR/share/snapshot" -type f 2>/dev/null | wc -l) + echo "Snapshot files saved: $snapshot_count" + fi + + # Save log directory (useful for debugging) + if [ -d "$SHARE_DIR/log" ] && [ "$(ls -A "$SHARE_DIR/log" 2>/dev/null)" ]; then + echo "Saving log data..." + mkdir -p "$SAVE_DIR/share/log" + cp -r "$SHARE_DIR/log/"* "$SAVE_DIR/share/log/" 2>/dev/null || true + echo "Log files saved" + fi +} + +# ------------------------------------------------------------------------------ +# Create manifest with metadata +# ------------------------------------------------------------------------------ +create_manifest() { + echo "" + echo "=== Creating Manifest ===" + + local manifest="$SAVE_DIR/manifest.json" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local config_count=$(find "$SAVE_DIR/config" -type f 2>/dev/null | wc -l) + local share_count=$(find "$SAVE_DIR/share" -type f 2>/dev/null | wc -l) + local total_size=$(du -sh "$SAVE_DIR" 2>/dev/null | cut -f1) + + cat << EOF > "$manifest" +{ + "version": "2.0", + "timestamp": "$timestamp", + "hostname": "$(hostname)", + "stats": { + "config_files": $config_count, + "share_files": $share_count, + "total_size": "$total_size" + }, + "contents": { + "config": $([ -d "$SAVE_DIR/config" ] && ls "$SAVE_DIR/config" 2>/dev/null | jq -R -s -c 'split("\n") | map(select(length > 0))' || echo '[]'), + "share": $([ -d "$SAVE_DIR/share" ] && ls "$SAVE_DIR/share" 2>/dev/null | jq -R -s -c 'split("\n") | map(select(length > 0))' || echo '[]') + } +} +EOF + + echo "Manifest created: $manifest" + cat "$manifest" +} + +# ============================================================================== +# Main Save Process +# ============================================================================== + +save_config +save_share +create_manifest + +# ------------------------------------------------------------------------------ +# Final Summary +# ------------------------------------------------------------------------------ +echo "" +echo "=== Save Summary ===" + +# Calculate totals +total_files=$(find "$SAVE_DIR" -type f 2>/dev/null | wc -l) +total_size=$(du -sh "$SAVE_DIR" 2>/dev/null | cut -f1) + +echo "Save directory structure:" +if command -v tree &> /dev/null; then + tree -L 2 "$SAVE_DIR" 2>/dev/null || ls -laR "$SAVE_DIR" | head -50 +else + ls -laR "$SAVE_DIR" | head -50 +fi + +echo "" +echo "Total files to upload: $total_files" +echo "Total size: $total_size" +echo "" + +# Verify we have something to save +if [ "$total_files" -gt 1 ]; then + echo "Session data prepared successfully for artifact upload!" +else + echo "Warning: Minimal data to save. Creating placeholder..." + echo "{\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"status\": \"empty\"}" > "$SAVE_DIR/placeholder.json" +fi + +# ------------------------------------------------------------------------------ +# Optional Encryption (if OPENCODE_SERVER_PASSWORD is set) +# ------------------------------------------------------------------------------ +if [ -n "$ENCRYPTION_PASSWORD" ]; then + echo "" + echo "=== Encrypting Session Data ===" + + TEMP_ARCHIVE="/tmp/opencode-session-data.tar.gz" + ENCRYPTED_FILE="$SAVE_DIR.enc" + + tar -czf "$TEMP_ARCHIVE" -C "$SAVE_DIR" . + + openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 \ + -in "$TEMP_ARCHIVE" \ + -out "$ENCRYPTED_FILE" \ + -pass pass:"$ENCRYPTION_PASSWORD" + + rm -f "$TEMP_ARCHIVE" + rm -rf "$SAVE_DIR" + mkdir -p "$SAVE_DIR" + mv "$ENCRYPTED_FILE" "$SAVE_DIR/session.enc" + + echo '{"encrypted": true, "algorithm": "aes-256-cbc", "kdf": "pbkdf2", "iterations": 100000}' > "$SAVE_DIR/manifest.json" + + echo "Encryption complete. Artifact is password-protected." + echo "Encrypted file: $SAVE_DIR/session.enc" +fi + +echo "" +echo "Save location: $SAVE_DIR" +echo "Ready for artifact upload!"