Initial public release
This commit is contained in:
Vendored
+28
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
+12
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user