Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# OpenCode Server
OPENCODE_PORT=4096
OPENCODE_HOSTNAME=0.0.0.0
OPENCODE_HOSTNAME=127.0.0.1
OPENCODE_SERVER_USERNAME=opencode
# REQUIRED - Change this to a strong password!
OPENCODE_SERVER_PASSWORD=your-strong-password-here
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Check server syntax
run: |
node --check src/server.js
node --check src/server-manager.js
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ npm start
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENCODE_PORT` | OpenCode server port | `4096` |
| `OPENCODE_HOSTNAME` | Server bind address | `0.0.0.0` |
| `OPENCODE_HOSTNAME` | Server bind address | `127.0.0.1` |
| `OPENCODE_SERVER_USERNAME` | Auth username | `opencode` |
| `OPENCODE_SERVER_PASSWORD` | Auth password (**required**) | - |
| `OPENCODE_CORS` | Allowed CORS origins (comma-separated) | - |
Expand All @@ -135,7 +135,8 @@ Add these in **Settings → Secrets and variables → Codespaces**:
- Always set a strong `OPENCODE_SERVER_PASSWORD`
- Use HTTPS (automatically provided by Codespaces)
- Keep CORS origins restricted
- Clear credentials when not in use
- Passwords are kept in memory only and are not persisted to `localStorage`; reloads require re-entry
- Keep the default `127.0.0.1` bind unless you intentionally place the server behind a private/authenticated network boundary

## Testing

Expand Down
40 changes: 10 additions & 30 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,37 @@

class Auth {
constructor() {
this.storageKey = 'opencode-auth';
this.credentials = null;
// Remove credentials persisted by older versions of the app.
localStorage.removeItem('opencode-auth');
}

/**
* Store credentials
*/
saveCredentials(username, password) {
const credentials = {
this.credentials = {
username,
password: this.encode(password),
password,
timestamp: new Date().toISOString()
};
localStorage.setItem(this.storageKey, JSON.stringify(credentials));
}

/**
* Get stored credentials
*/
getCredentials() {
try {
const data = JSON.parse(localStorage.getItem(this.storageKey));
if (!data) return null;
return {
username: data.username,
password: this.decode(data.password),
timestamp: data.timestamp
};
} catch (e) {
return null;
}
if (!this.credentials) return null;
return { ...this.credentials };
}

/**
* Clear credentials
*/
clearCredentials() {
localStorage.removeItem(this.storageKey);
this.credentials = null;
localStorage.removeItem('opencode-auth');
localStorage.removeItem('opencode-password');
}

/**
Expand All @@ -51,20 +45,6 @@ class Auth {
return this.getCredentials() !== null;
}

/**
* Encode password (obfuscation, not encryption)
*/
encode(text) {
return btoa(encodeURIComponent(text));
}

/**
* Decode password
*/
decode(encoded) {
return decodeURIComponent(atob(encoded));
}

/**
* Verify server connection with credentials
*/
Expand Down
6 changes: 4 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ class App {

async init() {
try {
// Check for saved settings
// Restore non-sensitive settings only. Passwords are deliberately kept
// in memory and must be re-entered after a reload.
localStorage.removeItem('opencode-password'); // purge legacy plaintext storage
const savedConfig = {
url: localStorage.getItem('opencode-url'),
username: localStorage.getItem('opencode-username') || 'opencode',
password: localStorage.getItem('opencode-password') || ''
password: ''
};

// Restore theme
Expand Down
16 changes: 11 additions & 5 deletions src/server-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const command = process.argv[2] || 'start';
const port = parseInt(process.env.OPENCODE_PORT || process.argv[3] || '4096', 10);
const password = process.env.OPENCODE_SERVER_PASSWORD;
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
const hostname = process.env.OPENCODE_HOSTNAME || '127.0.0.1';

if (!password) {
console.error('❌ OPENCODE_SERVER_PASSWORD environment variable is required');
Expand All @@ -19,6 +20,7 @@ if (!password) {

const server = new OpenCodeServer({
port,
hostname,
password,
username,
cors: (process.env.OPENCODE_CORS || '').split(',').filter(Boolean)
Expand All @@ -28,11 +30,15 @@ async function main() {
switch (command) {
case 'start':
await server.start();
console.log('\n📱 Access OpenCode mobile UI at:');
console.log(` http://localhost:${port}`);
console.log(`\n🔐 Credentials:`);
console.log(` Username: ${username}`);
console.log(` Password: ${password}`);
const displayHost = ['0.0.0.0', '::'].includes(hostname) ? 'localhost' : hostname;
console.log('\n📱 OpenCode server:');
console.log(` http://${displayHost}:${port}`);
console.log(`\n🔐 Username: ${username}`);

console.log(' Password: configured via OPENCODE_SERVER_PASSWORD (not displayed)');
if (!['127.0.0.1', '::1', 'localhost'].includes(hostname)) {
console.warn('⚠️ OpenCode is bound beyond loopback. Only do this behind an authenticated/private network boundary.');
}
console.log('\nPress Ctrl+C to stop\n');

// Keep process alive
Expand Down
51 changes: 22 additions & 29 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

const { exec, spawn } = require('child_process');
const http = require('http');
const path = require('path');
const fs = require('fs');
const os = require('os');
Expand All @@ -12,7 +13,7 @@ class OpenCodeServer {
constructor(options = {}) {
this.options = {
port: options.port || 4096,
hostname: options.hostname || '0.0.0.0',
hostname: options.hostname || process.env.OPENCODE_HOSTNAME || '127.0.0.1',
password: options.password || process.env.OPENCODE_SERVER_PASSWORD,
username: options.username || process.env.OPENCODE_SERVER_USERNAME || 'opencode',
cors: options.cors || [],
Expand Down Expand Up @@ -79,8 +80,10 @@ class OpenCodeServer {

const installed = await this.checkOpenCodeInstalled();
if (!installed) {
console.log('OpenCode not installed. Installing...');
await this.installOpenCode();
throw new Error(
'OpenCode is not installed. Install it explicitly, verify the installer you use, ' +
'then run this command again. Automatic curl-to-shell installation is disabled.'
);
}

console.log('🚀 Starting OpenCode web server...');
Expand Down Expand Up @@ -132,26 +135,6 @@ class OpenCodeServer {
};
}

/**
* Install OpenCode
*/
installOpenCode() {
return new Promise((resolve, reject) => {
const install = spawn('bash', ['-c', 'curl -fsSL https://opencode.ai/install | bash'], {
stdio: 'inherit',
shell: false
});

install.on('exit', (code) => {
if (code === 0) {
console.log('OpenCode installed successfully');
resolve();
} else {
reject(new Error('Failed to install OpenCode'));
}
});
});
}

/**
* Wait for server to be ready
Expand All @@ -163,14 +146,24 @@ class OpenCodeServer {

const check = setInterval(() => {
const port = this.options.port;
const { exec } = require('child_process');

exec(`curl -s http://${this.options.hostname}:${port}/doc`, (error, stdout) => {
if (!error && stdout) {
const healthHost = ['0.0.0.0', '::'].includes(this.options.hostname) ? '127.0.0.1' : this.options.hostname;

const healthRequest = http.get({
hostname: healthHost,
port,
path: '/doc',
timeout: 1000
}, (response) => {
response.resume();
if (response.statusCode && response.statusCode < 500) {
clearInterval(check);
console.log(`✅ OpenCode server ready at http://${this.options.hostname}:${port}`);
console.log(`✅ OpenCode server ready at http://${healthHost}:${port}`);
resolve();
} else if (Date.now() - start > timeout) {
}
});
healthRequest.on('timeout', () => healthRequest.destroy());
healthRequest.on('error', () => {
if (Date.now() - start > timeout) {
clearInterval(check);
console.log('⚠️ Server not ready within timeout, but may still be starting...');
resolve();
Expand Down
7 changes: 4 additions & 3 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -768,17 +768,18 @@ class OpenCodeUI {
this.api.username = username;
this.api.password = password;

// Save to localStorage
// Persist only non-sensitive settings. The password remains in memory
// for this page lifetime and is removed from legacy localStorage.
localStorage.setItem('opencode-url', url);
localStorage.setItem('opencode-username', username);
localStorage.setItem('opencode-password', password);
localStorage.removeItem('opencode-password');

// Apply theme
document.documentElement.dataset.theme = darkMode ? 'dark' : 'light';
localStorage.setItem('opencode-theme', darkMode ? 'dark' : 'light');

this.hideModal();
this.showToast('Settings saved', 'success');
this.showToast('Settings saved. Password will be forgotten when this page reloads.', 'success');

// Reconnect
this.loadSessions();
Expand Down
Loading