mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 12:25:35 +08:00
Merge branch 'main' into fix/network-setup-improvements
This commit is contained in:
@@ -450,9 +450,10 @@ async function cleanupProject(projectPath, sessionId = null) {
|
||||
* SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
|
||||
*/
|
||||
class SSEStreamWriter {
|
||||
constructor(res) {
|
||||
constructor(res, userId = null) {
|
||||
this.res = res;
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
this.isSSEStreamWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
@@ -485,9 +486,10 @@ class SSEStreamWriter {
|
||||
* Non-streaming response collector
|
||||
*/
|
||||
class ResponseCollector {
|
||||
constructor() {
|
||||
constructor(userId = null) {
|
||||
this.messages = [];
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
send(data) {
|
||||
@@ -920,7 +922,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
|
||||
writer = new SSEStreamWriter(res);
|
||||
writer = new SSEStreamWriter(res, req.user.id);
|
||||
|
||||
// Send initial status
|
||||
writer.send({
|
||||
@@ -930,7 +932,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
});
|
||||
} else {
|
||||
// Non-streaming mode: collect messages
|
||||
writer = new ResponseCollector();
|
||||
writer = new ResponseCollector(req.user.id);
|
||||
|
||||
// Collect initial status message
|
||||
writer.send({
|
||||
@@ -1219,7 +1221,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
writer = new SSEStreamWriter(res);
|
||||
writer = new SSEStreamWriter(res, req.user.id);
|
||||
}
|
||||
|
||||
if (!res.writableEnded) {
|
||||
|
||||
@@ -96,10 +96,27 @@ router.get('/gemini/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClaudeSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore missing or malformed settings and fall back to other auth sources.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials using two methods with priority order:
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*
|
||||
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
|
||||
@@ -128,6 +145,27 @@ async function checkClaudeCredentials() {
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1b: Check ~/.claude/settings.json env values.
|
||||
// Claude Code can read proxy/auth values from settings.json even when the
|
||||
// CloudCLI server process itself was not started with those env vars exported.
|
||||
const settingsEnv = await loadClaudeSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'Configured via settings.json',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
|
||||
// This is the standard authentication method used by Claude CLI after running
|
||||
// 'claude /login' or 'claude setup-token' commands.
|
||||
|
||||
@@ -651,26 +651,28 @@ router.get('/branches', async (req, res) => {
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
||||
|
||||
// Parse branches
|
||||
const branches = stdout
|
||||
|
||||
const rawLines = stdout
|
||||
.split('\n')
|
||||
.map(branch => branch.trim())
|
||||
.filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer
|
||||
.map(branch => {
|
||||
// Remove asterisk from current branch
|
||||
if (branch.startsWith('* ')) {
|
||||
return branch.substring(2);
|
||||
}
|
||||
// Remove remotes/ prefix
|
||||
if (branch.startsWith('remotes/origin/')) {
|
||||
return branch.substring(15);
|
||||
}
|
||||
return branch;
|
||||
})
|
||||
.filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates
|
||||
|
||||
res.json({ branches });
|
||||
.map(b => b.trim())
|
||||
.filter(b => b && !b.includes('->'));
|
||||
|
||||
// Local branches (may start with '* ' for current)
|
||||
const localBranches = rawLines
|
||||
.filter(b => !b.startsWith('remotes/'))
|
||||
.map(b => (b.startsWith('* ') ? b.substring(2) : b));
|
||||
|
||||
// Remote branches — strip 'remotes/<remote>/' prefix
|
||||
const remoteBranches = rawLines
|
||||
.filter(b => b.startsWith('remotes/'))
|
||||
.map(b => b.replace(/^remotes\/[^/]+\//, ''))
|
||||
.filter(name => !localBranches.includes(name)); // skip if already a local branch
|
||||
|
||||
// Backward-compat flat list (local + unique remotes, deduplicated)
|
||||
const branches = [...localBranches, ...remoteBranches]
|
||||
.filter((b, i, arr) => arr.indexOf(b) === i);
|
||||
|
||||
res.json({ branches, localBranches, remoteBranches });
|
||||
} catch (error) {
|
||||
console.error('Git branches error:', error);
|
||||
res.json({ error: error.message });
|
||||
@@ -721,6 +723,32 @@ router.post('/create-branch', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a local branch
|
||||
router.post('/delete-branch', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch name are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Safety: cannot delete the currently checked-out branch
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
|
||||
if (currentBranch.trim() === branch) {
|
||||
return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
|
||||
}
|
||||
|
||||
const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
console.error('Git delete branch error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent commits
|
||||
router.get('/commits', async (req, res) => {
|
||||
const { project, limit = 10 } = req.query;
|
||||
@@ -740,7 +768,7 @@ router.get('/commits', async (req, res) => {
|
||||
// Get commit log with stats
|
||||
const { stdout } = await spawnAsync(
|
||||
'git',
|
||||
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
|
||||
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
|
||||
{ cwd: projectPath },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from 'express';
|
||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Notification Preferences
|
||||
// ===============================
|
||||
|
||||
router.get('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to save notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Push Subscription Management
|
||||
// ===============================
|
||||
|
||||
router.get('/push/vapid-public-key', async (req, res) => {
|
||||
try {
|
||||
const publicKey = getPublicKey();
|
||||
res.json({ publicKey });
|
||||
} catch (error) {
|
||||
console.error('Error fetching VAPID public key:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return res.status(400).json({ error: 'Missing subscription fields' });
|
||||
}
|
||||
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
|
||||
|
||||
// Enable webPush in preferences so the confirmation goes through the full pipeline
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
if (!currentPrefs?.channels?.webPush) {
|
||||
notificationPreferencesDb.updatePreferences(req.user.id, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs?.channels, webPush: true },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
// Send a confirmation push through the full notification pipeline
|
||||
const event = createNotificationEvent({
|
||||
provider: 'system',
|
||||
kind: 'info',
|
||||
code: 'push.enabled',
|
||||
meta: { message: 'Push notifications are now enabled!' },
|
||||
severity: 'info'
|
||||
});
|
||||
notifyUserIfEnabled({ userId: req.user.id, event });
|
||||
} catch (error) {
|
||||
console.error('Error saving push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to save push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: 'Missing endpoint' });
|
||||
}
|
||||
pushSubscriptionsDb.removeSubscription(endpoint);
|
||||
|
||||
// Disable webPush in preferences to match subscription state
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
if (currentPrefs?.channels?.webPush) {
|
||||
notificationPreferencesDb.updatePreferences(req.user.id, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs.channels, webPush: false },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to remove push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user