import { ScreepsAPI } from "screeps-api"; import * as core from "@actions/core"; import fs from "fs"; import { glob } from "glob"; import path from "path"; import { fileURLToPath } from "url"; import { monitorConsole } from "./monitor.js"; /** * Replaces specific placeholder strings within the provided content with corresponding dynamic values. * * This function specifically targets three placeholders: * - {{gitHash}} is replaced with the current Git commit hash, obtained from the GITHUB_SHA environment variable. * - {{gitRef}} is replaced with the Git reference (branch or tag) that triggered the workflow, obtained from the GITHUB_REF environment variable. * - {{deployTime}} is replaced with the current ISO timestamp. * * Note: This function is designed for use within a GitHub Actions workflow where GITHUB_SHA and GITHUB_REF environment variables are automatically set. * * @param {string} content - The string content in which placeholders are to be replaced. * @returns {string} The content with placeholders replaced by their respective dynamic values. */ export function replacePlaceholders(content, hostname) { const deployTime = new Date().toISOString(); return content .replace(/{{gitHash}}/g, process.env.GITHUB_SHA) .replace(/{{gitRef}}/g, process.env.GITHUB_REF) .replace(/{{deployTime}}/g, deployTime) .replace(/{{hostname}}/g, hostname); } /** * Reads all files matching a specified pattern, replaces certain placeholders in their content, and writes the updated content back to the files. * * This function searches for files in the filesystem using the provided glob pattern, optionally prefixed. It reads each file, * uses the `replacePlaceholders` function to replace specific placeholders in the file's content, and then writes the modified content * back to the original file. This is useful for dynamically updating file contents in a batch process, such as during a build or deployment. * * @param {string} pattern - The glob pattern used to find files. Example: '*.js' for all JavaScript files. * @param {string} [prefix] - An optional directory prefix to prepend to the glob pattern. This allows searching within a specific directory. * @returns {Promise} A promise that resolves with an array of file paths that were processed, or rejects with an error if the process fails. */ export async function readReplaceAndWriteFiles(pattern, prefix, hostname) { let globPattern = prefix ? path.join(prefix, pattern) : pattern; globPattern = globPattern.replace(/\\/g, "/"); const files = await glob(globPattern); let processPromises = files.map((file) => { return fs.promises.readFile(file, "utf8").then((content) => { content = replacePlaceholders(content, hostname); return fs.promises.writeFile(file, content); }); }); await Promise.all(processPromises); return files; } /** * Reads files matching a glob pattern into a dictionary. * @param {string} pattern - Glob pattern to match files. * @param {string} prefix - Directory prefix for file paths. * @returns {Promise} - Promise resolving to a dictionary of file contents keyed by filenames. */ export async function readFilesIntoDict(pattern, prefix) { // Prepend the prefix to the glob pattern let globPattern = prefix ? path.join(prefix, pattern) : pattern; globPattern = globPattern.replace(/\\/g, "/"); const files = await glob(globPattern); let fileDict = {}; let readPromises = files.map((file) => { return fs.promises.readFile(file, "utf8").then((content) => { // Remove the prefix from the filename and drop the file suffix let key = file; if (prefix && file.startsWith(prefix)) { key = key.slice(prefix.length); } key = path.basename(key, path.extname(key)); // Drop the file suffix fileDict[key] = content; }); }); await Promise.all(readPromises); return fileDict; } /** * Validates the provided authentication credentials. * @param {string} token - The authentication token. * @param {string} username - The username. * @param {string} password - The password. * @returns {string|null} - Returns an error message if validation fails, otherwise null. */ export function validateAuthentication(token, username, password) { if (token) { if (username || password) { return "Token is defined along with username and/or password."; } } else { if (!username && !password) { return "Neither token nor password and username are defined."; } if (username && !password) { return "Username is defined but no password is provided."; } if (!username && password) { return "Password is defined but no username is provided."; } } return null; // No errors found } /** * Applies the 'ignore' | 'warn' | 'fail' enum action when the given flag is true. * Exported so it can be unit-tested independently. * * @param {'ignore'|'warn'|'fail'} action * @param {boolean} flag - Only acts when true * @param {string} message - Passed to core.warning / core.setFailed */ export function applyOnAction(action, flag, message) { if (!flag) return; if (action === "warn") { core.warning(message); return; } if (action === "fail") { core.setFailed(message); } // 'ignore' → no-op } /** * Posts code to Screeps server. */ export async function postCode() { const protocol = core.getInput("protocol") || "https"; const hostname = core.getInput("hostname") || "screeps.com"; const port = core.getInput("port") || "443"; const path = core.getInput("path") || "/"; const token = core.getInput("token") || undefined; const username = core.getInput("username") || undefined; const password = core.getInput("password") || undefined; const prefix = core.getInput("source-prefix"); const pattern = core.getInput("pattern") || "*.js"; const branch = core.getInput("branch") || "default"; const gitReplace = core.getInput("git-replace") || null; if (gitReplace) { await readReplaceAndWriteFiles(gitReplace, prefix, hostname); } const files_to_push = await readFilesIntoDict(pattern, prefix); core.info(`Trying to upload the following files to ${branch}:`); Object.keys(files_to_push).forEach((key) => { core.info(`Key: ${key}`); }); const login_arguments = { token: token, username: username, password: password, protocol: protocol, hostname: hostname, port: port, path: path, }; core.info("login_arguments:"); core.info(JSON.stringify(login_arguments, null, 2)); const errorMessage = validateAuthentication(token, username, password); if (errorMessage) { core.error(errorMessage); return; } let api = new ScreepsAPI(login_arguments); if (token) { const response = await api.code.set(branch, files_to_push); core.info(JSON.stringify(response, null, 2)); core.info(`Code set successfully to ${branch}`); } else { core.info(`Logging in as user ${username}`); await Promise.resolve() .then(() => api.auth(username, password, login_arguments)) .then(() => api.code.set(branch, files_to_push)) .then(() => { core.info(`Code set successfully to ${branch}`); }) .catch((err) => { core.error(`Upload error: ${err}`); throw err; }); } // ── Console monitoring (optional) ──────────────────────────────────────── const monitorTicks = parseInt(core.getInput("monitor") || "0", 10); if (monitorTicks > 0) { const result = await monitorConsole(api, { monitor: monitorTicks, logToFile: core.getBooleanInput("log_to_file"), onTraceback: core.getInput("on_traceback") || "fail", onErrorLog: core.getInput("on_error_log") || "warn", onWarningLog: core.getInput("on_warning_log") || "ignore", monitorInterval: parseInt(core.getInput("monitor_interval") || "10", 10), hostname, shard: core.getInput("shard") || undefined, }); core.setOutput("saw_traceback", String(result.sawTraceback)); core.setOutput("saw_error_log", String(result.sawErrorLog)); core.setOutput("saw_warning_log", String(result.sawWarningLog)); applyOnAction( core.getInput("on_traceback"), result.sawTraceback, "Screeps console: traceback detected", ); applyOnAction( core.getInput("on_error_log"), result.sawErrorLog, "Screeps console: error log output detected", ); applyOnAction( core.getInput("on_warning_log"), result.sawWarningLog, "Screeps console: warning log output detected", ); } } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] === __filename) { postCode(); }