feat: added github action for starting opencode/openchamber in actions infrastructure with persistence encryption (#194)

* fix(ui): make chat message action icons always visible

- Removes opacity-0 and pointer-events-none classes from UserMessageBody action buttons container.
- Ensures icons are visible by default instead of only on hover/focus.
- Addresses user feedback about empty space at the bottom of chat bubbles.

* fix(ui): make chat message action icons always visible

- Removes opacity-0 and pointer-events-none classes from UserMessageBody action buttons container.
- Ensures icons are visible by default instead of only on hover/focus.
- Addresses user feedback about empty space at the bottom of chat bubbles.

* fix(ui): prevent diff comment input from closing on click (#3)

Replaced global `document.querySelector('[data-comment-ui]')` with a local `useRef` to detect clicks inside the comment input area. This fixes an issue where clicking the input in one diff viewer would close it if another diff viewer (found first by querySelector) existed in the DOM.

- Added `commentContainerRef` in `PierreDiffViewer.tsx`.
- Updated `handleClickOutside` to check against the ref.
- Attached ref to comment UI containers in both 'fill' and 'inline' layouts.
- Preserved `data-comment-ui` attribute for backward compatibility.

* feat(ui): add visual connector lines to file explorer (#2)

- Replaced manual padding-based indentation with nested list structure
- Added vertical connector lines using border-l on nested ul
- Added horizontal connector lines using pseudo-elements on list items
- Added tail masking for the last item in a branch to create the 'L' shape
- Removed manual padding calculation from file/folder buttons

* Create openchamber.yml

* Create OPENCODE_FOR_ACTIONS.md

* feat: update OpenChamber for Actions workflow and docs (#6)

- Rename `docs/OPENCODE_FOR_ACTIONS.md` to `docs/OPENCHAMBER_FOR_ACTIONS.md`.
- Update `README.md` to include a section on OpenChamber for Actions.
- Update `.github/workflows/openchamber.yml` to allow users to select which service to expose (OpenChamber, OpenCode, or Both).
- Update documentation to reflect the new workflow options.

* feat: Add shimmer effect for generating messages (#7)

* Create test_unreleased.yml

* Update test_unreleased.yml

* Delete test_unreleased.yml

* 🧪 Labs: Enhanced File Explorer with Context Menus & UI Improvements (#13)

* feat: add file management capabilities (create, rename, delete) (#11)

Added backend endpoints for delete and rename operations.
Extended FilesAPI interface and implementation.
Updated FilesView with toolbar buttons for Create File/Folder.
Added context menu to file tree items for Rename, Delete, and Copy Path.
Implemented dialogs for all file operations.

* Refactor UI/UX: Modernize styles, components, and layout architecture (#10)

Refactored `packages/ui` CSS into modular files (design-system, typography, mobile).
Modernized `Header` with pill-style tabs and semantic Flexbox layout.
Enhanced `Chat` UI with threading visuals, improved typography, and better spacing.
Updated `Sidebar` to match the new design system aesthetic.
Verified with full workspace build.

* Add workflow

* Change to manual dispatch only

* Delete test.yml

* Update openchamber.yml

* Fix styling and workflow improvements (#17)

* Refactor OpenChamber workflow and update docs (#18)

- Refactor `.github/workflows/openchamber.yml` to use modular helper scripts.
- Add helper scripts in `scripts/`: `monitor.sh`, `persistence-restore.sh`, `persistence-save.sh`, `opencode-config.sh`.
- Update `docs/OPENCHAMBER_FOR_ACTIONS.md` to reflect new inputs and features.
- Implement robust monitoring with self-healing and proper URL logging.
- Improve persistence robustness for file copying.

* Expose Opencode Core, Web, and Chamber via Cloudflare tunnels (#19)

This commit updates the GitHub Actions workflow and monitoring script to:
1. Install `wetty` to expose the core Opencode TTY over the web (interpreted from user request for "WiTTY").
2. Start `opencode web` (8080), `openchamber` (9090), and `wetty` (3000) concurrently.
3. Configure Cloudflare to create three separate tunnels for these ports.
4. Update `monitor.sh` to accept and display URLs for all three services.

* Update opencode-config.sh

* Tighten actions persistence and UI polish

* Delete openchamber.yml

* Delete persistence-save.sh

* Delete persistence-restore.sh

* Delete opencode-config.sh

* Delete monitor.sh

* Delete install.sh

* Fix OpenChamber workflow and scripts for multi-service tunnels (#21)

* Create auto-opencode.yml

* Update opencode.yml

* feat: add password protection for OpenChamber in Actions (#22)

- Introduce `OPENCODE_SERVER_PASSWORD` secret support in `.github/workflows/opencode.yml`.
- Automatically set `OPENCHAMBER_UI_PASSWORD` and `OPENCODE_UI_PASSWORD` if the secret is provided.
- Configure `ttyd` to use basic auth (`-c user:password`) when password is set.
- Update `scripts/monitor.sh` to respect password settings during service restarts.
- Significantly update `docs/OPENCHAMBER_FOR_ACTIONS.md` with an overview, password configuration instructions, and improved layout (collapsible sections).

* use one password env variable

* Update OPENCHAMBER_FOR_ACTIONS.md

* feat: improve GITHUB_STEP_SUMMARY, fix TTY home logic and add encryption support in scripts

* feat: lazy load large diffs to prevent page freeze (#186)

When diff content exceeds 1500 lines or 150KB, show a placeholder with
"Load Diff" button instead of parsing immediately. This prevents the page
from freezing when viewing large diffs.

Co-authored-by: Jovines <jovines@qq.com>

* feat: add Web Push API support and PWA integration (#189)

* feat: add Web Push API support and PWA integration

Add web Push API with subscribe/unsubscribe and visibility endpoints
Introduce usePushVisibilityBeacon and useSessionDeepLink hooks
Integrate PWA with service worker, registerSW, and VAPID key persistence

* feat: add heartbeat visibility beacon for web runtime

Add a 10s heartbeat to ping visibility while visible
Subscribe to visibilitychange, focus, blur, pageshow, and pagehide events to report state
Clear heartbeat interval on unmount to avoid leaks

* feat(UI): Introduce new UI components for file attachments and related views (#191)

Add file management API and UI components
Implement directory listing, search and CRUD operations in desktop backend
Expose new Files API on frontend to list, search, and modify files

* feat: add workbox-window and improve event stream cleanup

Add workbox-window dependency to bun.lock and web package.json
Cancel and release the stream reader on disconnect to avoid leaks

* feat: provider config management (#193)

* feat: support scoped removal of provider config (auth, user, project, custom)

* feat: implement UI session token management with cookies for window visibility control

* feature: header layout changes (#195)

* fix(ui): remove fixed sessions button and mac titlebar coupling

Remove fixed sessions button from header on desktop Mac
Eliminate Mac titlebar spacer and drag-to-dock logic in sidebar
Update layout to rely on standard header/sidebar without mac-specific tweaks

* feat: improve mobile header with session toggle and back button

Add back button in mobile header to exit session switcher
Show Sessions label when session switcher is open in mobile header
Toggle between opening sessions and back navigation based on session state

* restore: add back scripts/install.sh from main branch

* docs: improve Tech Stack section with organized label badges

* Fix actions auth and remove shimmer
This commit is contained in:
Ashik Ahmed
2026-01-22 18:24:11 +02:00
committed by GitHub
parent 2dd4a7a3a6
commit 1937dbfcd2
11 changed files with 1458 additions and 34 deletions
+29
View File
@@ -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
+220 -20
View File
@@ -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
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
+24 -1
View File
@@ -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
+166
View File
@@ -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)
<details>
<summary><b>Click to expand: What is this and how does it help me?</b></summary>
### 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.
</details>
---
<details>
<summary><b>Usage Limits & Quotas</b></summary>
To ensure stability and compliance with GitHubs 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). |
</details>
---
## 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)
<details>
<summary><b>Click to expand: Step-by-step guide for setting up Ngrok</b></summary>
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.
</details>
---
## Frequently Asked Questions (FAQ)
<details>
<summary><b>Q: Is this completely free?</b></summary>
GitHub provides 2,000 minutes for private repositories and unlimited minutes for public repositories. This workflow uses those minutes.
</details>
<details>
<summary><b>Q: Why did my session stop working after a few hours?</b></summary>
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.
</details>
<details>
<summary><b>Q: Can I use this on my phone or tablet?</b></summary>
Yes. Once you have a public URL (Cloudflare or Ngrok), open it in any mobile browser.
</details>
<details>
<summary><b>Q: Is my data private?</b></summary>
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.
</details>
<details>
<summary><b>Q: I see a "Connection Refused" error. What do I do?</b></summary>
Wait about 30 seconds and refresh the page. If it persists, check the Monitor logs in the Actions run.
</details>
---
## 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.
+2
View File
@@ -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[] }>;
}
+107 -13
View File
@@ -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();
+36
View File
@@ -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,
};
},
});
+292
View File
@@ -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 <tunnel_provider> <timeout_minutes> <url_tty> <url_chamber> <url_web>
#
# 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
+167
View File
@@ -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!"
+182
View File
@@ -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!"
+233
View File
@@ -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!"