import { execFileSync } from "node:child_process"; import { readFile, rm, writeFile } from "node:fs/promises"; import process from "node:process"; const TIME_ZONE = "Europe/Berlin"; const DEFAULT_README_PATHS = ["README.md", "packages/coding-agent/README.md"]; const DEFAULT_STATE_PATH = ".github/oss-weekend.json"; const MARKER_START = ""; const MARKER_END = ""; const DISCORD_URL = "https://discord.com/invite/3cU7Bz4UPx"; function parseArgs(argv) { const options = {}; for (const arg of argv) { if (!arg.startsWith("--")) continue; const trimmedArg = arg.slice(2); const separatorIndex = trimmedArg.indexOf("="); if (separatorIndex === -1) { options[trimmedArg] = "true"; continue; } const key = trimmedArg.slice(0, separatorIndex); const value = trimmedArg.slice(separatorIndex + 1); options[key] = value; } return options; } function getOption(name, cliOptions, envName, fallback) { const cliValue = cliOptions[name]; if (cliValue !== undefined) return cliValue; const envValue = process.env[envName]; if (envValue !== undefined && envValue !== "") return envValue; return fallback; } function isTruthy(value) { return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function formatLongDate(date) { return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, weekday: "long", month: "long", day: "numeric", year: "numeric", }).format(date); } function parseDateInput(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) { throw new Error(`Invalid end date: ${value}. Use YYYY-MM-DD.`); } const [, year, month, day] = match; return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0)); } function buildBanner(now, endDate) { const startDate = formatLongDate(now); const reopenDate = formatLongDate(endDate); return [ MARKER_START, "# 🏖️ OSS Weekend", "", `**Issue tracker reopens ${reopenDate}.**`, "", `OSS weekend runs ${startDate} through ${reopenDate}. New issues are auto-closed during this time. For support, join [Discord](${DISCORD_URL}).`, MARKER_END, "", "---", "", "", ].join("\n"); } function upsertBanner(readme, now, endDate) { const banner = buildBanner(now, endDate); const bannerPattern = new RegExp( `${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, "m", ); if (bannerPattern.test(readme)) { return readme.replace(bannerPattern, banner); } return `${banner}${readme}`; } function removeBanner(readme) { const bannerPattern = new RegExp( `^${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, "m", ); return readme.replace(bannerPattern, ""); } function parseReadmePaths(cliOptions) { const readmeOption = getOption("readme", cliOptions, "OSS_WEEKEND_README_PATH", ""); if (!readmeOption) return DEFAULT_README_PATHS; return readmeOption .split(",") .map((path) => path.trim()) .filter(Boolean); } function buildState(now, endDateInput, endDate) { return JSON.stringify( { active: true, mode: "weekend", startsAt: now.toISOString(), startsAtText: formatLongDate(now), reopensOn: endDateInput, reopensOnText: formatLongDate(endDate), discordUrl: DISCORD_URL, }, null, 2, ); } async function readOptionalFile(path) { try { return await readFile(path, "utf8"); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return null; } throw error; } } function runCommand(command, args, options = {}) { return execFileSync(command, args, { encoding: "utf8", ...options }); } function quoteArg(arg) { return /[^A-Za-z0-9_./:=@-]/.test(arg) ? JSON.stringify(arg) : arg; } function formatCommand(command, args) { return [command, ...args].map(quoteArg).join(" "); } function hasStagedChanges(paths) { try { runCommand("git", ["diff", "--cached", "--quiet", "--", ...paths], { stdio: "ignore" }); return false; } catch { return true; } } function runGitOperations(mode, paths, dryRun) { const commitMessage = mode === "close" ? "docs: enable OSS weekend" : "docs: disable OSS weekend"; const addArgs = ["add", "--", ...paths]; const pushArgs = ["push"]; const commands = [formatCommand("git", addArgs)]; if (dryRun) { commands.push(`git commit -m ${quoteArg(commitMessage)}`); commands.push(formatCommand("git", pushArgs)); return { commitMessage, commands, committed: false, pushed: false, stagedChanges: false, }; } runCommand("git", addArgs, { stdio: "inherit" }); if (!hasStagedChanges(paths)) { return { commitMessage, commands, committed: false, pushed: false, stagedChanges: false, }; } const commitArgs = ["commit", "-m", commitMessage]; commands.push(formatCommand("git", commitArgs)); runCommand("git", commitArgs, { stdio: "inherit" }); commands.push(formatCommand("git", pushArgs)); runCommand("git", pushArgs, { stdio: "inherit" }); return { commitMessage, commands, committed: true, pushed: true, stagedChanges: true, }; } function printUsage() { process.stdout.write( [ "Usage:", " node scripts/oss-weekend.mjs --mode=close --end-date=2026-03-23", " node scripts/oss-weekend.mjs --mode=close --end-date=2026-03-23 --git", " node scripts/oss-weekend.mjs --mode=open", " node scripts/oss-weekend.mjs --mode=open --git", "", "Options:", " --mode=close|open Required. close enables OSS weekend mode. open disables it.", " --end-date=YYYY-MM-DD Required for --mode=close.", " --readme=PATHS Optional comma-separated README paths. Defaults to README.md,packages/coding-agent/README.md.", " --state=PATH Optional state file path. Defaults to .github/oss-weekend.json.", " --git Stage only the OSS weekend files, commit, and push after updating them.", " --dry-run Preview without editing files or running git operations.", " --now=ISO Optional current timestamp override for testing.", " --help Show this message.", "", ].join("\n"), ); } async function main() { const cliOptions = parseArgs(process.argv.slice(2)); if (isTruthy(cliOptions.help ?? "false")) { printUsage(); return; } const mode = getOption("mode", cliOptions, "OSS_WEEKEND_MODE", ""); if (mode !== "close" && mode !== "open") { throw new Error("--mode must be close or open."); } const dryRun = isTruthy(getOption("dry-run", cliOptions, "OSS_WEEKEND_DRY_RUN", "false")); const runGit = isTruthy(getOption("git", cliOptions, "OSS_WEEKEND_GIT", "false")); const nowInput = getOption("now", cliOptions, "OSS_WEEKEND_NOW", ""); const readmePaths = parseReadmePaths(cliOptions); const statePath = getOption("state", cliOptions, "OSS_WEEKEND_STATE_PATH", DEFAULT_STATE_PATH); const endDateInput = getOption("end-date", cliOptions, "OSS_WEEKEND_END_DATE", ""); const now = nowInput ? new Date(nowInput) : new Date(); if (Number.isNaN(now.getTime())) { throw new Error(`Invalid date: ${nowInput}`); } if (mode === "close" && !endDateInput) { throw new Error("--end-date is required when --mode=close."); } const endDate = mode === "close" ? parseDateInput(endDateInput) : null; const readmeResults = []; for (const readmePath of readmePaths) { const currentReadme = await readFile(readmePath, "utf8"); const nextReadme = mode === "close" ? upsertBanner(currentReadme, now, endDate) : removeBanner(currentReadme); const changed = nextReadme !== currentReadme; if (changed && !dryRun) { await writeFile(readmePath, nextReadme, "utf8"); } readmeResults.push({ path: readmePath, changed }); } const currentState = await readOptionalFile(statePath); const nextState = mode === "close" ? buildState(now, endDateInput, endDate) : null; const stateChanged = mode === "close" ? currentState !== nextState : currentState !== null; if (!dryRun) { if (mode === "close") { await writeFile(statePath, `${nextState}\n`, "utf8"); } else { await rm(statePath, { force: true }); } } const gitPaths = [...readmePaths, statePath]; const gitResult = runGit ? runGitOperations(mode, gitPaths, dryRun) : null; const output = { mode, dry_run: dryRun ? "true" : "false", weekend_active: mode === "close" ? "true" : "false", readme_paths: readmeResults.map((result) => result.path).join(","), readme_changed: readmeResults.some((result) => result.changed) ? "true" : "false", readme_changed_paths: readmeResults .filter((result) => result.changed) .map((result) => result.path) .join(","), state_path: statePath, state_changed: stateChanged ? "true" : "false", git_enabled: runGit ? "true" : "false", git_paths: gitPaths.join(","), git_commit_message: gitResult?.commitMessage ?? "", git_committed: gitResult?.committed ? "true" : "false", git_pushed: gitResult?.pushed ? "true" : "false", git_commands: gitResult ? gitResult.commands.join(" && ") : "", end_date: endDate ? endDateInput : "", end_date_text: endDate ? formatLongDate(endDate) : "", now_utc: now.toISOString(), now_berlin: new Intl.DateTimeFormat("sv-SE", { timeZone: TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }).format(now), }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); printUsage(); process.exit(1); });