203 lines
9.0 KiB
JavaScript
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==
|