import { SlashCommandBuilder } from 'discord.js'; import { MessageFlags } from 'discord-api-types/v10'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); // Wrap Git errors class GitError extends Error { constructor(message) { super(message); this.name = 'GitError'; } } /** * Execute a git command with given arguments and return its output. * @param {string[]} args - Git command arguments (e.g., ['status', '--porcelain']). * @returns {Promise} - Trimmed stdout or stderr from the command. * @throws {GitError} - When the git command exits with an error. */ async function runGit(args) { try { const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`); const out = stdout.trim() || stderr.trim(); return out || '(no output)'; } catch (err) { const msg = err.stderr?.trim() || err.message; throw new GitError(msg); } } /** * Wrap content into a Markdown code block, optionally specifying a language. * @param {string} content - The text to wrap in a code block. * @param {string} [lang] - Optional language identifier (e.g., 'js'). * @returns {string} - The content wrapped in triple backticks. */ function formatCodeBlock(content, lang = '') { const fence = '```'; return lang ? `${fence}${lang}\n${content}\n${fence}` : `${fence}\n${content}\n${fence}`; } /** * Split a large string into smaller chunks for message limits. * @param {string} str - The input string to split. * @param {number} chunkSize - Maximum length of each chunk. * @returns {string[]} - An array of substring chunks. */ function chunkString(str, chunkSize) { const chunks = []; for (let i = 0; i < str.length; i += chunkSize) { chunks.push(str.slice(i, i + chunkSize)); } return chunks; } // Single /git command: run arbitrary git export const commands = [ { data: new SlashCommandBuilder() .setName('git') .setDescription('Run an arbitrary git command (Owner only)') .addStringOption(opt => opt.setName('args') .setDescription('Arguments to pass to git') .setRequired(true)) .addBooleanOption(opt => opt.setName('ephemeral') .setDescription('Make the reply ephemeral') .setRequired(false)), async execute(interaction, client) { const ownerId = client.config.owner; if (interaction.user.id !== ownerId) { return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral }); } const raw = interaction.options.getString('args'); // Disallow semicolons to prevent command chaining if (raw.includes(';')) { return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral }); } const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g) .map(s => s.replace(/^"(.+)"$/, '$1')); try { // Log the exact git command being executed const cmdStr = args.join(' '); client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`); const output = await runGit(args); // Prepend the git command as a header; keep it intact when chunking const header = `git ${cmdStr}\n`; // Discord message limit ~2000; reserve for code fences const maxContent = 1990; // Calculate how much output can fit after the header in the first chunk const firstChunkSize = Math.max(0, maxContent - header.length); // Split the raw output into chunks const outputChunks = chunkString(output, firstChunkSize); // Send first block with header + first output chunk const firstBlock = header + (outputChunks[0] || ''); const replyOpts = { content: formatCodeBlock(firstBlock) }; if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral; await interaction.reply(replyOpts); // Send any remaining blocks without the header for (let i = 1; i < outputChunks.length; i++) { const fuOpts = { content: formatCodeBlock(outputChunks[i]) }; if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral; await interaction.followUp(fuOpts); } } catch (err) { const msg = err instanceof GitError ? err.message : String(err); await interaction.reply({ content: `Error: ${msg}`, flags: MessageFlags.Ephemeral }); } } } ]; // No special init logic export async function init(client) { client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution'); } // Helper functions for external use /** * Get current Git branch name * @returns {Promise} */ export async function getBranch() { return runGit(['rev-parse', '--abbrev-ref', 'HEAD']); } /** * Get short commit hash of HEAD * @returns {Promise} */ export async function getShortHash() { return runGit(['rev-parse', '--short', 'HEAD']); } /** * Get concise working tree status (git status --porcelain) * @returns {Promise} */ export async function getStatusShort() { return runGit(['status', '--porcelain']); } /** * Get Git remote origin URL * @returns {Promise} */ export async function getRemoteUrl() { return runGit(['config', '--get', 'remote.origin.url']); } /** * Get recent commit log (n lines, one-line format) * @param {number} [n=5] * @returns {Promise} */ export async function getLog(n = 5) { return runGit(['log', `-n${n}`, '--oneline']); } /** * Get diff summary (git diff --stat) * @returns {Promise} */ export async function getDiffStat() { return runGit(['diff', '--stat']); }