ClientX/_opt/gitUtils.js

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']);
}