AI-Repo-Commander/src/command-executor.js

203 lines
9.0 KiB
JavaScript

// ==COMMAND EXECUTOR START==
/* global GM_xmlhttpRequest */
/* global GM_notification */
(function () {
/**
* @typedef {Object} RepoCommand
* @property {string} action
* @property {string} [repo]
* @property {string} [owner]
* @property {string} [path]
* @property {string} [content]
* @property {string} [commit_message]
* @property {string} [url]
* @property {boolean} [example]
*/
/**
* @typedef {Object} FileEntry
* @property {string} [path]
* @property {string} [name]
*/
class CommandExecutor {
/**
* @param {RepoCommand} command
* @param {Element} sourceElement
* @param {string} [label]
*/
static async execute(command, sourceElement, label = '') {
const log = window.AI_REPO_LOGGER;
const cfg = window.AI_REPO_CONFIG;
try {
log.command(command.action, 'executing', { repo: command.repo, path: command.path, label });
if (['update_file', 'create_file'].includes(command.action) && !command.commit_message) {
command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`;
log.trace('Auto-generated commit message', { message: command.commit_message });
}
if (!cfg.get('api.enabled')) {
log.warn('API disabled, using mock execution', { action: command.action });
await this.delay(300);
const result = this._success({ status: 200, responseText: JSON.stringify({ success: true, message: 'Mock execution completed' }) }, command, sourceElement, true, label);
log.command(command.action, 'complete', { mock: true });
return result;
}
log.verbose('Making API request', { action: command.action, url: command.url, label });
const res = await this._api(command);
log.verbose('API request succeeded', { action: command.action, status: res.status });
const result = this._success(res, command, sourceElement, false, label);
log.command(command.action, 'complete', { repo: command.repo, path: command.path });
return result;
} catch (err) {
log.command(command.action, 'error', { error: err.message });
log.error('Command execution failed', { action: command.action, error: err.message, stack: err.stack?.slice(0, 150) });
return this._error(err, command, sourceElement, label);
}
}
static _api(command, attempt = 0) {
const cfg = window.AI_REPO_CONFIG;
const log = window.AI_REPO_LOGGER;
const maxRetries = cfg.get('api.maxRetries') ?? 2;
const timeout = cfg.get('api.timeout') ?? 60000;
const bridgeKey = this._getBridgeKey();
if (attempt > 0) {
log.warn(`Retrying API call (attempt ${attempt + 1}/${maxRetries + 1})`, { action: command.action });
}
log.trace('GM_xmlhttpRequest details', {
method: 'POST',
url: command.url,
timeout,
hasKey: !!bridgeKey,
attempt: attempt + 1
});
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: command.url,
headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' },
data: JSON.stringify(command),
timeout,
onload: (r) => {
if (r.status >= 200 && r.status < 300) {
log.trace('API response received', { status: r.status, bodyLength: r.responseText?.length });
resolve(r);
} else {
log.error(`API returned error status`, { status: r.status, statusText: r.statusText });
reject(new Error(`API Error ${r.status}: ${r.statusText}`));
}
},
onerror: (e) => {
if (attempt < maxRetries) {
const delay = 1000 * (attempt + 1);
log.warn(`Network error, retrying in ${delay}ms`, { error: e?.error || 'unknown' });
setTimeout(() => this._api(command, attempt + 1).then(resolve).catch(reject), delay);
} else {
log.error('Network error, max retries exceeded', { attempts: attempt + 1, error: e?.error || 'unknown' });
reject(new Error(`Network error after ${attempt + 1} attempts: ${e?.error || 'unknown'}`));
}
},
ontimeout: () => {
log.error('API request timed out', { timeout, action: command.action });
reject(new Error(`API timeout after ${timeout}ms`));
}
});
});
}
static _getBridgeKey() {
const cfg = window.AI_REPO_CONFIG;
const log = window.AI_REPO_LOGGER;
let key = cfg.get('api.bridgeKey');
if (!key) {
log.warn('Bridge key not found, prompting user');
key = prompt('[AI Repo Commander] Enter your bridge key for this session:') || '';
if (!key) {
log.error('User did not provide bridge key');
throw new Error('Bridge key required when API is enabled');
}
if (confirm('Save this bridge key to avoid future prompts?')) {
cfg.set('api.bridgeKey', key);
log.info('Bridge key saved to config');
} else {
log.info('Bridge key accepted for this session only');
}
} else {
log.trace('Using saved bridge key from config');
}
return key;
}
static _success(response, command, el, isMock = false, label = '') {
let data; try { data = JSON.parse(response.responseText || '{}'); } catch { data = { message: 'Operation completed' }; }
this._status(el, isMock ? 'MOCK' : 'SUCCESS', { action: command.action, details: data.message || 'Completed successfully', label });
if (command.action === 'get_file') this._handleGetFile(data, label);
if (command.action === 'list_files') this._handleListFiles(data, label);
return { success: true, data, isMock };
}
static _error(error, command, el, label = '') {
this._status(el, 'ERROR', { action: command.action, details: error.message, label });
return { success: false, error: error.message };
}
static _status(el, type, data) {
const div = document.createElement('div');
div.style.cssText = `
padding:8px 12px;margin:8px 0;border-radius:4px;
border-left:4px solid ${this._color(type)};
background:rgba(255,255,255,.05);font-family:monospace;font-size:13px;white-space:pre-wrap;
`;
div.textContent = `${data.label || data.action}${type}${data.details ? ': ' + data.details : ''}`;
el.appendChild(div);
}
static _color(t){ return ({SUCCESS:'#10B981', ERROR:'#EF4444', MOCK:'#8B5CF6'})[t] || '#6B7280'; }
static _handleGetFile(data, label) {
const log = window.AI_REPO_LOGGER;
const content = data?.content?.data ?? data?.content ?? data?.result?.content?.data ?? data?.result?.content;
if (!content) {
log.warn('get_file response missing content field');
return;
}
window.AI_REPO_RESPONSES = window.AI_REPO_RESPONSES || [];
window.AI_REPO_RESPONSES.push({ label, content });
log.verbose('File content stored for paste-back', { label, contentLength: content.length });
}
static _handleListFiles(data, label) {
const log = window.AI_REPO_LOGGER;
/** @type {Array<string|FileEntry>} */
const files = data?.files ?? data?.result?.files;
if (!Array.isArray(files)) {
log.warn('list_files response missing files array');
return;
}
const listing = '```text\n' + files.map(f => (typeof f === 'string' ? f : (f?.path || f?.name || JSON.stringify(f)))).join('\n') + '\n```';
window.AI_REPO_RESPONSES = window.AI_REPO_RESPONSES || [];
window.AI_REPO_RESPONSES.push({ label, content: listing });
log.verbose('File listing stored for paste-back', { label, fileCount: files.length });
}
static delay(ms) { return new Promise(r => setTimeout(r, ms)); }
}
window.AI_REPO_EXECUTOR = CommandExecutor;
})();
// ==COMMAND EXECUTOR END==