Initial public release
@@ -0,0 +1,2 @@
|
||||
server/prompt-templates.js
|
||||
prompt-enhancer-config.json
|
||||
@@ -0,0 +1,34 @@
|
||||
# @openchamber/web
|
||||
|
||||
Web interface for the [OpenCode](https://opencode.ai) AI coding agent.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm add -g @openchamber/web
|
||||
|
||||
openchamber # Start on port 3000
|
||||
openchamber --port 8080 # Custom port
|
||||
openchamber --daemon # Background mode
|
||||
openchamber --ui-password secret # Password-protect UI
|
||||
openchamber stop # Stop server
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [OpenCode CLI](https://opencode.ai) installed and running (`opencode serve`)
|
||||
- Node.js 20+
|
||||
|
||||
## Features
|
||||
|
||||
- Integrated terminal
|
||||
- Git operations with identity management and AI commit message generation
|
||||
- Beautiful themes (Flexoki Light/Dark)
|
||||
- Mobile-optimized with edge-swipe gestures
|
||||
- Rich permission cards with syntax-highlighted operation previews
|
||||
- Smart tool visualization (inline diffs, file trees, results highlighting)
|
||||
- Per-agent permission mode control
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,561 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
||||
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword };
|
||||
let command = 'serve';
|
||||
|
||||
const consumeValue = (currentIndex, inlineValue) => {
|
||||
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
|
||||
return { value: inlineValue, nextIndex: currentIndex };
|
||||
}
|
||||
const candidate = args[currentIndex + 1];
|
||||
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
|
||||
return { value: candidate, nextIndex: currentIndex + 1 };
|
||||
}
|
||||
return { value: undefined, nextIndex: currentIndex };
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
let optionName;
|
||||
let inlineValue;
|
||||
|
||||
if (arg.startsWith('--')) {
|
||||
const eqIndex = arg.indexOf('=');
|
||||
optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
||||
inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
||||
} else {
|
||||
optionName = arg.slice(1);
|
||||
inlineValue = undefined;
|
||||
}
|
||||
|
||||
switch (optionName) {
|
||||
case 'port':
|
||||
case 'p': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
const parsed = parseInt(value ?? '', 10);
|
||||
options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT;
|
||||
break;
|
||||
}
|
||||
case 'daemon':
|
||||
case 'd':
|
||||
options.daemon = true;
|
||||
break;
|
||||
case 'ui-password': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.uiPassword = typeof value === 'string' ? value : '';
|
||||
break;
|
||||
}
|
||||
case 'help':
|
||||
case 'h':
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
break;
|
||||
case 'version':
|
||||
case 'v':
|
||||
console.log(PACKAGE_JSON.version);
|
||||
process.exit(0);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
command = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
OpenChamber - Web interface for the OpenCode AI coding agent
|
||||
|
||||
USAGE:
|
||||
openchamber [COMMAND] [OPTIONS]
|
||||
|
||||
COMMANDS:
|
||||
serve Start the web server (default)
|
||||
stop Stop running instance(s)
|
||||
restart Stop and start the server
|
||||
status Show server status
|
||||
|
||||
OPTIONS:
|
||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||
--ui-password Protect browser UI with single password
|
||||
-d, --daemon Run in background (serve command)
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
|
||||
ENVIRONMENT:
|
||||
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
||||
|
||||
EXAMPLES:
|
||||
openchamber # Start on default port 3000
|
||||
openchamber --port 8080 # Start on port 8080
|
||||
openchamber serve --daemon # Start in background
|
||||
openchamber stop # Stop all running instances
|
||||
openchamber stop --port 3000 # Stop specific instance
|
||||
openchamber status # Check status
|
||||
`);
|
||||
}
|
||||
|
||||
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
||||
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
||||
: [''];
|
||||
|
||||
function isExecutable(filePath) {
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExplicitBinary(candidate) {
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
||||
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
|
||||
return isExecutable(resolved) ? resolved : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function searchPathFor(command) {
|
||||
const pathValue = process.env.PATH || '';
|
||||
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
||||
for (const dir of segments) {
|
||||
for (const ext of WINDOWS_EXTENSIONS) {
|
||||
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
||||
const candidate = path.join(dir, fileName);
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function checkOpenCodeCLI() {
|
||||
if (process.env.OPENCODE_BINARY) {
|
||||
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
|
||||
if (override) {
|
||||
process.env.OPENCODE_BINARY = override;
|
||||
return override;
|
||||
}
|
||||
console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`);
|
||||
}
|
||||
|
||||
const resolvedFromPath = searchPathFor('opencode');
|
||||
if (resolvedFromPath) {
|
||||
process.env.OPENCODE_BINARY = resolvedFromPath;
|
||||
return resolvedFromPath;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const shellCandidates = [];
|
||||
if (process.env.SHELL) {
|
||||
shellCandidates.push(process.env.SHELL);
|
||||
}
|
||||
shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh');
|
||||
|
||||
for (const shellPath of shellCandidates) {
|
||||
if (!shellPath || !isExecutable(shellPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const candidate = result.stdout.trim().split(/\s+/).pop();
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
const dir = path.dirname(candidate);
|
||||
const currentPath = process.env.PATH || '';
|
||||
const segments = currentPath.split(path.delimiter).filter(Boolean);
|
||||
if (!segments.includes(dir)) {
|
||||
segments.unshift(dir);
|
||||
process.env.PATH = segments.join(path.delimiter);
|
||||
}
|
||||
process.env.OPENCODE_BINARY = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const result = spawnSync('where', ['opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
process.env.OPENCODE_BINARY = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Error: Unable to locate the opencode CLI on PATH.');
|
||||
console.error(`Current PATH: ${process.env.PATH || '<empty>'}`);
|
||||
console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function getPidFilePath(port) {
|
||||
const os = await import('os');
|
||||
const tmpDir = os.tmpdir();
|
||||
return path.join(tmpDir, `openchamber-${port}.pid`);
|
||||
}
|
||||
|
||||
function readPidFile(pidFilePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
|
||||
const pid = parseInt(content);
|
||||
if (isNaN(pid)) {
|
||||
return null;
|
||||
}
|
||||
return pid;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePidFile(pidFilePath, pid) {
|
||||
try {
|
||||
fs.writeFileSync(pidFilePath, pid.toString());
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not write PID file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile(pidFilePath) {
|
||||
try {
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not remove PID file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessRunning(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const commands = {
|
||||
async serve(options) {
|
||||
const pidFilePath = await getPidFilePath(options.port);
|
||||
|
||||
const existingPid = readPidFile(pidFilePath);
|
||||
if (existingPid && isProcessRunning(existingPid)) {
|
||||
console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`);
|
||||
console.error('Use "openchamber stop" to stop the existing instance');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opencodeBinary = await checkOpenCodeCLI();
|
||||
|
||||
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
||||
|
||||
const serverArgs = [serverPath, '--port', options.port.toString()];
|
||||
if (typeof options.uiPassword === 'string') {
|
||||
serverArgs.push('--ui-password', options.uiPassword);
|
||||
}
|
||||
|
||||
if (options.daemon) {
|
||||
|
||||
const child = spawn(process.execPath, serverArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCHAMBER_PORT: options.port.toString(),
|
||||
OPENCODE_BINARY: opencodeBinary,
|
||||
...(typeof options.uiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: options.uiPassword } : {})
|
||||
}
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
setTimeout(() => {
|
||||
if (isProcessRunning(child.pid)) {
|
||||
writePidFile(pidFilePath, child.pid);
|
||||
console.log(`OpenChamber started in daemon mode on port ${options.port}`);
|
||||
console.log(`PID: ${child.pid}`);
|
||||
console.log(`Visit: http://localhost:${options.port}`);
|
||||
} else {
|
||||
console.error('Failed to start server in daemon mode');
|
||||
process.exit(1);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
|
||||
process.env.OPENCODE_BINARY = opencodeBinary;
|
||||
if (typeof options.uiPassword === 'string') {
|
||||
process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword;
|
||||
}
|
||||
const { startWebUiServer } = await import(serverPath);
|
||||
await startWebUiServer({
|
||||
port: options.port,
|
||||
attachSignals: true,
|
||||
exitOnShutdown: true,
|
||||
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async stop(options) {
|
||||
const os = await import('os');
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
let runningInstances = [];
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(tmpDir);
|
||||
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
|
||||
|
||||
for (const file of pidFiles) {
|
||||
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
|
||||
if (!isNaN(port)) {
|
||||
const pidFilePath = path.join(tmpDir, file);
|
||||
const pid = readPidFile(pidFilePath);
|
||||
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
runningInstances.push({ port, pid, pidFilePath });
|
||||
} else {
|
||||
|
||||
removePidFile(pidFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
|
||||
if (runningInstances.length === 0) {
|
||||
console.log('No running OpenChamber instances found');
|
||||
return;
|
||||
}
|
||||
|
||||
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
||||
|
||||
if (portWasSpecified) {
|
||||
const targetInstance = runningInstances.find(inst => inst.port === options.port);
|
||||
|
||||
if (!targetInstance) {
|
||||
console.log(`No OpenChamber instance found running on port ${options.port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
||||
|
||||
try {
|
||||
process.kill(targetInstance.pid, 'SIGTERM');
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const checkShutdown = setInterval(() => {
|
||||
attempts++;
|
||||
if (!isProcessRunning(targetInstance.pid)) {
|
||||
clearInterval(checkShutdown);
|
||||
removePidFile(targetInstance.pidFilePath);
|
||||
console.log('OpenChamber stopped successfully');
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkShutdown);
|
||||
console.log('Force killing process...');
|
||||
process.kill(targetInstance.pid, 'SIGKILL');
|
||||
removePidFile(targetInstance.pidFilePath);
|
||||
console.log('OpenChamber force stopped');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error stopping process: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
|
||||
console.log(`Stopping all OpenChamber instances (${runningInstances.length} found)...`);
|
||||
|
||||
for (const instance of runningInstances) {
|
||||
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
||||
|
||||
try {
|
||||
process.kill(instance.pid, 'SIGTERM');
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const checkShutdown = setInterval(() => {
|
||||
attempts++;
|
||||
if (!isProcessRunning(instance.pid)) {
|
||||
clearInterval(checkShutdown);
|
||||
removePidFile(instance.pidFilePath);
|
||||
console.log(` Port ${instance.port} stopped successfully`);
|
||||
resolve(true);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkShutdown);
|
||||
console.log(` Force killing port ${instance.port}...`);
|
||||
try {
|
||||
process.kill(instance.pid, 'SIGKILL');
|
||||
removePidFile(instance.pidFilePath);
|
||||
console.log(` Port ${instance.port} force stopped`);
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
resolve(true);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(` Error stopping port ${instance.port}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nAll OpenChamber instances stopped');
|
||||
}
|
||||
},
|
||||
|
||||
async restart(options) {
|
||||
await commands.stop(options);
|
||||
await commands.serve(options);
|
||||
},
|
||||
|
||||
async status(options) {
|
||||
const os = await import('os');
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
let runningInstances = [];
|
||||
let stoppedInstances = [];
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(tmpDir);
|
||||
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
|
||||
|
||||
for (const file of pidFiles) {
|
||||
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
|
||||
if (!isNaN(port)) {
|
||||
const pidFilePath = path.join(tmpDir, file);
|
||||
const pid = readPidFile(pidFilePath);
|
||||
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
runningInstances.push({ port, pid, pidFilePath });
|
||||
} else {
|
||||
|
||||
removePidFile(pidFilePath);
|
||||
stoppedInstances.push({ port });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
|
||||
if (runningInstances.length === 0) {
|
||||
console.log('OpenChamber Status:');
|
||||
console.log(' Status: Stopped');
|
||||
if (stoppedInstances.length > 0) {
|
||||
console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('OpenChamber Status:');
|
||||
for (const [index, instance] of runningInstances.entries()) {
|
||||
if (runningInstances.length > 1) {
|
||||
console.log(`\nInstance ${index + 1}:`);
|
||||
}
|
||||
console.log(' Status: Running');
|
||||
console.log(` PID: ${instance.pid}`);
|
||||
console.log(` Port: ${instance.port}`);
|
||||
console.log(` Visit: http://localhost:${instance.port}`);
|
||||
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim();
|
||||
console.log(` Start Time: ${startTime}`);
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const { command, options } = parseArgs();
|
||||
|
||||
if (!commands[command]) {
|
||||
console.error(`Error: Unknown command '${command}'`);
|
||||
console.error('Use --help to see available commands');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await commands[command](options);
|
||||
} catch (error) {
|
||||
console.error(`Error executing command '${command}': ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main();
|
||||
|
||||
export { commands, parseArgs, getPidFilePath };
|
||||
@@ -0,0 +1,194 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/logo-dark.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo-light.svg" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
|
||||
<link rel="mask-icon" href="/logo-dark.svg" color="#edb449" />
|
||||
|
||||
<!-- Apple touch icon - PNG format required for iOS PWA support -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/apple-touch-icon-167x167.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
|
||||
|
||||
<!-- Web app manifest (data URL to avoid nginx auth issues) -->
|
||||
<script>
|
||||
const baseUrl = location.origin;
|
||||
const manifest = {
|
||||
"name": "OpenChamber - AI Coding Assistant",
|
||||
"short_name": "OpenChamber",
|
||||
"description": "Web interface companion for OpenCode AI coding agent",
|
||||
"start_url": baseUrl + "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#151313",
|
||||
"theme_color": "#edb449",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": baseUrl + "/logo-dark.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": baseUrl + "/favicon-16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": baseUrl + "/favicon-32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["developer", "tools", "productivity"],
|
||||
"lang": "en"
|
||||
};
|
||||
|
||||
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'});
|
||||
const manifestURL = URL.createObjectURL(manifestBlob);
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'manifest';
|
||||
link.href = manifestURL;
|
||||
document.head.appendChild(link);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var variant = localStorage.getItem('selectedThemeVariant');
|
||||
var useSystem = localStorage.getItem('useSystemTheme');
|
||||
if (!variant || (variant !== 'light' && variant !== 'dark')) {
|
||||
if (useSystem === null || useSystem === 'true') {
|
||||
variant = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
variant = 'dark';
|
||||
}
|
||||
}
|
||||
document.documentElement.setAttribute('data-splash-variant', variant === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply splash variant:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Theme color - Safari iOS 26+ prioritizes CSS background-color over this, but keep as fallback -->
|
||||
<meta name="theme-color" content="#151313" />
|
||||
<meta name="theme-color" content="#151313" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
<!-- iOS Safari PWA styling -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="OpenChamber" />
|
||||
|
||||
<title>OpenChamber - AI Coding Assistant</title>
|
||||
<meta name="description" content="Web interface companion for OpenCode AI coding agent" />
|
||||
<meta name="application-name" content="OpenChamber" />
|
||||
<meta name="apple-mobile-web-app-title" content="OpenChamber" />
|
||||
|
||||
<!-- Inline CSS for loading screen (before Tailwind loads) -->
|
||||
<style>
|
||||
:root {
|
||||
--splash-background: #151313;
|
||||
--splash-foreground: #cdccc3;
|
||||
--splash-rect: #4B4646;
|
||||
--splash-grad-stop1: #F8F8F8;
|
||||
--splash-grad-stop2: #DAD6D0;
|
||||
--splash-grad-stop3: #BAB4AF;
|
||||
--splash-stroke1: rgba(255, 255, 255, 0.08);
|
||||
--splash-stroke2: rgba(0, 0, 0, 0.15);
|
||||
--splash-stroke3: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
html[data-splash-variant='light'] {
|
||||
--splash-background: #F6F4EF;
|
||||
--splash-foreground: #453f37;
|
||||
--splash-rect: #CFCDCD;
|
||||
--splash-grad-stop1: #B3AEA6;
|
||||
--splash-grad-stop2: #928E86;
|
||||
--splash-grad-stop3: #6E6A63;
|
||||
--splash-stroke1: rgba(255, 255, 255, 0.22);
|
||||
--splash-stroke2: rgba(60, 56, 47, 0.25);
|
||||
--splash-stroke3: rgba(43, 39, 34, 0.4);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.loading-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
#initial-loading {
|
||||
background-color: var(--splash-background);
|
||||
color: var(--splash-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
transition: opacity 0.3s ease-out;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
#initial-loading.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-background text-foreground">
|
||||
<div id="root" class="h-full">
|
||||
<!-- Loading fallback while React initializes -->
|
||||
<div id="initial-loading">
|
||||
<div style="display: flex; align-items: center; justify-content: center;">
|
||||
<svg class="loading-pulse" width="96" height="96" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
||||
<defs>
|
||||
<linearGradient id="loadingGlyphGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--splash-grad-stop1)"/>
|
||||
<stop offset="55%" stop-color="var(--splash-grad-stop2)"/>
|
||||
<stop offset="100%" stop-color="var(--splash-grad-stop3)"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="loadingGlyphStroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--splash-stroke1)"/>
|
||||
<stop offset="45%" stop-color="var(--splash-stroke2)"/>
|
||||
<stop offset="100%" stop-color="var(--splash-stroke3)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8.75" y="31" width="17.5" height="20.5" fill="var(--splash-rect)" />
|
||||
<path d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="url(#loadingGlyphGradient)" stroke="url(#loadingGlyphStroke)" stroke-width="1.1" stroke-linejoin="round" />
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="url(#loadingGlyphGradient)" stroke="url(#loadingGlyphStroke)" stroke-width="1.1" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fallback: hide loading screen after 10 seconds if React fails to load
|
||||
setTimeout(function() {
|
||||
const loading = document.getElementById('initial-loading');
|
||||
if (loading) {
|
||||
console.warn('Loading screen timeout - forcing hide after 10s');
|
||||
loading.classList.add('fade-out');
|
||||
setTimeout(function() {
|
||||
loading.remove();
|
||||
}, 300);
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
|
||||
<!-- Polyfill for process before loading React -->
|
||||
<script>
|
||||
if (typeof process === 'undefined') {
|
||||
window.process = { env: {} };
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./server/index.js",
|
||||
"types": "./server/index.d.ts",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm run build:watch",
|
||||
"dev:server": "node server/index.js --port 3001",
|
||||
"dev:server:watch": "nodemon --watch server --ext js --exec \"node server/index.js --port 3001\"",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@opencode-ai/sdk": "^1.0.65",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"strip-json-comments": "^5.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"server",
|
||||
"bin",
|
||||
"public",
|
||||
"package.json",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 858 B |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 999 B |
|
After Width: | Height: | Size: 811 B |
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1,18 @@
|
||||
<svg width="180" height="180" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(8.75, 8.75) scale(0.75)">
|
||||
<!-- Letter O with white fill and thin black stroke -->
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
stroke-width="1.1"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Letter C with white fill and thin black stroke -->
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
stroke-width="1.1"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 689 B |
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 582 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="black"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="white"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "OpenChamber - AI Coding Companion",
|
||||
"short_name": "OpenChamber",
|
||||
"description": "OpenChamber desktop companion for the OpenCode AI coding agent",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#151313",
|
||||
"theme_color": "#edb449",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo-dark.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/logo-light.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["developer", "tools", "productivity"],
|
||||
"lang": "en"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,28 @@
|
||||
import type { Express } from "express";
|
||||
import type { Server } from "http";
|
||||
|
||||
export interface WebUiServerController {
|
||||
expressApp: Express;
|
||||
httpServer: Server;
|
||||
getPort: () => number | null;
|
||||
getOpenCodePort: () => number | null;
|
||||
isReady: () => boolean;
|
||||
restartOpenCode: () => Promise<void>;
|
||||
stop: (options?: { exitProcess?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartWebUiServerOptions {
|
||||
port?: number;
|
||||
attachSignals?: boolean;
|
||||
exitOnShutdown?: boolean;
|
||||
uiPassword?: string | null;
|
||||
}
|
||||
|
||||
export declare function startWebUiServer(
|
||||
options?: StartWebUiServerOptions
|
||||
): Promise<WebUiServerController>;
|
||||
|
||||
export declare function gracefulShutdown(options?: { exitProcess?: boolean }): Promise<void>;
|
||||
export declare function setupProxy(app: Express): void;
|
||||
export declare function restartOpenCode(): Promise<void>;
|
||||
export declare function parseArgs(argv?: string[]): { port: number; uiPassword: string | null };
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const STORAGE_DIR = path.join(os.homedir(), '.config', 'openchamber');
|
||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'git-identities.json');
|
||||
|
||||
function ensureStorageDir() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProfiles() {
|
||||
ensureStorageDir();
|
||||
|
||||
if (!fs.existsSync(STORAGE_FILE)) {
|
||||
return { profiles: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(STORAGE_FILE, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load git identity profiles:', error);
|
||||
return { profiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfiles(data) {
|
||||
ensureStorageDir();
|
||||
|
||||
try {
|
||||
fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save git identity profiles:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfiles() {
|
||||
const data = loadProfiles();
|
||||
return data.profiles || [];
|
||||
}
|
||||
|
||||
export function getProfile(id) {
|
||||
const profiles = getProfiles();
|
||||
return profiles.find(p => p.id === id) || null;
|
||||
}
|
||||
|
||||
export function createProfile(profileData) {
|
||||
const profiles = getProfiles();
|
||||
|
||||
if (profiles.some(p => p.id === profileData.id)) {
|
||||
throw new Error(`Profile with ID "${profileData.id}" already exists`);
|
||||
}
|
||||
|
||||
if (!profileData.id || !profileData.userName || !profileData.userEmail) {
|
||||
throw new Error('Profile must have id, userName, and userEmail');
|
||||
}
|
||||
|
||||
const newProfile = {
|
||||
id: profileData.id,
|
||||
name: profileData.name || profileData.userName,
|
||||
userName: profileData.userName,
|
||||
userEmail: profileData.userEmail,
|
||||
sshKey: profileData.sshKey || null,
|
||||
color: profileData.color || 'keyword',
|
||||
icon: profileData.icon || 'branch'
|
||||
};
|
||||
|
||||
profiles.push(newProfile);
|
||||
saveProfiles({ profiles });
|
||||
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
export function updateProfile(id, updates) {
|
||||
const profiles = getProfiles();
|
||||
const index = profiles.findIndex(p => p.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Profile with ID "${id}" not found`);
|
||||
}
|
||||
|
||||
profiles[index] = {
|
||||
...profiles[index],
|
||||
...updates,
|
||||
id: profiles[index].id
|
||||
};
|
||||
|
||||
saveProfiles({ profiles });
|
||||
return profiles[index];
|
||||
}
|
||||
|
||||
export function deleteProfile(id) {
|
||||
const profiles = getProfiles();
|
||||
const filteredProfiles = profiles.filter(p => p.id !== id);
|
||||
|
||||
if (filteredProfiles.length === profiles.length) {
|
||||
throw new Error(`Profile with ID "${id}" not found`);
|
||||
}
|
||||
|
||||
saveProfiles({ profiles: filteredProfiles });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
import simpleGit from 'simple-git';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
const fsp = fs.promises;
|
||||
|
||||
export async function isGitRepository(directory) {
|
||||
if (!directory || !fs.existsSync(directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gitDir = path.join(directory, '.git');
|
||||
return fs.existsSync(gitDir);
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory) {
|
||||
if (!directory || !fs.existsSync(directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gitDir = path.join(directory, '.git');
|
||||
if (!fs.existsSync(gitDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const infoDir = path.join(gitDir, 'info');
|
||||
const excludePath = path.join(infoDir, 'exclude');
|
||||
const entry = '/.openchamber/';
|
||||
|
||||
try {
|
||||
await fsp.mkdir(infoDir, { recursive: true });
|
||||
let contents = '';
|
||||
try {
|
||||
contents = await fsp.readFile(excludePath, 'utf8');
|
||||
} catch (readError) {
|
||||
if (readError && readError.code !== 'ENOENT') {
|
||||
throw readError;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = contents.split(/\r?\n/).map((line) => line.trim());
|
||||
if (!lines.includes(entry)) {
|
||||
const prefix = contents.length > 0 && !contents.endsWith('\n') ? '\n' : '';
|
||||
await fsp.appendFile(excludePath, `${prefix}${entry}\n`, 'utf8');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure .openchamber ignore:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGlobalIdentity() {
|
||||
const git = simpleGit();
|
||||
|
||||
try {
|
||||
const userName = await git.getConfig('user.name', 'global').catch(() => null);
|
||||
const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
|
||||
const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
|
||||
|
||||
return {
|
||||
userName: userName?.value || null,
|
||||
userEmail: userEmail?.value || null,
|
||||
sshCommand: sshCommand?.value || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get global Git identity:', error);
|
||||
return {
|
||||
userName: null,
|
||||
userEmail: null,
|
||||
sshCommand: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentIdentity(directory) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
|
||||
const userName = await git.getConfig('user.name', 'local').catch(() =>
|
||||
git.getConfig('user.name', 'global')
|
||||
);
|
||||
|
||||
const userEmail = await git.getConfig('user.email', 'local').catch(() =>
|
||||
git.getConfig('user.email', 'global')
|
||||
);
|
||||
|
||||
const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
|
||||
git.getConfig('core.sshCommand', 'global')
|
||||
);
|
||||
|
||||
return {
|
||||
userName: userName?.value || null,
|
||||
userEmail: userEmail?.value || null,
|
||||
sshCommand: sshCommand?.value || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get current Git identity:', error);
|
||||
return {
|
||||
userName: null,
|
||||
userEmail: null,
|
||||
sshCommand: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLocalIdentity(directory, profile) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
|
||||
await git.addConfig('user.name', profile.userName, false, 'local');
|
||||
await git.addConfig('user.email', profile.userEmail, false, 'local');
|
||||
|
||||
if (profile.sshKey) {
|
||||
await git.addConfig(
|
||||
'core.sshCommand',
|
||||
`ssh -i ${profile.sshKey}`,
|
||||
false,
|
||||
'local'
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to set Git identity:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatus(directory) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const status = await git.status();
|
||||
|
||||
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
|
||||
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
|
||||
git.raw(['diff', '--numstat']).catch(() => ''),
|
||||
]);
|
||||
|
||||
const diffStatsMap = new Map();
|
||||
|
||||
const accumulateStats = (raw) => {
|
||||
if (!raw) return;
|
||||
raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) {
|
||||
return;
|
||||
}
|
||||
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||
const path = pathParts.join('\t');
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
||||
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
||||
|
||||
const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
|
||||
diffStatsMap.set(path, {
|
||||
insertions: existing.insertions + insertions,
|
||||
deletions: existing.deletions + deletions,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
accumulateStats(stagedStatsRaw);
|
||||
accumulateStats(workingStatsRaw);
|
||||
|
||||
const diffStats = Object.fromEntries(diffStatsMap.entries());
|
||||
|
||||
const newFileStats = await Promise.all(
|
||||
status.files.map(async (file) => {
|
||||
const working = (file.working_dir || '').trim();
|
||||
const indexStatus = (file.index || '').trim();
|
||||
const statusCode = working || indexStatus;
|
||||
|
||||
if (statusCode !== '?' && statusCode !== 'A') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = diffStats[file.path];
|
||||
if (existing && existing.insertions > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absolutePath = path.join(directory, file.path);
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(absolutePath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await fsp.readFile(absolutePath);
|
||||
if (buffer.indexOf(0) !== -1) {
|
||||
return {
|
||||
path: file.path,
|
||||
insertions: existing?.insertions ?? 0,
|
||||
deletions: existing?.deletions ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
|
||||
if (!normalized.length) {
|
||||
return {
|
||||
path: file.path,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const segments = normalized.split('\n');
|
||||
if (normalized.endsWith('\n')) {
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
const lineCount = segments.length;
|
||||
return {
|
||||
path: file.path,
|
||||
insertions: lineCount,
|
||||
deletions: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to estimate diff stats for new file', file.path, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const entry of newFileStats) {
|
||||
if (!entry) continue;
|
||||
diffStats[entry.path] = {
|
||||
insertions: entry.insertions,
|
||||
deletions: entry.deletions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
current: status.current,
|
||||
tracking: status.tracking,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
files: status.files.map(f => ({
|
||||
path: f.path,
|
||||
index: f.index,
|
||||
working_dir: f.working_dir
|
||||
})),
|
||||
isClean: status.isClean(),
|
||||
diffStats,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const args = ['diff', '--no-color'];
|
||||
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
|
||||
if (staged) {
|
||||
args.push('--cached');
|
||||
}
|
||||
|
||||
if (path) {
|
||||
args.push('--', path);
|
||||
}
|
||||
|
||||
const diff = await git.raw(args);
|
||||
if (diff && diff.trim().length > 0) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
if (staged) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
try {
|
||||
await git.raw(['ls-files', '--error-unmatch', path]);
|
||||
return diff;
|
||||
} catch {
|
||||
const noIndexArgs = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
noIndexArgs.push('--no-index', '--', '/dev/null', path);
|
||||
const noIndexDiff = await git.raw(noIndexArgs);
|
||||
return noIndexDiff;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git diff:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
|
||||
if (!directory || !filePath) {
|
||||
throw new Error('directory and path are required for getFileDiff');
|
||||
}
|
||||
|
||||
const git = simpleGit(directory);
|
||||
|
||||
let original = '';
|
||||
try {
|
||||
original = await git.show([`HEAD:${filePath}`]);
|
||||
} catch {
|
||||
|
||||
original = '';
|
||||
}
|
||||
|
||||
const fullPath = path.join(directory, filePath);
|
||||
let modified = '';
|
||||
try {
|
||||
const stat = await fsp.stat(fullPath);
|
||||
if (stat.isFile()) {
|
||||
modified = await fsp.readFile(fullPath, 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
|
||||
modified = '';
|
||||
} else {
|
||||
console.error('Failed to read modified file contents for diff:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original,
|
||||
modified,
|
||||
path: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revertFile(directory, filePath) {
|
||||
const git = simpleGit(directory);
|
||||
const repoRoot = path.resolve(directory);
|
||||
const absoluteTarget = path.resolve(repoRoot, filePath);
|
||||
|
||||
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
const isTracked = await git
|
||||
.raw(['ls-files', '--error-unmatch', filePath])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!isTracked) {
|
||||
try {
|
||||
await git.raw(['clean', '-f', '-d', '--', filePath]);
|
||||
return;
|
||||
} catch (cleanError) {
|
||||
try {
|
||||
await fsp.rm(absoluteTarget, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (fsError) {
|
||||
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to remove untracked file during revert:', fsError);
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await git.raw(['restore', '--staged', filePath]);
|
||||
} catch (error) {
|
||||
await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
await git.raw(['restore', filePath]);
|
||||
} catch (error) {
|
||||
try {
|
||||
await git.raw(['checkout', '--', filePath]);
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to revert git file:', fallbackError);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDiffs(directory, files = []) {
|
||||
const results = [];
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const diff = await getDiff(directory, { path: filePath });
|
||||
if (diff && diff.trim().length > 0) {
|
||||
results.push({ path: filePath, diff });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to diff ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function pull(directory, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const result = await git.pull(
|
||||
options.remote || 'origin',
|
||||
options.branch,
|
||||
options.options || {}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
summary: result.summary,
|
||||
files: result.files,
|
||||
insertions: result.insertions,
|
||||
deletions: result.deletions
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to pull:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function push(directory, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const result = await git.push(
|
||||
options.remote || 'origin',
|
||||
options.branch,
|
||||
options.options || {}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pushed: result.pushed,
|
||||
repo: result.repo,
|
||||
ref: result.ref
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to push:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRemoteBranch(directory, options = {}) {
|
||||
const { branch, remote } = options;
|
||||
if (!branch) {
|
||||
throw new Error('branch is required to delete remote branch');
|
||||
}
|
||||
|
||||
const git = simpleGit(directory);
|
||||
const targetBranch = branch.startsWith('refs/heads/')
|
||||
? branch.substring('refs/heads/'.length)
|
||||
: branch;
|
||||
const remoteName = remote || 'origin';
|
||||
|
||||
try {
|
||||
await git.push(remoteName, `:${targetBranch}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete remote branch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetch(directory, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
await git.fetch(
|
||||
options.remote || 'origin',
|
||||
options.branch,
|
||||
options.options || {}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function commit(directory, message, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
|
||||
if (options.addAll) {
|
||||
await git.add('.');
|
||||
} else if (Array.isArray(options.files) && options.files.length > 0) {
|
||||
await git.add(options.files);
|
||||
}
|
||||
|
||||
const commitArgs =
|
||||
!options.addAll && Array.isArray(options.files) && options.files.length > 0
|
||||
? options.files
|
||||
: undefined;
|
||||
|
||||
const result = await git.commit(message, commitArgs);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commit: result.commit,
|
||||
branch: result.branch,
|
||||
summary: result.summary
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to commit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBranches(directory) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const result = await git.branch();
|
||||
|
||||
const allBranches = result.all;
|
||||
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
|
||||
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
|
||||
|
||||
const filteredAll = [
|
||||
...allBranches.filter(branch => !branch.startsWith('remotes/')),
|
||||
...activeRemoteBranches
|
||||
];
|
||||
|
||||
return {
|
||||
all: filteredAll,
|
||||
current: result.current,
|
||||
branches: result.branches
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get branches:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function filterActiveRemoteBranches(git, remoteBranches) {
|
||||
try {
|
||||
|
||||
const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']);
|
||||
const actualRemoteBranches = new Set();
|
||||
|
||||
const lines = lsRemoteResult.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes('\trefs/heads/')) {
|
||||
const branchName = line.split('\t')[1].replace('refs/heads/', '');
|
||||
actualRemoteBranches.add(branchName);
|
||||
}
|
||||
}
|
||||
|
||||
return remoteBranches.filter(remoteBranch => {
|
||||
|
||||
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const branchName = match[1];
|
||||
return actualRemoteBranches.has(branchName);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to filter active remote branches, returning all:', error.message);
|
||||
return remoteBranches;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBranch(directory, branchName, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
|
||||
return { success: true, branch: branchName };
|
||||
} catch (error) {
|
||||
console.error('Failed to create branch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkoutBranch(directory, branchName) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
await git.checkout(branchName);
|
||||
return { success: true, branch: branchName };
|
||||
} catch (error) {
|
||||
console.error('Failed to checkout branch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorktrees(directory) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const result = await git.raw(['worktree', 'list', '--porcelain']);
|
||||
|
||||
const worktrees = [];
|
||||
const lines = result.split('\n');
|
||||
let current = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current.worktree) {
|
||||
worktrees.push(current);
|
||||
}
|
||||
current = { worktree: line.substring(9) };
|
||||
} else if (line.startsWith('HEAD ')) {
|
||||
current.head = line.substring(5);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.substring(7);
|
||||
} else if (line === '') {
|
||||
if (current.worktree) {
|
||||
worktrees.push(current);
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current.worktree) {
|
||||
worktrees.push(current);
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch (error) {
|
||||
console.error('Failed to list worktrees:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addWorktree(directory, worktreePath, branch, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const args = ['worktree', 'add'];
|
||||
|
||||
if (options.createBranch) {
|
||||
args.push('-b', branch);
|
||||
}
|
||||
|
||||
args.push(worktreePath);
|
||||
|
||||
if (!options.createBranch) {
|
||||
args.push(branch);
|
||||
}
|
||||
|
||||
await git.raw(args);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: worktreePath,
|
||||
branch
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to add worktree:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeWorktree(directory, worktreePath, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const args = ['worktree', 'remove', worktreePath];
|
||||
|
||||
if (options.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
await git.raw(args);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to remove worktree:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBranch(directory, branch, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const branchName = branch.startsWith('refs/heads/')
|
||||
? branch.substring('refs/heads/'.length)
|
||||
: branch;
|
||||
const args = ['branch', options.force ? '-D' : '-d', branchName];
|
||||
await git.raw(args);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete branch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLog(directory, options = {}) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
const maxCount = options.maxCount || 50;
|
||||
const baseLog = await git.log({
|
||||
maxCount,
|
||||
from: options.from,
|
||||
to: options.to,
|
||||
file: options.file
|
||||
});
|
||||
|
||||
const logArgs = [
|
||||
'log',
|
||||
`--max-count=${maxCount}`,
|
||||
'--date=iso',
|
||||
'--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
|
||||
'--shortstat'
|
||||
];
|
||||
|
||||
if (options.from && options.to) {
|
||||
logArgs.push(`${options.from}..${options.to}`);
|
||||
} else if (options.from) {
|
||||
logArgs.push(`${options.from}..HEAD`);
|
||||
} else if (options.to) {
|
||||
logArgs.push(options.to);
|
||||
}
|
||||
|
||||
if (options.file) {
|
||||
logArgs.push('--', options.file);
|
||||
}
|
||||
|
||||
const rawLog = await git.raw(logArgs);
|
||||
const records = rawLog
|
||||
.split('\x1e')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const statsMap = new Map();
|
||||
|
||||
records.forEach((record) => {
|
||||
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
||||
const header = lines.shift() || '';
|
||||
const [hash] = header.split('\x1f');
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
let filesChanged = 0;
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
||||
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
||||
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
||||
|
||||
if (filesMatch) {
|
||||
filesChanged = parseInt(filesMatch[1], 10);
|
||||
}
|
||||
if (insertMatch) {
|
||||
insertions = parseInt(insertMatch[1], 10);
|
||||
}
|
||||
if (deleteMatch) {
|
||||
deletions = parseInt(deleteMatch[1], 10);
|
||||
}
|
||||
});
|
||||
|
||||
statsMap.set(hash, { filesChanged, insertions, deletions });
|
||||
});
|
||||
|
||||
const merged = baseLog.all.map((entry) => {
|
||||
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
|
||||
return {
|
||||
hash: entry.hash,
|
||||
date: entry.date,
|
||||
message: entry.message,
|
||||
refs: entry.refs || '',
|
||||
body: entry.body || '',
|
||||
author_name: entry.author_name,
|
||||
author_email: entry.author_email,
|
||||
filesChanged: stats.filesChanged,
|
||||
insertions: stats.insertions,
|
||||
deletions: stats.deletions
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
all: merged,
|
||||
latest: merged[0] || null,
|
||||
total: baseLog.total
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get log:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory) {
|
||||
const git = simpleGit(directory);
|
||||
try {
|
||||
const [gitDir, gitCommonDir] = await Promise.all([
|
||||
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
|
||||
git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
|
||||
]);
|
||||
return gitDir !== gitCommonDir;
|
||||
} catch (error) {
|
||||
console.error('Failed to determine worktree type:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCommitFiles(directory, commitHash) {
|
||||
const git = simpleGit(directory);
|
||||
|
||||
try {
|
||||
|
||||
const numstatRaw = await git.raw([
|
||||
'show',
|
||||
'--numstat',
|
||||
'--format=',
|
||||
commitHash
|
||||
]);
|
||||
|
||||
const files = [];
|
||||
const lines = numstatRaw.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||
const filePath = pathParts.join('\t');
|
||||
if (!filePath) continue;
|
||||
|
||||
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
||||
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
||||
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
||||
|
||||
let changeType = 'M';
|
||||
let displayPath = filePath;
|
||||
|
||||
if (filePath.includes(' => ')) {
|
||||
changeType = 'R';
|
||||
|
||||
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
|
||||
if (match) {
|
||||
displayPath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: displayPath,
|
||||
insertions,
|
||||
deletions,
|
||||
isBinary,
|
||||
changeType
|
||||
});
|
||||
}
|
||||
|
||||
const nameStatusRaw = await git.raw([
|
||||
'show',
|
||||
'--name-status',
|
||||
'--format=',
|
||||
commitHash
|
||||
]).catch(() => '');
|
||||
|
||||
const statusMap = new Map();
|
||||
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
|
||||
for (const line of statusLines) {
|
||||
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
||||
if (match) {
|
||||
const [, status, path] = match;
|
||||
statusMap.set(path, status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const basePath = file.path.includes(' => ')
|
||||
? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
|
||||
: file.path;
|
||||
|
||||
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
||||
if (status) {
|
||||
file.changeType = status;
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
} catch (error) {
|
||||
console.error('Failed to get commit files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import yaml from 'yaml';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||
|
||||
function ensureDirs() {
|
||||
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
||||
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(AGENT_DIR)) {
|
||||
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(COMMAND_DIR)) {
|
||||
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isPromptFileReference(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return PROMPT_FILE_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
function resolvePromptFilePath(reference) {
|
||||
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
let target = match[1].trim();
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.startsWith('./')) {
|
||||
target = target.slice(2);
|
||||
target = path.join(OPENCODE_CONFIG_DIR, target);
|
||||
} else if (!path.isAbsolute(target)) {
|
||||
target = path.join(OPENCODE_CONFIG_DIR, target);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function writePromptFile(filePath, content) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, content ?? '', 'utf8');
|
||||
console.log(`Updated prompt file: ${filePath}`);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
const normalized = stripJsonComments(content).trim();
|
||||
if (!normalized) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(normalized);
|
||||
} catch (error) {
|
||||
console.error('Failed to read config file:', error);
|
||||
throw new Error('Failed to read OpenCode configuration');
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(config) {
|
||||
try {
|
||||
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const backupFile = `${CONFIG_FILE}.openchamber.backup`;
|
||||
fs.copyFileSync(CONFIG_FILE, backupFile);
|
||||
console.log(`Created config backup: ${backupFile}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
console.log('Successfully wrote config file');
|
||||
} catch (error) {
|
||||
console.error('Failed to write config file:', error);
|
||||
throw new Error('Failed to write OpenCode configuration');
|
||||
}
|
||||
}
|
||||
|
||||
function parseMdFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content.trim() };
|
||||
}
|
||||
|
||||
const frontmatter = yaml.parse(match[1]) || {};
|
||||
const body = match[2].trim();
|
||||
|
||||
return { frontmatter, body };
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse markdown file ${filePath}:`, error);
|
||||
throw new Error('Failed to parse agent markdown file');
|
||||
}
|
||||
}
|
||||
|
||||
function writeMdFile(filePath, frontmatter, body) {
|
||||
try {
|
||||
const yamlStr = yaml.stringify(frontmatter);
|
||||
const content = `---\n${yamlStr}---\n\n${body}`;
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
console.log(`Successfully wrote markdown file: ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write markdown file ${filePath}:`, error);
|
||||
throw new Error('Failed to write agent markdown file');
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentSources(agentName) {
|
||||
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
const mdExists = fs.existsSync(mdPath);
|
||||
|
||||
const config = readConfig();
|
||||
const jsonSection = config.agent?.[agentName];
|
||||
|
||||
const sources = {
|
||||
md: {
|
||||
exists: mdExists,
|
||||
path: mdExists ? mdPath : null,
|
||||
fields: []
|
||||
},
|
||||
json: {
|
||||
exists: !!jsonSection,
|
||||
path: CONFIG_FILE,
|
||||
fields: []
|
||||
}
|
||||
};
|
||||
|
||||
if (mdExists) {
|
||||
const { frontmatter, body } = parseMdFile(mdPath);
|
||||
sources.md.fields = Object.keys(frontmatter);
|
||||
if (body) {
|
||||
sources.md.fields.push('prompt');
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonSection) {
|
||||
sources.json.fields = Object.keys(jsonSection);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function createAgent(agentName, config) {
|
||||
ensureDirs();
|
||||
|
||||
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
throw new Error(`Agent ${agentName} already exists as .md file`);
|
||||
}
|
||||
|
||||
const existingConfig = readConfig();
|
||||
if (existingConfig.agent?.[agentName]) {
|
||||
throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
||||
}
|
||||
|
||||
const { prompt, ...frontmatter } = config;
|
||||
|
||||
writeMdFile(mdPath, frontmatter, prompt || '');
|
||||
console.log(`Created new agent: ${agentName}`);
|
||||
}
|
||||
|
||||
function updateAgent(agentName, updates) {
|
||||
ensureDirs();
|
||||
|
||||
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
const mdExists = fs.existsSync(mdPath);
|
||||
|
||||
let mdData = mdExists ? parseMdFile(mdPath) : null;
|
||||
let config = readConfig();
|
||||
const jsonSection = config.agent?.[agentName];
|
||||
|
||||
let mdModified = false;
|
||||
let jsonModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
|
||||
if (field === 'prompt') {
|
||||
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||
|
||||
if (mdExists) {
|
||||
mdData.body = normalizedValue;
|
||||
mdModified = true;
|
||||
} else if (isPromptFileReference(jsonSection?.prompt)) {
|
||||
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
|
||||
if (!promptFilePath) {
|
||||
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
|
||||
}
|
||||
writePromptFile(promptFilePath, normalizedValue);
|
||||
} else if (isPromptFileReference(normalizedValue)) {
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName].prompt = normalizedValue;
|
||||
jsonModified = true;
|
||||
} else {
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName].prompt = normalizedValue;
|
||||
jsonModified = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||
const inJson = jsonSection?.[field] !== undefined;
|
||||
|
||||
if (inMd) {
|
||||
|
||||
mdData.frontmatter[field] = value;
|
||||
mdModified = true;
|
||||
} else if (inJson) {
|
||||
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName][field] = value;
|
||||
jsonModified = true;
|
||||
} else {
|
||||
|
||||
if (mdExists && jsonSection) {
|
||||
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName][field] = value;
|
||||
jsonModified = true;
|
||||
} else if (mdExists) {
|
||||
|
||||
mdData.frontmatter[field] = value;
|
||||
mdModified = true;
|
||||
} else {
|
||||
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName][field] = value;
|
||||
jsonModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mdModified) {
|
||||
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
||||
}
|
||||
|
||||
if (jsonModified) {
|
||||
writeConfig(config);
|
||||
}
|
||||
|
||||
console.log(`Updated agent: ${agentName} (md: ${mdModified}, json: ${jsonModified})`);
|
||||
}
|
||||
|
||||
function deleteAgent(agentName) {
|
||||
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||
let deleted = false;
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
fs.unlinkSync(mdPath);
|
||||
console.log(`Deleted agent .md file: ${mdPath}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (config.agent?.[agentName]) {
|
||||
delete config.agent[agentName];
|
||||
writeConfig(config);
|
||||
console.log(`Removed agent from opencode.json: ${agentName}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
if (!config.agent) config.agent = {};
|
||||
config.agent[agentName] = { disable: true };
|
||||
writeConfig(config);
|
||||
console.log(`Disabled built-in agent: ${agentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandSources(commandName) {
|
||||
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
||||
const mdExists = fs.existsSync(mdPath);
|
||||
|
||||
const config = readConfig();
|
||||
const jsonSection = config.command?.[commandName];
|
||||
|
||||
const sources = {
|
||||
md: {
|
||||
exists: mdExists,
|
||||
path: mdExists ? mdPath : null,
|
||||
fields: []
|
||||
},
|
||||
json: {
|
||||
exists: !!jsonSection,
|
||||
path: CONFIG_FILE,
|
||||
fields: []
|
||||
}
|
||||
};
|
||||
|
||||
if (mdExists) {
|
||||
const { frontmatter, body } = parseMdFile(mdPath);
|
||||
sources.md.fields = Object.keys(frontmatter);
|
||||
if (body) {
|
||||
sources.md.fields.push('template');
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonSection) {
|
||||
sources.json.fields = Object.keys(jsonSection);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function createCommand(commandName, config) {
|
||||
ensureDirs();
|
||||
|
||||
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
throw new Error(`Command ${commandName} already exists as .md file`);
|
||||
}
|
||||
|
||||
const existingConfig = readConfig();
|
||||
if (existingConfig.command?.[commandName]) {
|
||||
throw new Error(`Command ${commandName} already exists in opencode.json`);
|
||||
}
|
||||
|
||||
const { template, ...frontmatter } = config;
|
||||
|
||||
writeMdFile(mdPath, frontmatter, template || '');
|
||||
console.log(`Created new command: ${commandName}`);
|
||||
}
|
||||
|
||||
function updateCommand(commandName, updates) {
|
||||
ensureDirs();
|
||||
|
||||
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
||||
const mdExists = fs.existsSync(mdPath);
|
||||
|
||||
let mdData = mdExists ? parseMdFile(mdPath) : null;
|
||||
let config = readConfig();
|
||||
const jsonSection = config.command?.[commandName];
|
||||
|
||||
let mdModified = false;
|
||||
let jsonModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
|
||||
if (field === 'template') {
|
||||
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||
|
||||
if (mdExists) {
|
||||
mdData.body = normalizedValue;
|
||||
mdModified = true;
|
||||
} else if (isPromptFileReference(jsonSection?.template)) {
|
||||
const templateFilePath = resolvePromptFilePath(jsonSection.template);
|
||||
if (!templateFilePath) {
|
||||
throw new Error(`Invalid template file reference for command ${commandName}`);
|
||||
}
|
||||
writePromptFile(templateFilePath, normalizedValue);
|
||||
} else if (isPromptFileReference(normalizedValue)) {
|
||||
if (!config.command) config.command = {};
|
||||
if (!config.command[commandName]) config.command[commandName] = {};
|
||||
config.command[commandName].template = normalizedValue;
|
||||
jsonModified = true;
|
||||
} else {
|
||||
if (!config.command) config.command = {};
|
||||
if (!config.command[commandName]) config.command[commandName] = {};
|
||||
config.command[commandName].template = normalizedValue;
|
||||
jsonModified = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||
const inJson = jsonSection?.[field] !== undefined;
|
||||
|
||||
if (inMd) {
|
||||
|
||||
mdData.frontmatter[field] = value;
|
||||
mdModified = true;
|
||||
} else if (inJson) {
|
||||
|
||||
if (!config.command) config.command = {};
|
||||
if (!config.command[commandName]) config.command[commandName] = {};
|
||||
config.command[commandName][field] = value;
|
||||
jsonModified = true;
|
||||
} else {
|
||||
|
||||
if (mdExists && jsonSection) {
|
||||
|
||||
if (!config.command) config.command = {};
|
||||
if (!config.command[commandName]) config.command[commandName] = {};
|
||||
config.command[commandName][field] = value;
|
||||
jsonModified = true;
|
||||
} else if (mdExists) {
|
||||
|
||||
mdData.frontmatter[field] = value;
|
||||
mdModified = true;
|
||||
} else {
|
||||
|
||||
if (!config.command) config.command = {};
|
||||
if (!config.command[commandName]) config.command[commandName] = {};
|
||||
config.command[commandName][field] = value;
|
||||
jsonModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mdModified) {
|
||||
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
||||
}
|
||||
|
||||
if (jsonModified) {
|
||||
writeConfig(config);
|
||||
}
|
||||
|
||||
console.log(`Updated command: ${commandName} (md: ${mdModified}, json: ${jsonModified})`);
|
||||
}
|
||||
|
||||
function deleteCommand(commandName) {
|
||||
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
||||
let deleted = false;
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
fs.unlinkSync(mdPath);
|
||||
console.log(`Deleted command .md file: ${mdPath}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (config.command?.[commandName]) {
|
||||
delete config.command[commandName];
|
||||
writeConfig(config);
|
||||
console.log(`Removed command from opencode.json: ${commandName}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Command "${commandName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getAgentSources,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
AGENT_DIR,
|
||||
COMMAND_DIR,
|
||||
CONFIG_FILE
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
declare module "../server/lib/opencode-config.js" {
|
||||
export function getAgentSources(agentName: string): {
|
||||
md: { exists: boolean; path: string | null; fields: string[] };
|
||||
json: { exists: boolean; path: string | null; fields: string[] };
|
||||
};
|
||||
|
||||
export function createAgent(agentName: string, config: Record<string, unknown>): void;
|
||||
|
||||
export function updateAgent(agentName: string, updates: Record<string, unknown>): void;
|
||||
|
||||
export function deleteAgent(agentName: string): void;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
const isSecureRequest = (req) => {
|
||||
if (req.secure) {
|
||||
return true;
|
||||
}
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
if (typeof forwardedProto === 'string') {
|
||||
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
||||
return firstProto === 'https';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseCookies = (cookieHeader) => {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cookieHeader.split(';').reduce((acc, segment) => {
|
||||
const [name, ...rest] = segment.split('=');
|
||||
if (!name) {
|
||||
return acc;
|
||||
}
|
||||
const key = name.trim();
|
||||
if (!key) {
|
||||
return acc;
|
||||
}
|
||||
const value = rest.join('=').trim();
|
||||
acc[key] = decodeURIComponent(value || '');
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const buildCookie = ({
|
||||
name,
|
||||
value,
|
||||
maxAge,
|
||||
secure,
|
||||
}) => {
|
||||
const attributes = [
|
||||
`${name}=${value}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
];
|
||||
|
||||
if (typeof maxAge === 'number') {
|
||||
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
||||
}
|
||||
|
||||
const expires = maxAge === 0
|
||||
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
||||
|
||||
attributes.push(`Expires=${expires}`);
|
||||
|
||||
if (secure) {
|
||||
attributes.push('Secure');
|
||||
}
|
||||
|
||||
return attributes.join('; ');
|
||||
};
|
||||
|
||||
const normalizePassword = (candidate) => {
|
||||
if (typeof candidate !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return candidate.normalize().trim();
|
||||
};
|
||||
|
||||
export const createUiAuth = ({
|
||||
password,
|
||||
cookieName = SESSION_COOKIE_NAME,
|
||||
sessionTtlMs = SESSION_TTL_MS,
|
||||
} = {}) => {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
|
||||
if (!normalizedPassword) {
|
||||
return {
|
||||
enabled: false,
|
||||
requireAuth: (_req, _res, next) => next(),
|
||||
handleSessionStatus: (_req, res) => {
|
||||
res.json({ authenticated: true, disabled: true });
|
||||
},
|
||||
handleSessionCreate: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
dispose: () => {
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16);
|
||||
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
||||
const sessions = new Map();
|
||||
|
||||
let cleanupTimer = null;
|
||||
|
||||
const getTokenFromRequest = (req) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (cookies[cookieName]) {
|
||||
return cookies[cookieName];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const dropSession = (token) => {
|
||||
if (token) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
};
|
||||
|
||||
const setSessionCookie = (req, res, token) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: encodeURIComponent(token),
|
||||
maxAge: maxAgeSeconds,
|
||||
secure,
|
||||
});
|
||||
res.setHeader('Set-Cookie', header);
|
||||
};
|
||||
|
||||
const clearSessionCookie = (req, res) => {
|
||||
const secure = isSecureRequest(req);
|
||||
const header = buildCookie({
|
||||
name: cookieName,
|
||||
value: '',
|
||||
maxAge: 0,
|
||||
secure,
|
||||
});
|
||||
res.setHeader('Set-Cookie', header);
|
||||
};
|
||||
|
||||
const verifyPassword = (candidate) => {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const normalizedCandidate = normalizePassword(candidate);
|
||||
if (!normalizedCandidate) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
|
||||
return crypto.timingSafeEqual(candidateHash, expectedHash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isSessionValid = (token) => {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const record = sessions.get(token);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
if (Date.now() - record.lastSeen > sessionTtlMs) {
|
||||
sessions.delete(token);
|
||||
return false;
|
||||
}
|
||||
record.lastSeen = Date.now();
|
||||
return true;
|
||||
};
|
||||
|
||||
const issueSession = (req, res) => {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const now = Date.now();
|
||||
sessions.set(token, { createdAt: now, lastSeen: now });
|
||||
setSessionCookie(req, res, token);
|
||||
return token;
|
||||
};
|
||||
|
||||
const cleanupStaleSessions = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, record] of sessions.entries()) {
|
||||
if (now - record.lastSeen > sessionTtlMs) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startCleanup = () => {
|
||||
if (!cleanupTimer) {
|
||||
cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||
if (cleanupTimer && typeof cleanupTimer.unref === 'function') {
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startCleanup();
|
||||
|
||||
const respondUnauthorized = (req, res) => {
|
||||
res.status(401);
|
||||
const acceptsJson = req.headers.accept?.includes('application/json');
|
||||
if (acceptsJson || req.path.startsWith('/api')) {
|
||||
res.json({ error: 'UI authentication required', locked: true });
|
||||
} else {
|
||||
res.type('text/plain').send('Authentication required');
|
||||
}
|
||||
};
|
||||
|
||||
const requireAuth = (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const token = getTokenFromRequest(req);
|
||||
if (isSessionValid(token)) {
|
||||
return next();
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
};
|
||||
|
||||
const handleSessionStatus = (req, res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (isSessionValid(token)) {
|
||||
res.json({ authenticated: true });
|
||||
return;
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ authenticated: false, locked: true });
|
||||
};
|
||||
|
||||
const handleSessionCreate = (req, res) => {
|
||||
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
|
||||
if (!verifyPassword(candidate)) {
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ error: 'Invalid password', locked: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const previousToken = getTokenFromRequest(req);
|
||||
if (previousToken) {
|
||||
dropSession(previousToken);
|
||||
}
|
||||
|
||||
issueSession(req, res);
|
||||
res.json({ authenticated: true });
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
sessions.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
requireAuth,
|
||||
handleSessionStatus,
|
||||
handleSessionCreate,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
export const createWebFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
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.error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
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.error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as gitApiHttp from '@openchamber/ui/lib/gitApiHttp';
|
||||
import type {
|
||||
GitAPI,
|
||||
CreateGitCommitOptions,
|
||||
GitLogOptions,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: gitApiHttp.checkIsGitRepository,
|
||||
getGitStatus: gitApiHttp.getGitStatus,
|
||||
getGitDiff: gitApiHttp.getGitDiff,
|
||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||
revertGitFile: gitApiHttp.revertGitFile,
|
||||
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
||||
getGitBranches: gitApiHttp.getGitBranches,
|
||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
|
||||
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
||||
listGitWorktrees: gitApiHttp.listGitWorktrees,
|
||||
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
|
||||
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
|
||||
ensureOpenChamberIgnored: gitApiHttp.ensureOpenChamberIgnored,
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
|
||||
return gitApiHttp.createGitCommit(directory, message, options);
|
||||
},
|
||||
gitPush: gitApiHttp.gitPush,
|
||||
gitPull: gitApiHttp.gitPull,
|
||||
gitFetch: gitApiHttp.gitFetch,
|
||||
checkoutBranch: gitApiHttp.checkoutBranch,
|
||||
createBranch: gitApiHttp.createBranch,
|
||||
getGitLog(directory: string, options?: GitLogOptions) {
|
||||
return gitApiHttp.getGitLog(directory, options);
|
||||
},
|
||||
getCommitFiles: gitApiHttp.getCommitFiles,
|
||||
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
||||
setGitIdentity: gitApiHttp.setGitIdentity,
|
||||
getGitIdentities: gitApiHttp.getGitIdentities,
|
||||
createGitIdentity: gitApiHttp.createGitIdentity,
|
||||
updateGitIdentity: gitApiHttp.updateGitIdentity,
|
||||
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import { createWebTerminalAPI } from './terminal';
|
||||
import { createWebGitAPI } from './git';
|
||||
import { createWebFilesAPI } from './files';
|
||||
import { createWebSettingsAPI } from './settings';
|
||||
import { createWebPermissionsAPI } from './permissions';
|
||||
import { createWebNotificationsAPI } from './notifications';
|
||||
import { createWebToolsAPI } from './tools';
|
||||
|
||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'web', isDesktop: false, label: 'web' },
|
||||
terminal: createWebTerminalAPI(),
|
||||
git: createWebGitAPI(),
|
||||
files: createWebFilesAPI(),
|
||||
settings: createWebSettingsAPI(),
|
||||
permissions: createWebPermissionsAPI(),
|
||||
notifications: createWebNotificationsAPI(),
|
||||
tools: createWebToolsAPI(),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean> => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
console.info('Notifications not supported in this environment', payload);
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new Notification(payload?.title ?? 'OpenChamber', {
|
||||
body: payload?.body,
|
||||
tag: payload?.tag,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send notification', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebNotificationsAPI = (): NotificationsAPI => ({
|
||||
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
||||
return notifyWithWebAPI(payload);
|
||||
},
|
||||
canNotify: () => (typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false),
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebPermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
|
||||
return { success: true, path: request.path };
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||
const RELOAD_ENDPOINT = '/api/config/reload';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createWebSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load settings: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return {
|
||||
settings: payload,
|
||||
source: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return payload;
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to restart OpenCode');
|
||||
}
|
||||
return { restarted: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
connectTerminalStream,
|
||||
createTerminalSession,
|
||||
resizeTerminal,
|
||||
sendTerminalInput,
|
||||
closeTerminal,
|
||||
} from '@openchamber/ui/lib/terminalApi';
|
||||
import type {
|
||||
TerminalAPI,
|
||||
TerminalHandlers,
|
||||
TerminalStreamOptions,
|
||||
CreateTerminalOptions,
|
||||
ResizeTerminalPayload,
|
||||
TerminalSession,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const getRetryPolicy = (options?: TerminalStreamOptions) => {
|
||||
const retry = options?.retry;
|
||||
return {
|
||||
maxRetries: retry?.maxRetries ?? 3,
|
||||
initialRetryDelay: retry?.initialDelayMs ?? 1000,
|
||||
maxRetryDelay: retry?.maxDelayMs ?? 8000,
|
||||
connectionTimeout: options?.connectionTimeoutMs ?? 10000,
|
||||
};
|
||||
};
|
||||
|
||||
export const createWebTerminalAPI = (): TerminalAPI => ({
|
||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
return createTerminalSession(options);
|
||||
},
|
||||
|
||||
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions) {
|
||||
const unsubscribe = connectTerminalStream(
|
||||
sessionId,
|
||||
handlers.onEvent,
|
||||
handlers.onError,
|
||||
getRetryPolicy(options)
|
||||
);
|
||||
|
||||
return {
|
||||
close: () => unsubscribe(),
|
||||
};
|
||||
},
|
||||
|
||||
async sendInput(sessionId: string, input: string): Promise<void> {
|
||||
await sendTerminalInput(sessionId, input);
|
||||
},
|
||||
|
||||
async resize(payload: ResizeTerminalPayload): Promise<void> {
|
||||
await resizeTerminal(payload.sessionId, payload.cols, payload.rows);
|
||||
},
|
||||
|
||||
async close(sessionId: string): Promise<void> {
|
||||
await closeTerminal(sessionId);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createWebAPIs } from './api';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import '@openchamber/ui/index.css';
|
||||
import '@openchamber/ui/styles/fonts';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||
import('@openchamber/ui/main');
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["../ui/src/*", "./src/*"],
|
||||
"@web/*": ["./src/*"],
|
||||
"@openchamber/ui/*": ["../ui/src/*"],
|
||||
"@openchamber/web/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { themeStoragePlugin } from '../../vite-theme-plugin';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: path.resolve(__dirname, '.'),
|
||||
plugins: [
|
||||
react(),
|
||||
themeStoragePlugin(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '../ui/src'),
|
||||
'@web': path.resolve(__dirname, './src'),
|
||||
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
|
||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
global: 'globalThis',
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@opencode-ai/sdk'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1200,
|
||||
rollupOptions: {
|
||||
external: ['node:child_process', 'node:fs', 'node:path', 'node:url'],
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined;
|
||||
|
||||
const match = id.split('node_modules/')[1];
|
||||
if (!match) return undefined;
|
||||
|
||||
const segments = match.split('/');
|
||||
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
|
||||
|
||||
if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react';
|
||||
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
|
||||
|
||||
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk';
|
||||
if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown';
|
||||
if (packageName.startsWith('@radix-ui')) return 'vendor-radix';
|
||||
if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax';
|
||||
|
||||
const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-');
|
||||
return `vendor-${sanitized}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||