Compare commits

..

No commits in common. "2ad727b4d4d24ce62b68fd52050455669a7cee68" and "8ce8bf72f71c5ea0add5de1fb6e336aaf84e2414" have entirely different histories.

7 changed files with 114 additions and 95 deletions

View file

@ -1,9 +0,0 @@
node_modules
dist
dist-server
.git
claude-data
cloudcli-data
workspace
*.log
.env*

View file

@ -6,30 +6,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq ripgrep sqlite3 zip unzip tree vim-tiny curl git \ jq ripgrep sqlite3 zip unzip tree vim-tiny curl git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Claude Code CLI and Taskmaster globally # Install Claude Code CLI globally
RUN npm install -g @anthropic-ai/claude-code task-master-ai RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app # Install CloudCLI (claudecodeui) globally
RUN npm install -g @cloudcli-ai/cloudcli
# Install dependencies (separate layer for cache efficiency) # Install Taskmaster MCP
COPY package*.json ./ RUN npm install -g task-master-ai
RUN npm ci
# VITE_ vars must be present at build time (baked into frontend bundle) # Create workspace and data dirs with correct ownership
ARG FORGEJO_BASE_URL=https://forge.wilddragon.net RUN mkdir -p /home/node/workspace /home/node/.cloudcli \
ENV VITE_FORGEJO_BASE_URL=${FORGEJO_BASE_URL} && chown -R node:node /home/node/
# Copy source and build
COPY . .
RUN npm run build
# Create persistent data dirs with correct ownership
RUN mkdir -p /home/node/workspace /home/node/.cloudcli /home/node/.claude \
&& chown -R node:node /home/node/ \
&& chown -R node:node /app
USER node USER node
WORKDIR /home/node
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist-server/server/index.js"] CMD ["cloudcli", "start", "--port", "3001"]

View file

@ -3,16 +3,17 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- FORGEJO_BASE_URL=https://forge.wilddragon.net
image: claudecodeui:local image: claudecodeui:local
container_name: claudecodeui container_name: claudecodeui
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3001:3001" - "3001:3001"
volumes: volumes:
# Claude Code credentials & config (persistent auth)
- /mnt/NVME/Docker/Claude/claudecodeui/claude-data:/home/node/.claude - /mnt/NVME/Docker/Claude/claudecodeui/claude-data:/home/node/.claude
# CloudCLI database (sessions, auth, history)
- /mnt/NVME/Docker/Claude/claudecodeui/cloudcli-data:/home/node/.cloudcli - /mnt/NVME/Docker/Claude/claudecodeui/cloudcli-data:/home/node/.cloudcli
# Workspace - project files you work on via the UI
- /mnt/NVME/Docker/Claude/claudecodeui/workspace:/home/node/workspace - /mnt/NVME/Docker/Claude/claudecodeui/workspace:/home/node/workspace
environment: environment:
- SERVER_PORT=3001 - SERVER_PORT=3001
@ -21,6 +22,6 @@ services:
- VITE_CONTEXT_WINDOW=160000 - VITE_CONTEXT_WINDOW=160000
- ANTHROPIC_BASE_URL=https://ollama.wilddragon.net/v1 - ANTHROPIC_BASE_URL=https://ollama.wilddragon.net/v1
- ANTHROPIC_API_KEY=sk-6610655970c8b144-4lhp46-ac73a34f - ANTHROPIC_API_KEY=sk-6610655970c8b144-4lhp46-ac73a34f
- FORGEJO_BASE_URL=https://forge.wilddragon.net # node-pty needs /dev/ptmx access for terminal emulation
devices: devices:
- /dev/ptmx:/dev/ptmx - /dev/ptmx:/dev/ptmx

View file

@ -9,7 +9,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js'; import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js'; import { spawnGemini } from '../gemini-cli.js';
const FORGEJO_BASE_URL = process.env.FORGEJO_BASE_URL || 'https://forge.wilddragon.net'; import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js'; import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js'; import { normalizeProjectPath } from '../shared/utils.js';
@ -99,28 +99,36 @@ async function getGitRemoteUrl(repoPath) {
} }
/** /**
* Normalize git URLs for comparison * Normalize GitHub URLs for comparison
* @param {string} url - Git URL (any host) * @param {string} url - GitHub URL
* @returns {string} - Normalized URL * @returns {string} - Normalized URL
*/ */
function normalizeGitUrl(url) { function normalizeGitHubUrl(url) {
// Remove .git suffix
let normalized = url.replace(/\.git$/, ''); let normalized = url.replace(/\.git$/, '');
normalized = normalized.replace(/^git@([^:]+):/, 'https://$1/'); // Convert SSH to HTTPS format for comparison
normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
// Remove trailing slash
normalized = normalized.replace(/\/$/, ''); normalized = normalized.replace(/\/$/, '');
return normalized.toLowerCase(); return normalized.toLowerCase();
} }
/** /**
* Parse git URL to extract owner and repo (works with any host) * Parse GitHub URL to extract owner and repo
* @param {string} url - Git URL (HTTPS or SSH, any host) * @param {string} url - GitHub URL (HTTPS or SSH)
* @returns {{owner: string, repo: string}} - Parsed owner and repo * @returns {{owner: string, repo: string}} - Parsed owner and repo
*/ */
function parseGitUrl(url) { function parseGitHubUrl(url) {
const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/); // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/); // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
const match = httpsMatch || sshMatch; const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (!match) { throw new Error('Invalid git URL format'); } if (!match) {
return { owner: match[1], repo: match[2] }; throw new Error('Invalid GitHub URL format');
}
return {
owner: match[1],
repo: match[2].replace(/\.git$/, '')
};
} }
/** /**
@ -249,11 +257,47 @@ async function getCommitMessages(projectPath, limit = 5) {
}); });
} }
/**
* Create a new branch on GitHub using the API
* @param {Octokit} octokit - Octokit instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} branchName - Name of the new branch
* @param {string} baseBranch - Base branch to branch from (default: 'main')
* @returns {Promise<void>}
*/
async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
try {
// Get the SHA of the base branch
const { data: ref } = await octokit.git.getRef({
owner,
repo,
ref: `heads/${baseBranch}`
});
const baseSha = ref.object.sha;
// Create the new branch
await octokit.git.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: baseSha
});
console.log(`✅ Created branch '${branchName}' on GitHub`);
} catch (error) {
if (error.status === 422 && error.message.includes('Reference already exists')) {
console.log(` Branch '${branchName}' already exists on GitHub`);
} else {
throw error;
}
}
}
/** /**
* Create a pull request on a Forgejo instance * Create a pull request on GitHub
* @param {string} baseUrl - Forgejo base URL * @param {Octokit} octokit - Octokit instance
* @param {string} token - Forgejo access token
* @param {string} owner - Repository owner * @param {string} owner - Repository owner
* @param {string} repo - Repository name * @param {string} repo - Repository name
* @param {string} branchName - Head branch name * @param {string} branchName - Head branch name
@ -262,24 +306,22 @@ async function getCommitMessages(projectPath, limit = 5) {
* @param {string} baseBranch - Base branch (default: 'main') * @param {string} baseBranch - Base branch (default: 'main')
* @returns {Promise<{number: number, url: string}>} - PR number and URL * @returns {Promise<{number: number, url: string}>} - PR number and URL
*/ */
async function createForgejoPR(baseUrl, token, owner, repo, branchName, title, body, baseBranch = 'main') { async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
const response = await fetch(`${baseUrl}/api/v1/repos/${owner}/${repo}/pulls`, { const { data: pr } = await octokit.pulls.create({
method: 'POST', owner,
headers: { repo,
'Authorization': `token ${token}`, title,
'Content-Type': 'application/json', head: branchName,
}, base: baseBranch,
body: JSON.stringify({ title, body, head: branchName, base: baseBranch }), body
}); });
if (!response.ok) { console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
const err = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Failed to create PR: ${err.message || response.statusText}`);
}
const pr = await response.json(); return {
console.log(`Created pull request #${pr.number}: ${pr.html_url}`); number: pr.number,
return { number: pr.number, url: pr.html_url }; url: pr.html_url
};
} }
/** /**
@ -289,12 +331,12 @@ async function createForgejoPR(baseUrl, token, owner, repo, branchName, title, b
* @param {string} projectPath - Path for cloning the repository * @param {string} projectPath - Path for cloning the repository
* @returns {Promise<string>} - Path to the cloned repository * @returns {Promise<string>} - Path to the cloned repository
*/ */
async function cloneGitRepo(repoUrl, gitToken = null, projectPath) { async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
// Validate GitHub URL // Validate GitHub URL
if (!repoUrl || !repoUrl.trim()) { if (!githubUrl || !githubUrl.includes('github.com')) {
throw new Error('Repository URL is required'); throw new Error('Invalid GitHub URL');
} }
const cloneDir = path.resolve(projectPath); const cloneDir = path.resolve(projectPath);
@ -305,14 +347,14 @@ async function cloneGitRepo(repoUrl, gitToken = null, projectPath) {
// Directory exists - check if it's a git repo with the same URL // Directory exists - check if it's a git repo with the same URL
try { try {
const existingUrl = await getGitRemoteUrl(cloneDir); const existingUrl = await getGitRemoteUrl(cloneDir);
const normalizedExisting = normalizeGitUrl(existingUrl); const normalizedExisting = normalizeGitHubUrl(existingUrl);
const normalizedRequested = normalizeGitUrl(repoUrl); const normalizedRequested = normalizeGitHubUrl(githubUrl);
if (normalizedExisting === normalizedRequested) { if (normalizedExisting === normalizedRequested) {
console.log('✅ Repository already exists at path with correct URL'); console.log('✅ Repository already exists at path with correct URL');
return resolve(cloneDir); return resolve(cloneDir);
} else { } else {
throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${repoUrl}`); throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
} }
} catch (gitError) { } catch (gitError) {
throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`); throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
@ -325,16 +367,11 @@ async function cloneGitRepo(repoUrl, gitToken = null, projectPath) {
await fs.mkdir(path.dirname(cloneDir), { recursive: true }); await fs.mkdir(path.dirname(cloneDir), { recursive: true });
// Prepare the git clone URL with authentication if token is provided // Prepare the git clone URL with authentication if token is provided
let cloneUrl = repoUrl; let cloneUrl = githubUrl;
if (gitToken) { if (githubToken) {
try { // Convert HTTPS URL to authenticated URL
const url = new URL(repoUrl); // Example: https://github.com/user/repo -> https://token@github.com/user/repo
url.username = gitToken; cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
url.password = '';
cloneUrl = url.toString();
} catch {
// SSH URLs used as-is
}
} }
console.log('🔄 Cloning repository:', githubUrl); console.log('🔄 Cloning repository:', githubUrl);
@ -850,7 +887,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash); targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
} }
finalProjectPath = await cloneGitRepo(githubUrl.trim(), tokenToUse, targetPath); finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
} else { } else {
// Use existing project path // Use existing project path
finalProjectPath = normalizeProjectPath(path.resolve(projectPath)); finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
@ -960,14 +997,17 @@ router.post('/', validateExternalApiKey, async (req, res) => {
throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.'); throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
} }
// Initialize Octokit
const octokit = new Octokit({ auth: tokenToUse });
// Get GitHub URL - either from parameter or from git remote // Get GitHub URL - either from parameter or from git remote
let repoUrl = githubUrl; let repoUrl = githubUrl;
if (!repoUrl) { if (!repoUrl) {
console.log('🔍 Getting GitHub URL from git remote...'); console.log('🔍 Getting GitHub URL from git remote...');
try { try {
repoUrl = await getGitRemoteUrl(finalProjectPath); repoUrl = await getGitRemoteUrl(finalProjectPath);
if (!repoUrl.trim()) { if (!repoUrl.includes('github.com')) {
throw new Error('Project does not have a git remote configured'); throw new Error('Project does not have a GitHub remote configured');
} }
console.log(`✅ Found GitHub remote: ${repoUrl}`); console.log(`✅ Found GitHub remote: ${repoUrl}`);
} catch (error) { } catch (error) {
@ -976,7 +1016,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
} }
// Parse GitHub URL to get owner and repo // Parse GitHub URL to get owner and repo
const { owner, repo } = parseGitUrl(repoUrl); const { owner, repo } = parseGitHubUrl(repoUrl);
console.log(`📦 Repository: ${owner}/${repo}`); console.log(`📦 Repository: ${owner}/${repo}`);
// Use provided branch name or auto-generate from message // Use provided branch name or auto-generate from message
@ -1061,7 +1101,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
branchInfo = { branchInfo = {
name: finalBranchName, name: finalBranchName,
url: `${FORGEJO_BASE_URL}/${owner}/${repo}/src/branch/${finalBranchName}` url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
}; };
} }
@ -1086,7 +1126,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
// Create the pull request // Create the pull request
console.log('🔄 Creating pull request...'); console.log('🔄 Creating pull request...');
prInfo = await createForgejoPR(FORGEJO_BASE_URL, tokenToUse, owner, repo, finalBranchName, prTitle, prBody, 'main'); prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
} }
// Send branch/PR info in response // Send branch/PR info in response

View file

@ -95,7 +95,7 @@ export default function GithubCredentialsSection({
</div> </div>
<a <a
href={`${import.meta.env.VITE_FORGEJO_BASE_URL || 'https://forge.wilddragon.net'}/user/settings/applications`} href="https://github.com/settings/tokens"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block text-xs text-primary hover:underline" className="block text-xs text-primary hover:underline"

View file

@ -38,11 +38,10 @@ export const useGitHubStars = (owner: string, repo: string) => {
const fetchStars = async () => { const fetchStars = async () => {
try { try {
const baseUrl = import.meta.env.VITE_FORGEJO_BASE_URL || 'https://forge.wilddragon.net'; const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
const response = await fetch(`${baseUrl}/api/v1/repos/${owner}/${repo}`);
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
const count = data.stars_count; const count = data.stargazers_count;
if (typeof count === 'number') { if (typeof count === 'number') {
setStarCount(count); setStarCount(count);
try { try {

View file

@ -47,12 +47,8 @@ export const useVersionCheck = (owner: string, repo: string) => {
useEffect(() => { useEffect(() => {
const checkVersion = async () => { const checkVersion = async () => {
try { try {
const baseUrl = import.meta.env.VITE_FORGEJO_BASE_URL || 'https://forge.wilddragon.net'; const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const response = await fetch(`${baseUrl}/api/v1/repos/${owner}/${repo}/releases?limit=1&page=1`); const data = await response.json();
if (!response.ok) return;
const releases = await response.json();
const data = releases[0];
if (!data) return;
// Handle the case where there might not be any releases // Handle the case where there might not be any releases
if (data.tag_name) { if (data.tag_name) {
@ -65,7 +61,7 @@ export const useVersionCheck = (owner: string, repo: string) => {
setReleaseInfo({ setReleaseInfo({
title: data.name || data.tag_name, title: data.name || data.tag_name,
body: data.body || '', body: data.body || '',
htmlUrl: data.html_url || `${import.meta.env.VITE_FORGEJO_BASE_URL || 'https://forge.wilddragon.net'}/${owner}/${repo}/releases`, htmlUrl: data.html_url || `https://github.com/${owner}/${repo}/releases/latest`,
publishedAt: data.published_at publishedAt: data.published_at
}); });
} else { } else {