feat: implement worktree setup commands management
- Introduced `readFile` and `writeFile` methods in the FilesAPI for reading and writing files. - Added `execCommands` method to execute shell commands in a specified directory. - Implemented `runWorktreeSetupCommands` function to handle setup commands for worktrees. - Created `OpenChamberConfig` service for managing project-specific configuration, including setup commands. - Enhanced `useMultiRunStore` to save and execute setup commands during multi-run creation. - Updated VSCode bridge to handle file read/write and command execution requests. - Added server endpoints for reading and writing files, and executing shell commands.
This commit is contained in:
@@ -316,10 +316,22 @@ export interface FileSearchResult {
|
||||
preview?: string[];
|
||||
}
|
||||
|
||||
export interface CommandExecResult {
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import { getWorktreeSetupCommands, substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) {
|
||||
@@ -167,3 +182,164 @@ export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktre
|
||||
: normalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorktreeSetupResult {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worktree setup commands in the background.
|
||||
* This does not block - it returns a promise that resolves when all commands complete.
|
||||
*
|
||||
* @param worktreePath - The path to the new worktree where commands will run
|
||||
* @param projectDirectory - The root project directory (for $ROOT_WORKTREE_PATH substitution)
|
||||
* @param commands - Optional commands to run. If not provided, reads from config.
|
||||
* @returns Promise resolving to setup results
|
||||
*/
|
||||
export async function runWorktreeSetupCommands(
|
||||
worktreePath: string,
|
||||
projectDirectory: string,
|
||||
commands?: string[]
|
||||
): Promise<WorktreeSetupResult> {
|
||||
const commandsToRun = commands ?? await getWorktreeSetupCommands(projectDirectory);
|
||||
|
||||
if (commandsToRun.length === 0) {
|
||||
return { success: true, results: [] };
|
||||
}
|
||||
|
||||
// Substitute variables in commands
|
||||
const substitutedCommands = commandsToRun.map(cmd =>
|
||||
substituteCommandVariables(cmd, { rootWorktreePath: projectDirectory })
|
||||
);
|
||||
|
||||
console.log('[worktreeService] Running setup commands:', { worktreePath, projectDirectory, commands: substitutedCommands });
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.execCommands) {
|
||||
console.log('[worktreeService] Using runtime API for exec');
|
||||
try {
|
||||
// Don't use background mode - we want actual results for toast notifications
|
||||
// The bridge now uses async exec (not execSync) so it won't block other operations
|
||||
const result = await runtimeFiles.execCommands(substitutedCommands, worktreePath);
|
||||
console.log('[worktreeService] Runtime exec result:', result);
|
||||
return result as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Runtime exec error:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
console.log('[worktreeService] Using web API for exec');
|
||||
|
||||
const startResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Use background job so we don't hold long-lived HTTP connections.
|
||||
body: JSON.stringify({
|
||||
commands: substitutedCommands,
|
||||
cwd: worktreePath,
|
||||
background: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const startPayload = await startResponse.json().catch(() => null);
|
||||
|
||||
if (startResponse.status === 202 && startPayload && typeof startPayload.jobId === 'string') {
|
||||
const jobId = startPayload.jobId as string;
|
||||
const pollIntervalMs = 800;
|
||||
const timeoutMs = Math.max(5 * 60_000, substitutedCommands.length * 60_000);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
const pollResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec/${jobId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const pollPayload = await pollResponse.json().catch(() => null);
|
||||
if (!pollResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: (pollPayload && pollPayload.error) || 'Failed to poll exec job',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const status = pollPayload?.status;
|
||||
if (status === 'done') {
|
||||
const results = Array.isArray(pollPayload?.results) ? pollPayload.results : [];
|
||||
const success = pollPayload?.success === true;
|
||||
return { success, results } as WorktreeSetupResult;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: 'Setup commands timed out',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const error = (startPayload && startPayload.error) || 'Request failed';
|
||||
console.error('[worktreeService] Web exec failed:', startPayload);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat: older servers may still return results synchronously.
|
||||
console.log('[worktreeService] Web exec result:', startPayload);
|
||||
return startPayload as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Exec exception:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worktree setup commands are configured for a project.
|
||||
*/
|
||||
export async function hasWorktreeSetupCommands(projectDirectory: string): Promise<boolean> {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
return commands.length > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* OpenChamber project-level configuration service.
|
||||
* Manages .openchamber/openchamber.json file for project-specific settings.
|
||||
*/
|
||||
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
const CONFIG_DIR = '.openchamber';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OpenChamberConfig {
|
||||
'setup-worktree'?: string[];
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinPath = (base: string, segment: string): string => {
|
||||
const normalizedBase = normalize(base);
|
||||
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!normalizedBase || normalizedBase === '/') {
|
||||
return `/${cleanSegment}`;
|
||||
}
|
||||
return `${normalizedBase}/${cleanSegment}`;
|
||||
};
|
||||
|
||||
const getConfigPath = (projectDirectory: string): string => {
|
||||
return joinPath(joinPath(projectDirectory, CONFIG_DIR), CONFIG_FILENAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the openchamber.json config file for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readOpenChamberConfig(projectDirectory: string): Promise<OpenChamberConfig | null> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(configPath);
|
||||
if (!result.content.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(result.content);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(configPath)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the openchamber.json config file for a project.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
config: OpenChamberConfig
|
||||
): Promise<boolean> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
const configDir = joinPath(projectDirectory, CONFIG_DIR);
|
||||
|
||||
try {
|
||||
// Ensure .openchamber directory exists
|
||||
await opencodeClient.createDirectory(configDir);
|
||||
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.writeFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config via runtime API:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/write`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: configPath,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific keys in the config, preserving other values.
|
||||
*/
|
||||
export async function updateOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
updates: Partial<OpenChamberConfig>
|
||||
): Promise<boolean> {
|
||||
const existing = await readOpenChamberConfig(projectDirectory) || {};
|
||||
const merged = { ...existing, ...updates };
|
||||
return writeOpenChamberConfig(projectDirectory, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree setup commands from config.
|
||||
*/
|
||||
export async function getWorktreeSetupCommands(projectDirectory: string): Promise<string[]> {
|
||||
const config = await readOpenChamberConfig(projectDirectory);
|
||||
return config?.['setup-worktree'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save worktree setup commands to config.
|
||||
*/
|
||||
export async function saveWorktreeSetupCommands(
|
||||
projectDirectory: string,
|
||||
commands: string[]
|
||||
): Promise<boolean> {
|
||||
// Filter out empty commands
|
||||
const filtered = commands.filter(cmd => cmd.trim().length > 0);
|
||||
return updateOpenChamberConfig(projectDirectory, { 'setup-worktree': filtered });
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute variables in a command string.
|
||||
* Supported variables:
|
||||
* - $ROOT_WORKTREE_PATH: The root project directory path
|
||||
*/
|
||||
export function substituteCommandVariables(
|
||||
command: string,
|
||||
variables: { rootWorktreePath: string }
|
||||
): string {
|
||||
return command
|
||||
.replace(/\$ROOT_WORKTREE_PATH/g, variables.rootWorktreePath)
|
||||
.replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath);
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
if (defaultBaseUrl.startsWith('/')) {
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
Reference in New Issue
Block a user