diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js index 8af6d80..f743c06 100644 --- a/_opt/gitUtils.js +++ b/_opt/gitUtils.js @@ -1,77 +1,102 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); -/** - * Git-related slash commands: /gitstatus and /gitpull - */ +// Wrap Git errors +class GitError extends Error { + constructor(message) { + super(message); + this.name = 'GitError'; + } +} + +// Run `git ` and return trimmed output or throw +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 in Markdown code block +function formatCodeBlock(content, lang = '') { + const fence = '```'; + return lang + ? `${fence}${lang}\n${content}\n${fence}` + : `${fence}\n${content}\n${fence}`; +} + +// Split string into chunks of at most chunkSize +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 = [ - // Show current branch and commit status { data: new SlashCommandBuilder() - .setName('gitstatus') - .setDescription('Show current git branch and remote status'), - async execute(interaction, client) { - try { - // Get current branch and commit - const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD'); - const branch = branchOut.trim(); - const { stdout: localOut } = await execAsync('git rev-parse HEAD'); - const local = localOut.trim().slice(0, 7); - // Fetch updates and get remote commit - await execAsync('git fetch --quiet'); - const { stdout: remoteOut } = await execAsync(`git rev-parse origin/${branch}`); - const remote = remoteOut.trim().slice(0, 7); - // Determine ahead/behind counts - const { stdout: behindOut } = await execAsync(`git rev-list --count HEAD..origin/${branch}`); - const behind = parseInt(behindOut.trim(), 10); - const { stdout: aheadOut } = await execAsync(`git rev-list --count origin/${branch}..HEAD`); - const ahead = parseInt(aheadOut.trim(), 10); - const status = (ahead === 0 && behind === 0) - ? 'Up-to-date' : `${ahead} ahead, ${behind} behind`; - // Reply with status - await interaction.reply({ - content: `Branch: \`${branch}\`\nLocal: \`${local}\`\nRemote: \`${remote}\`\nStatus: ${status}`, - ephemeral: true - }); - } catch (err) { - client.logger.error(`Error in gitstatus: ${err.message}`); - await interaction.reply({ content: `Failed to get git status: ${err.message}`, ephemeral: true }); - } - } - }, - // Pull latest changes and restart bot (owner only) - { - data: new SlashCommandBuilder() - .setName('gitpull') - .setDescription('Pull latest changes and restart bot (Owner only)') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + .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 perform this action.', ephemeral: true }); + return interaction.reply({ content: 'Only the bot owner can run git commands.', ephemeral: true }); } - await interaction.deferReply({ ephemeral: true }); + 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.', ephemeral: true }); + } + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g) + .map(s => s.replace(/^"(.+)"$/, '$1')); + try { - // Pull with fast-forward only - const { stdout, stderr } = await execAsync('git pull --ff-only'); - const output = stdout.trim() || stderr.trim(); - await interaction.editReply({ - content: `Git pull output:\n\`\`\`\n${output}\n\`\`\`\nRestarting...` - }); - setTimeout(() => process.exit(0), 1000); + // Log the exact git command being executed + const cmdStr = args.join(' '); + client.logger.warn(`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] || ''); + await interaction.reply({ content: formatCodeBlock(firstBlock), ephemeral }); + // Send any remaining blocks without the header + for (let i = 1; i < outputChunks.length; i++) { + await interaction.followUp({ content: formatCodeBlock(outputChunks[i]), ephemeral }); + } } catch (err) { - client.logger.error(`Error in gitpull: ${err.message}`); - await interaction.editReply({ content: `Git pull failed: ${err.message}`, ephemeral: true }); + const msg = err instanceof GitError ? err.message : String(err); + await interaction.reply({ content: `Error: ${msg}`, ephemeral: true }); } } } ]; -/** - * No special init logic for git utilities - */ -export async function init(client, config) { - client.logger.info('Git utilities module loaded'); +// No special init logic +export async function init(client) { + client.logger.warn('Git utilities module loaded - dangerous module, use with caution'); } \ No newline at end of file diff --git a/config.js b/config.js index 57ea88b..6a1ebba 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ export default { { id: 'IO3', enabled: true, - owner: 378741522822070272, + owner: process.env.OWNER_ID, discord: { appId: process.env.IO3_DISCORD_APPID,