178 lines
6.1 KiB
JavaScript
178 lines
6.1 KiB
JavaScript
import { SlashCommandBuilder } from 'discord.js';
|
|
import { MessageFlags } from 'discord-api-types/v10';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
// Use execFile to avoid shell interpretation of arguments
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// 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<string>} - Trimmed stdout or stderr from the command.
|
|
* @throws {GitError} - When the git command exits with an error.
|
|
*/
|
|
async function runGit(args) {
|
|
// Sanitize arguments: disallow dangerous shell metacharacters
|
|
if (!Array.isArray(args)) {
|
|
throw new GitError('Invalid git arguments');
|
|
}
|
|
const dangerous = /[;&|<>`$\\]/;
|
|
for (const arg of args) {
|
|
if (dangerous.test(arg)) {
|
|
throw new GitError(`Illegal character in git argument: ${arg}`);
|
|
}
|
|
}
|
|
try {
|
|
// Exec git directly without shell
|
|
const { stdout, stderr } = await execFileAsync('git', args);
|
|
const out = (stdout || stderr || '').toString().trim();
|
|
return out || '(no output)';
|
|
} catch (err) {
|
|
const msg = err.stderr?.toString().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 <args>
|
|
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<string>}
|
|
*/
|
|
export async function getBranch() {
|
|
return runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
}
|
|
/**
|
|
* Get short commit hash of HEAD
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function getShortHash() {
|
|
return runGit(['rev-parse', '--short', 'HEAD']);
|
|
}
|
|
/**
|
|
* Get concise working tree status (git status --porcelain)
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function getStatusShort() {
|
|
return runGit(['status', '--porcelain']);
|
|
}
|
|
/**
|
|
* Get Git remote origin URL
|
|
* @returns {Promise<string>}
|
|
*/
|
|
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<string>}
|
|
*/
|
|
export async function getLog(n = 5) {
|
|
return runGit(['log', `-n${n}`, '--oneline']);
|
|
}
|
|
/**
|
|
* Get diff summary (git diff --stat)
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function getDiffStat() {
|
|
return runGit(['diff', '--stat']);
|
|
} |