From b23f94aa1a2a53bfe1666841c18500fbcbc22e72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:40:26 +0000 Subject: [PATCH 1/3] Initial plan From b968e1e59030ed18ecc8b4a016f83b03bf11be85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:48:38 +0000 Subject: [PATCH 2/3] Add explicit filename validation to prevent path traversal attacks - Add path.basename() sanitization before file writes - Add explicit SAFE_FILENAME validation at point of use - Add defensive path traversal check using path boundaries - Apply fixes to both fetchFromContentsApi and fetchFromActionsApi modes - Format code with prettier for consistency Co-authored-by: eaftan <4733401+eaftan@users.noreply.github.com> --- docs/scripts/fetch-playground-snapshots.mjs | 364 ++++++++++---------- 1 file changed, 174 insertions(+), 190 deletions(-) diff --git a/docs/scripts/fetch-playground-snapshots.mjs b/docs/scripts/fetch-playground-snapshots.mjs index 8af8c67004..3a43c46fbb 100644 --- a/docs/scripts/fetch-playground-snapshots.mjs +++ b/docs/scripts/fetch-playground-snapshots.mjs @@ -1,29 +1,29 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { spawnSync } from 'node:child_process'; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { spawnSync } from "node:child_process"; const repo = process.env.PLAYGROUND_SNAPSHOTS_REPO; // "owner/repo" -const ref = process.env.PLAYGROUND_SNAPSHOTS_REF || 'main'; -const snapshotsPath = process.env.PLAYGROUND_SNAPSHOTS_PATH || 'snapshots'; +const ref = process.env.PLAYGROUND_SNAPSHOTS_REF || "main"; +const snapshotsPath = process.env.PLAYGROUND_SNAPSHOTS_PATH || "snapshots"; const token = process.env.PLAYGROUND_SNAPSHOTS_TOKEN || process.env.GITHUB_TOKEN; // Default keeps backward-compatible behavior (download JSON snapshots from a repo path). // Set PLAYGROUND_SNAPSHOTS_MODE=actions to generate snapshots from GitHub Actions runs. -const mode = process.env.PLAYGROUND_SNAPSHOTS_MODE || 'contents'; -const workflowsDir = process.env.PLAYGROUND_SNAPSHOTS_WORKFLOWS_DIR || '.github/workflows'; -const workflowIdsCsv = process.env.PLAYGROUND_SNAPSHOTS_WORKFLOW_IDS || ''; -const branch = process.env.PLAYGROUND_SNAPSHOTS_BRANCH || ref || 'main'; +const mode = process.env.PLAYGROUND_SNAPSHOTS_MODE || "contents"; +const workflowsDir = process.env.PLAYGROUND_SNAPSHOTS_WORKFLOWS_DIR || ".github/workflows"; +const workflowIdsCsv = process.env.PLAYGROUND_SNAPSHOTS_WORKFLOW_IDS || ""; +const branch = process.env.PLAYGROUND_SNAPSHOTS_BRANCH || ref || "main"; -const outDir = path.resolve('src/assets/playground-snapshots'); +const outDir = path.resolve("src/assets/playground-snapshots"); const MAX_FILES = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_FILES || 50); const MAX_FILE_BYTES = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_FILE_BYTES || 256 * 1024); const MAX_TOTAL_BYTES = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_TOTAL_BYTES || 2 * 1024 * 1024); -const INCLUDE_LOGS = String(process.env.PLAYGROUND_SNAPSHOTS_INCLUDE_LOGS || '1') !== '0'; +const INCLUDE_LOGS = String(process.env.PLAYGROUND_SNAPSHOTS_INCLUDE_LOGS || "1") !== "0"; const MAX_LOG_BYTES = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_LOG_BYTES || 512 * 1024); const MAX_LOG_LINES_TOTAL = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_LOG_LINES_TOTAL || 1200); const MAX_LOG_LINES_PER_GROUP = Number(process.env.PLAYGROUND_SNAPSHOTS_MAX_LOG_LINES_PER_GROUP || 120); @@ -39,8 +39,8 @@ function headerAuth() { async function ghJson(url) { const res = await fetch(url, { headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", ...headerAuth(), }, }); @@ -60,28 +60,28 @@ async function download(url) { } async function downloadJobLogsZip(jobId) { - if (!repo) throw new Error('[playground-snapshots] Missing PLAYGROUND_SNAPSHOTS_REPO'); + if (!repo) throw new Error("[playground-snapshots] Missing PLAYGROUND_SNAPSHOTS_REPO"); const url = `https://api.github.com/repos/${repo}/actions/jobs/${encodeURIComponent(String(jobId))}/logs`; const res = await fetch(url, { headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", ...headerAuth(), }, - redirect: 'manual', + redirect: "manual", }); - if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { - const loc = res.headers.get('location'); + if (res.status >= 300 && res.status < 400 && res.headers.get("location")) { + const loc = res.headers.get("location"); // Signed URLs typically don't need (or accept) Authorization. - const res2 = await fetch(loc, { redirect: 'follow' }); + const res2 = await fetch(loc, { redirect: "follow" }); if (!res2.ok) throw new Error(`Download job logs redirect failed ${res2.status} ${res2.statusText}`); return Buffer.from(await res2.arrayBuffer()); } if (!res.ok) { - const text = await res.text().catch(() => ''); + const text = await res.text().catch(() => ""); throw new Error(`Download job logs failed ${res.status} ${res.statusText}: ${text}`); } @@ -98,68 +98,68 @@ async function extractJobLogsText(bytes) { // - a ZIP archive (legacy / some hosts) // - a plain text file (e.g. job-logs.txt on blob storage) if (!looksLikeZip(bytes)) { - return Buffer.from(bytes).toString('utf8'); + return Buffer.from(bytes).toString("utf8"); } // Prefer system unzip to avoid extra npm dependencies. - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gh-aw-playground-logs-')); - const zipPath = path.join(tmpDir, 'logs.zip'); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-aw-playground-logs-")); + const zipPath = path.join(tmpDir, "logs.zip"); try { await fs.writeFile(zipPath, bytes); - const res = spawnSync('unzip', ['-p', zipPath], { + const res = spawnSync("unzip", ["-p", zipPath], { encoding: null, maxBuffer: Math.max(MAX_LOG_BYTES, 512 * 1024), }); if (res.error) throw res.error; if (res.status !== 0) { - const stderr = res.stderr ? res.stderr.toString('utf8') : ''; + const stderr = res.stderr ? res.stderr.toString("utf8") : ""; throw new Error(`unzip failed (exit ${res.status}): ${stderr}`); } - const out = Buffer.isBuffer(res.stdout) ? res.stdout : Buffer.from(String(res.stdout || ''), 'utf8'); - return out.toString('utf8'); + const out = Buffer.isBuffer(res.stdout) ? res.stdout : Buffer.from(String(res.stdout || ""), "utf8"); + return out.toString("utf8"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); } } function normalizeLogLine(line) { - let text = String(line ?? ''); - if (text.length > MAX_LOG_LINE_CHARS) text = text.slice(0, MAX_LOG_LINE_CHARS) + '…'; + let text = String(line ?? ""); + if (text.length > MAX_LOG_LINE_CHARS) text = text.slice(0, MAX_LOG_LINE_CHARS) + "…"; return text; } function normalizeKey(value) { - return String(value || '') + return String(value || "") .toLowerCase() - .replace(/[^a-z0-9]+/g, ''); + .replace(/[^a-z0-9]+/g, ""); } function parseGroupedLogs(text) { - const root = { title: 'Job logs', lines: [], children: [] }; + const root = { title: "Job logs", lines: [], children: [] }; const stack = [root]; let totalKept = 0; - const rawLines = String(text || '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .split('\n'); + const rawLines = String(text || "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n"); for (const raw of rawLines) { const line = normalizeLogLine(raw); // GitHub-hosted logs often prefix timestamps before group markers. // Also support `::group::` markers. - const ghaGroupIdx = line.indexOf('##[group]'); - const ghaEndGroupIdx = line.indexOf('##[endgroup]'); - const ghaAltGroupIdx = line.indexOf('::group::'); - const ghaAltEndGroupIdx = line.indexOf('::endgroup::'); + const ghaGroupIdx = line.indexOf("##[group]"); + const ghaEndGroupIdx = line.indexOf("##[endgroup]"); + const ghaAltGroupIdx = line.indexOf("::group::"); + const ghaAltEndGroupIdx = line.indexOf("::endgroup::"); if (ghaGroupIdx !== -1) { - const title = line.slice(ghaGroupIdx + '##[group]'.length).trim() || 'Group'; + const title = line.slice(ghaGroupIdx + "##[group]".length).trim() || "Group"; const group = { title, lines: [], children: [] }; stack[stack.length - 1].children.push(group); stack.push(group); @@ -172,7 +172,7 @@ function parseGroupedLogs(text) { } if (ghaAltGroupIdx !== -1) { - const title = line.slice(ghaAltGroupIdx + '::group::'.length).trim() || 'Group'; + const title = line.slice(ghaAltGroupIdx + "::group::".length).trim() || "Group"; const group = { title, lines: [], children: [] }; stack[stack.length - 1].children.push(group); stack.push(group); @@ -200,7 +200,7 @@ function parseGroupedLogs(text) { } // Prune empty groups (keep ones that have children). - const prune = (g) => { + const prune = g => { const kids = Array.isArray(g.children) ? g.children.map(prune).filter(Boolean) : []; const lines = Array.isArray(g.lines) ? g.lines : []; const hasContent = lines.length > 0 || kids.length > 0 || (g.omittedLineCount ?? 0) > 0; @@ -224,13 +224,13 @@ function findBestGroupForStep(jobLogGroup, stepName) { // Walk depth-first, look for the closest title match. const candidates = []; - const visit = (g) => { - if (!g || typeof g !== 'object') return; - const title = String(g.title || ''); + const visit = g => { + if (!g || typeof g !== "object") return; + const title = String(g.title || ""); const titleKey = normalizeKey(title); if (titleKey === target) candidates.push({ score: 100, g }); else if (titleKey.endsWith(target) || titleKey.includes(target)) candidates.push({ score: 50, g }); - else if (titleKey.startsWith('run' + target) || titleKey.startsWith('post' + target)) candidates.push({ score: 40, g }); + else if (titleKey.startsWith("run" + target) || titleKey.startsWith("post" + target)) candidates.push({ score: 40, g }); const kids = Array.isArray(g.children) ? g.children : []; for (const k of kids) visit(k); }; @@ -241,13 +241,13 @@ function findBestGroupForStep(jobLogGroup, stepName) { } function asString(value, label) { - if (typeof value !== 'string') throw new Error(`Invalid snapshot field '${label}': expected string`); + if (typeof value !== "string") throw new Error(`Invalid snapshot field '${label}': expected string`); return value; } function asOptionalString(value, label) { if (value === undefined || value === null) return undefined; - if (typeof value !== 'string') throw new Error(`Invalid snapshot field '${label}': expected string or undefined`); + if (typeof value !== "string") throw new Error(`Invalid snapshot field '${label}': expected string or undefined`); return value; } @@ -257,17 +257,7 @@ function asArray(value, label) { } function validateConclusion(value, label) { - const allowed = new Set([ - 'success', - 'failure', - 'cancelled', - 'skipped', - 'neutral', - 'timed_out', - 'action_required', - 'stale', - null, - ]); + const allowed = new Set(["success", "failure", "cancelled", "skipped", "neutral", "timed_out", "action_required", "stale", null]); if (!allowed.has(value)) { throw new Error(`Invalid snapshot field '${label}': unexpected conclusion '${String(value)}'`); @@ -276,51 +266,51 @@ function validateConclusion(value, label) { } function validateSnapshotJson(raw, fallbackWorkflowId) { - if (raw === null || typeof raw !== 'object') throw new Error('Snapshot JSON must be an object'); + if (raw === null || typeof raw !== "object") throw new Error("Snapshot JSON must be an object"); - const workflowId = asString(raw.workflowId ?? fallbackWorkflowId, 'workflowId'); - const updatedAt = asString(raw.updatedAt, 'updatedAt'); - const runUrl = asOptionalString(raw.runUrl ?? raw?.run?.html_url, 'runUrl'); - const conclusion = validateConclusion(raw.conclusion ?? null, 'conclusion'); - const jobsRaw = asArray(raw.jobs ?? [], 'jobs'); + const workflowId = asString(raw.workflowId ?? fallbackWorkflowId, "workflowId"); + const updatedAt = asString(raw.updatedAt, "updatedAt"); + const runUrl = asOptionalString(raw.runUrl ?? raw?.run?.html_url, "runUrl"); + const conclusion = validateConclusion(raw.conclusion ?? null, "conclusion"); + const jobsRaw = asArray(raw.jobs ?? [], "jobs"); /** @type {Array} */ const jobs = []; for (const job of jobsRaw) { - if (job === null || typeof job !== 'object') throw new Error('Invalid job entry: expected object'); - const jobName = asString(job.name, 'jobs[].name'); - const jobConclusion = validateConclusion(job.conclusion ?? null, 'jobs[].conclusion'); - const stepsRaw = asArray(job.steps ?? [], 'jobs[].steps'); + if (job === null || typeof job !== "object") throw new Error("Invalid job entry: expected object"); + const jobName = asString(job.name, "jobs[].name"); + const jobConclusion = validateConclusion(job.conclusion ?? null, "jobs[].conclusion"); + const stepsRaw = asArray(job.steps ?? [], "jobs[].steps"); - const jobSummary = asOptionalString(job.summary, 'jobs[].summary'); + const jobSummary = asOptionalString(job.summary, "jobs[].summary"); - const jobId = asOptionalNumber(job.id, 'jobs[].id'); - const jobStatus = asOptionalString(job.status, 'jobs[].status'); - const jobStartedAt = asOptionalIsoDateString(job.startedAt ?? job.started_at, 'jobs[].startedAt'); - const jobCompletedAt = asOptionalIsoDateString(job.completedAt ?? job.completed_at, 'jobs[].completedAt'); - const jobUrl = asOptionalString(job.url, 'jobs[].url'); + const jobId = asOptionalNumber(job.id, "jobs[].id"); + const jobStatus = asOptionalString(job.status, "jobs[].status"); + const jobStartedAt = asOptionalIsoDateString(job.startedAt ?? job.started_at, "jobs[].startedAt"); + const jobCompletedAt = asOptionalIsoDateString(job.completedAt ?? job.completed_at, "jobs[].completedAt"); + const jobUrl = asOptionalString(job.url, "jobs[].url"); - const jobLog = validateOptionalLogGroup(job.log, 'jobs[].log'); + const jobLog = validateOptionalLogGroup(job.log, "jobs[].log"); /** @type {Array} */ const steps = []; for (const step of stepsRaw) { - if (step === null || typeof step !== 'object') throw new Error('Invalid step entry: expected object'); + if (step === null || typeof step !== "object") throw new Error("Invalid step entry: expected object"); - const stepNumber = asOptionalNumber(step.number, 'jobs[].steps[].number'); - const stepStatus = asOptionalString(step.status, 'jobs[].steps[].status'); - const stepStartedAt = asOptionalIsoDateString(step.startedAt ?? step.started_at, 'jobs[].steps[].startedAt'); - const stepCompletedAt = asOptionalIsoDateString(step.completedAt ?? step.completed_at, 'jobs[].steps[].completedAt'); + const stepNumber = asOptionalNumber(step.number, "jobs[].steps[].number"); + const stepStatus = asOptionalString(step.status, "jobs[].steps[].status"); + const stepStartedAt = asOptionalIsoDateString(step.startedAt ?? step.started_at, "jobs[].steps[].startedAt"); + const stepCompletedAt = asOptionalIsoDateString(step.completedAt ?? step.completed_at, "jobs[].steps[].completedAt"); - const stepLog = validateOptionalLogGroup(step.log, 'jobs[].steps[].log'); + const stepLog = validateOptionalLogGroup(step.log, "jobs[].steps[].log"); steps.push({ - name: asString(step.name, 'jobs[].steps[].name'), - conclusion: validateConclusion(step.conclusion ?? null, 'jobs[].steps[].conclusion'), - ...(typeof stepNumber === 'number' ? { number: stepNumber } : {}), - ...(typeof stepStatus === 'string' ? { status: stepStatus } : {}), - ...(typeof stepStartedAt === 'string' ? { startedAt: stepStartedAt } : {}), - ...(typeof stepCompletedAt === 'string' ? { completedAt: stepCompletedAt } : {}), + name: asString(step.name, "jobs[].steps[].name"), + conclusion: validateConclusion(step.conclusion ?? null, "jobs[].steps[].conclusion"), + ...(typeof stepNumber === "number" ? { number: stepNumber } : {}), + ...(typeof stepStatus === "string" ? { status: stepStatus } : {}), + ...(typeof stepStartedAt === "string" ? { startedAt: stepStartedAt } : {}), + ...(typeof stepCompletedAt === "string" ? { completedAt: stepCompletedAt } : {}), ...(stepLog ? { log: stepLog } : {}), }); } @@ -329,12 +319,12 @@ function validateSnapshotJson(raw, fallbackWorkflowId) { name: jobName, conclusion: jobConclusion, steps, - ...(typeof jobSummary === 'string' && jobSummary.trim().length > 0 ? { summary: jobSummary } : {}), - ...(typeof jobId === 'number' ? { id: jobId } : {}), - ...(typeof jobStatus === 'string' ? { status: jobStatus } : {}), - ...(typeof jobStartedAt === 'string' ? { startedAt: jobStartedAt } : {}), - ...(typeof jobCompletedAt === 'string' ? { completedAt: jobCompletedAt } : {}), - ...(typeof jobUrl === 'string' ? { url: jobUrl } : {}), + ...(typeof jobSummary === "string" && jobSummary.trim().length > 0 ? { summary: jobSummary } : {}), + ...(typeof jobId === "number" ? { id: jobId } : {}), + ...(typeof jobStatus === "string" ? { status: jobStatus } : {}), + ...(typeof jobStartedAt === "string" ? { startedAt: jobStartedAt } : {}), + ...(typeof jobCompletedAt === "string" ? { completedAt: jobCompletedAt } : {}), + ...(typeof jobUrl === "string" ? { url: jobUrl } : {}), ...(jobLog ? { log: jobLog } : {}), }); } @@ -351,33 +341,29 @@ function validateSnapshotJson(raw, fallbackWorkflowId) { function validateOptionalLogGroup(value, label) { if (value === undefined || value === null) return undefined; - if (typeof value !== 'object') throw new Error(`Invalid snapshot field '${label}': expected object or undefined`); + if (typeof value !== "object") throw new Error(`Invalid snapshot field '${label}': expected object or undefined`); const title = asString(value.title, `${label}.title`); const linesRaw = value.lines; - const lines = Array.isArray(linesRaw) ? linesRaw.filter((x) => typeof x === 'string') : undefined; + const lines = Array.isArray(linesRaw) ? linesRaw.filter(x => typeof x === "string") : undefined; const omittedLineCount = asOptionalNumber(value.omittedLineCount, `${label}.omittedLineCount`); - const truncated = typeof value.truncated === 'boolean' ? value.truncated : undefined; + const truncated = typeof value.truncated === "boolean" ? value.truncated : undefined; const childrenRaw = value.children; - const children = Array.isArray(childrenRaw) - ? childrenRaw - .map((c, idx) => validateOptionalLogGroup(c, `${label}.children[${idx}]`)) - .filter(Boolean) - : undefined; + const children = Array.isArray(childrenRaw) ? childrenRaw.map((c, idx) => validateOptionalLogGroup(c, `${label}.children[${idx}]`)).filter(Boolean) : undefined; return { title, ...(lines && lines.length > 0 ? { lines } : {}), - ...(typeof omittedLineCount === 'number' && omittedLineCount > 0 ? { omittedLineCount } : {}), + ...(typeof omittedLineCount === "number" && omittedLineCount > 0 ? { omittedLineCount } : {}), ...(children && children.length > 0 ? { children } : {}), - ...(typeof truncated === 'boolean' ? { truncated } : {}), + ...(typeof truncated === "boolean" ? { truncated } : {}), }; } function asOptionalNumber(value, label) { if (value === undefined || value === null) return undefined; - if (typeof value !== 'number') throw new Error(`Invalid snapshot field '${label}': expected number or undefined`); + if (typeof value !== "number") throw new Error(`Invalid snapshot field '${label}': expected number or undefined`); return value; } @@ -395,17 +381,17 @@ function asOptionalIsoDateString(value, label) { } async function listWorkflowIdsFromLocalAssets() { - const workflowsAssetsDir = path.resolve('src/assets/playground-workflows/user-owned'); + const workflowsAssetsDir = path.resolve("src/assets/playground-workflows/user-owned"); const entries = await fs.readdir(workflowsAssetsDir).catch(() => []); return entries - .filter((f) => f.endsWith('.lock.yml')) - .map((f) => f.slice(0, -'.lock.yml'.length)) + .filter(f => f.endsWith(".lock.yml")) + .map(f => f.slice(0, -".lock.yml".length)) .filter(Boolean) .sort(); } async function fetchLatestRunSnapshotFromActionsApi(workflowId) { - if (!repo) throw new Error('[playground-snapshots] Missing PLAYGROUND_SNAPSHOTS_REPO'); + if (!repo) throw new Error("[playground-snapshots] Missing PLAYGROUND_SNAPSHOTS_REPO"); // GitHub API allows workflow identifier to be either numeric ID or file name. // These playground workflows are typically stored as {id}.lock.yml under .github/workflows. @@ -416,7 +402,7 @@ async function fetchLatestRunSnapshotFromActionsApi(workflowId) { const runs = Array.isArray(runsJson?.workflow_runs) ? runsJson.workflow_runs : []; const run = runs[0]; - if (!run || typeof run !== 'object') { + if (!run || typeof run !== "object") { return { workflowId, updatedAt: new Date().toISOString(), @@ -425,54 +411,52 @@ async function fetchLatestRunSnapshotFromActionsApi(workflowId) { }; } - const runId = asOptionalNumber(run.id, 'run.id'); - const runUrl = asOptionalString(run.html_url, 'run.html_url'); - const updatedAt = asOptionalIsoDateString(run.updated_at, 'run.updated_at') || new Date().toISOString(); - const conclusion = asOptionalConclusion(run.conclusion ?? null, 'run.conclusion') ?? null; + const runId = asOptionalNumber(run.id, "run.id"); + const runUrl = asOptionalString(run.html_url, "run.html_url"); + const updatedAt = asOptionalIsoDateString(run.updated_at, "run.updated_at") || new Date().toISOString(); + const conclusion = asOptionalConclusion(run.conclusion ?? null, "run.conclusion") ?? null; /** @type {Array} */ let jobs = []; - if (typeof runId === 'number') { + if (typeof runId === "number") { const jobsUrl = `https://api.github.com/repos/${repo}/actions/runs/${encodeURIComponent(String(runId))}/jobs?per_page=100`; const jobsJson = await ghJson(jobsUrl); const jobsRaw = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []; - jobs = jobsRaw.map((j) => { - const jobName = asString(j?.name ?? 'Unnamed job', 'jobs[].name'); - const jobConclusion = asOptionalConclusion(j?.conclusion ?? null, 'jobs[].conclusion') ?? null; + jobs = jobsRaw.map(j => { + const jobName = asString(j?.name ?? "Unnamed job", "jobs[].name"); + const jobConclusion = asOptionalConclusion(j?.conclusion ?? null, "jobs[].conclusion") ?? null; const stepsRaw = Array.isArray(j?.steps) ? j.steps : []; - const steps = stepsRaw - .slice(0, 200) - .map((s) => ({ - name: asString(s?.name ?? 'Unnamed step', 'jobs[].steps[].name'), - conclusion: asOptionalConclusion(s?.conclusion ?? null, 'jobs[].steps[].conclusion') ?? null, - // Extra fields for richer UI (ignored by current renderer but useful for future improvements) - ...(typeof s?.number === 'number' ? { number: s.number } : {}), - ...(typeof s?.status === 'string' ? { status: s.status } : {}), - ...(typeof s?.started_at === 'string' ? { startedAt: s.started_at } : {}), - ...(typeof s?.completed_at === 'string' ? { completedAt: s.completed_at } : {}), - })); + const steps = stepsRaw.slice(0, 200).map(s => ({ + name: asString(s?.name ?? "Unnamed step", "jobs[].steps[].name"), + conclusion: asOptionalConclusion(s?.conclusion ?? null, "jobs[].steps[].conclusion") ?? null, + // Extra fields for richer UI (ignored by current renderer but useful for future improvements) + ...(typeof s?.number === "number" ? { number: s.number } : {}), + ...(typeof s?.status === "string" ? { status: s.status } : {}), + ...(typeof s?.started_at === "string" ? { startedAt: s.started_at } : {}), + ...(typeof s?.completed_at === "string" ? { completedAt: s.completed_at } : {}), + })); return { name: jobName, conclusion: jobConclusion, steps, - ...(typeof j?.id === 'number' ? { id: j.id } : {}), - ...(typeof j?.status === 'string' ? { status: j.status } : {}), - ...(typeof j?.started_at === 'string' ? { startedAt: j.started_at } : {}), - ...(typeof j?.completed_at === 'string' ? { completedAt: j.completed_at } : {}), - ...(typeof j?.html_url === 'string' ? { url: j.html_url } : {}), + ...(typeof j?.id === "number" ? { id: j.id } : {}), + ...(typeof j?.status === "string" ? { status: j.status } : {}), + ...(typeof j?.started_at === "string" ? { startedAt: j.started_at } : {}), + ...(typeof j?.completed_at === "string" ? { completedAt: j.completed_at } : {}), + ...(typeof j?.html_url === "string" ? { url: j.html_url } : {}), }; }); if (INCLUDE_LOGS) { for (const job of jobs) { - if (typeof job?.id !== 'number') continue; + if (typeof job?.id !== "number") continue; try { const zipBytes = await downloadJobLogsZip(job.id); if (zipBytes.length > MAX_LOG_BYTES) { job.log = { - title: 'Job logs', + title: "Job logs", omittedLineCount: 0, truncated: true, lines: [`(logs payload is ${zipBytes.length} bytes; max ${MAX_LOG_BYTES} bytes)`], @@ -488,11 +472,7 @@ async function fetchLatestRunSnapshotFromActionsApi(workflowId) { // Best effort: try to find the step's group. Fallback to a tiny placeholder // so every step remains expandable (and users can jump to job-level logs). for (const step of job.steps || []) { - const candidates = [ - step.name, - `Run ${step.name}`, - `Post ${step.name}`, - ].filter(Boolean); + const candidates = [step.name, `Run ${step.name}`, `Post ${step.name}`].filter(Boolean); let match; for (const candidate of candidates) { @@ -500,17 +480,15 @@ async function fetchLatestRunSnapshotFromActionsApi(workflowId) { if (match) break; } - step.log = - match || - { - title: `Step logs: ${step.name}`, - lines: ['(No separate log group found for this step. See job logs above.)'], - }; + step.log = match || { + title: `Step logs: ${step.name}`, + lines: ["(No separate log group found for this step. See job logs above.)"], + }; } } } catch (err) { job.log = { - title: 'Job logs (unavailable)', + title: "Job logs (unavailable)", lines: [String(err?.message || err)], truncated: true, }; @@ -526,14 +504,14 @@ async function fetchLatestRunSnapshotFromActionsApi(workflowId) { conclusion, jobs, // Extra run-level metadata (ignored by current renderer). - ...(typeof runId === 'number' ? { runId } : {}), - ...(typeof run?.run_number === 'number' ? { runNumber: run.run_number } : {}), - ...(typeof run?.run_attempt === 'number' ? { runAttempt: run.run_attempt } : {}), - ...(typeof run?.status === 'string' ? { status: run.status } : {}), - ...(typeof run?.event === 'string' ? { event: run.event } : {}), - ...(typeof run?.head_branch === 'string' ? { headBranch: run.head_branch } : {}), - ...(typeof run?.head_sha === 'string' ? { headSha: run.head_sha } : {}), - ...(typeof run?.created_at === 'string' ? { createdAt: run.created_at } : {}), + ...(typeof runId === "number" ? { runId } : {}), + ...(typeof run?.run_number === "number" ? { runNumber: run.run_number } : {}), + ...(typeof run?.run_attempt === "number" ? { runAttempt: run.run_attempt } : {}), + ...(typeof run?.status === "string" ? { status: run.status } : {}), + ...(typeof run?.event === "string" ? { event: run.event } : {}), + ...(typeof run?.head_branch === "string" ? { headBranch: run.head_branch } : {}), + ...(typeof run?.head_sha === "string" ? { headSha: run.head_sha } : {}), + ...(typeof run?.created_at === "string" ? { createdAt: run.created_at } : {}), }; } @@ -545,29 +523,23 @@ async function fetchFromContentsApi() { const listing = await ghJson(url); if (!Array.isArray(listing)) { - throw new Error('[playground-snapshots] Expected directory listing (array) from GitHub contents API.'); + throw new Error("[playground-snapshots] Expected directory listing (array) from GitHub contents API."); } - const jsonFiles = listing - .filter((i) => i && i.type === 'file' && typeof i.name === 'string' && i.name.endsWith('.json')) - .filter((i) => SAFE_FILENAME.test(i.name)); + const jsonFiles = listing.filter(i => i && i.type === "file" && typeof i.name === "string" && i.name.endsWith(".json")).filter(i => SAFE_FILENAME.test(i.name)); if (jsonFiles.length > MAX_FILES) { throw new Error(`[playground-snapshots] Refusing to fetch ${jsonFiles.length} files (max ${MAX_FILES}).`); } if (jsonFiles.length === 0) { - console.warn('[playground-snapshots] No .json files found; leaving existing snapshots as-is.'); + console.warn("[playground-snapshots] No .json files found; leaving existing snapshots as-is."); return; } // Clean output directory first so removals in the snapshots repo are reflected. const existing = await fs.readdir(outDir).catch(() => []); - await Promise.all( - existing - .filter((f) => f.endsWith('.json')) - .map((f) => fs.rm(path.join(outDir, f), { force: true })) - ); + await Promise.all(existing.filter(f => f.endsWith(".json")).map(f => fs.rm(path.join(outDir, f), { force: true }))); let totalBytes = 0; for (const file of jsonFiles) { @@ -582,27 +554,37 @@ async function fetchFromContentsApi() { throw new Error(`[playground-snapshots] Refusing snapshots total ${totalBytes} bytes (max ${MAX_TOTAL_BYTES}).`); } - const fallbackWorkflowId = file.name.slice(0, -'.json'.length); - const raw = JSON.parse(bytes.toString('utf8')); + const fallbackWorkflowId = file.name.slice(0, -".json".length); + const raw = JSON.parse(bytes.toString("utf8")); const normalized = validateSnapshotJson(raw, fallbackWorkflowId); - await fs.writeFile(path.join(outDir, file.name), JSON.stringify(normalized, null, 2) + '\n', 'utf8'); - console.log(`[playground-snapshots] Wrote ${file.name}`); + // Validate filename at point of use to prevent path traversal attacks + const safeFilename = path.basename(file.name); + if (!SAFE_FILENAME.test(safeFilename)) { + throw new Error(`[playground-snapshots] Refusing unsafe filename: ${safeFilename}`); + } + const outputPath = path.join(outDir, safeFilename); + if (!outputPath.startsWith(outDir + path.sep) && outputPath !== outDir) { + throw new Error(`[playground-snapshots] Refusing path outside output directory: ${outputPath}`); + } + + await fs.writeFile(outputPath, JSON.stringify(normalized, null, 2) + "\n", "utf8"); + console.log(`[playground-snapshots] Wrote ${safeFilename}`); } } async function fetchFromActionsApi() { if (!repo) { - console.warn('[playground-snapshots] PLAYGROUND_SNAPSHOTS_REPO not set; skipping fetch.'); + console.warn("[playground-snapshots] PLAYGROUND_SNAPSHOTS_REPO not set; skipping fetch."); return; } if (!token) { - throw new Error('[playground-snapshots] Missing token for Actions API mode. Set PLAYGROUND_SNAPSHOTS_TOKEN or GITHUB_TOKEN.'); + throw new Error("[playground-snapshots] Missing token for Actions API mode. Set PLAYGROUND_SNAPSHOTS_TOKEN or GITHUB_TOKEN."); } await fs.mkdir(outDir, { recursive: true }); - if (workflowsDir && workflowsDir !== '.github/workflows') { + if (workflowsDir && workflowsDir !== ".github/workflows") { console.warn( `[playground-snapshots] Note: Actions API mode can only fetch runs for workflows located in '.github/workflows'. ` + `You have PLAYGROUND_SNAPSHOTS_WORKFLOWS_DIR='${workflowsDir}'. ` + @@ -611,8 +593,8 @@ async function fetchFromActionsApi() { } let ids = workflowIdsCsv - .split(',') - .map((s) => s.trim()) + .split(",") + .map(s => s.trim()) .filter(Boolean); if (ids.length === 0) { @@ -620,7 +602,7 @@ async function fetchFromActionsApi() { } if (ids.length === 0) { - console.warn('[playground-snapshots] No workflow IDs found for Actions API mode; leaving existing snapshots as-is.'); + console.warn("[playground-snapshots] No workflow IDs found for Actions API mode; leaving existing snapshots as-is."); return; } @@ -630,11 +612,7 @@ async function fetchFromActionsApi() { // Clean output directory first so removals are reflected. const existing = await fs.readdir(outDir).catch(() => []); - await Promise.all( - existing - .filter((f) => f.endsWith('.json')) - .map((f) => fs.rm(path.join(outDir, f), { force: true })) - ); + await Promise.all(existing.filter(f => f.endsWith(".json")).map(f => fs.rm(path.join(outDir, f), { force: true }))); console.log(`[playground-snapshots] Fetching latest Actions runs from ${repo} (branch: ${branch})`); console.log(`[playground-snapshots] Workflows dir in repo: ${workflowsDir}`); @@ -647,8 +625,8 @@ async function fetchFromActionsApi() { } const snapshot = await fetchLatestRunSnapshotFromActionsApi(id); - const json = JSON.stringify(snapshot, null, 2) + '\n'; - const bytes = Buffer.from(json, 'utf8'); + const json = JSON.stringify(snapshot, null, 2) + "\n"; + const bytes = Buffer.from(json, "utf8"); if (bytes.length > MAX_FILE_BYTES) { throw new Error(`[playground-snapshots] Refusing oversized snapshot ${safeName} (${bytes.length} bytes; max ${MAX_FILE_BYTES}).`); @@ -659,18 +637,24 @@ async function fetchFromActionsApi() { throw new Error(`[playground-snapshots] Refusing snapshots total ${totalBytes} bytes (max ${MAX_TOTAL_BYTES}).`); } - await fs.writeFile(path.join(outDir, safeName), json, 'utf8'); + // Additional validation at point of use to prevent path traversal + const outputPath = path.join(outDir, path.basename(safeName)); + if (!outputPath.startsWith(outDir + path.sep) && outputPath !== outDir) { + throw new Error(`[playground-snapshots] Refusing path outside output directory: ${outputPath}`); + } + + await fs.writeFile(outputPath, json, "utf8"); console.log(`[playground-snapshots] Wrote ${safeName}`); } } async function main() { if (!repo) { - console.warn('[playground-snapshots] PLAYGROUND_SNAPSHOTS_REPO not set; skipping fetch.'); + console.warn("[playground-snapshots] PLAYGROUND_SNAPSHOTS_REPO not set; skipping fetch."); return; } - if (mode === 'actions') { + if (mode === "actions") { await fetchFromActionsApi(); return; } @@ -679,7 +663,7 @@ async function main() { await fetchFromContentsApi(); } -main().catch((err) => { +main().catch(err => { console.error(String(err?.stack || err)); process.exitCode = 1; }); From 365a500b0cdebd22e1ce281904ff33a3aa0c355f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:09:46 +0000 Subject: [PATCH 3/3] Format JavaScript files with prettier - Run prettier on all .mjs files in docs/scripts - Standardize quote style from single to double quotes - All JavaScript files now pass lint-cjs validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/scripts/fetch-playground-local.mjs | 102 +++++++++----------- docs/scripts/fetch-playground-org-owned.mjs | 26 +++-- docs/scripts/fetch-playground-workflows.mjs | 50 ++++------ 3 files changed, 80 insertions(+), 98 deletions(-) diff --git a/docs/scripts/fetch-playground-local.mjs b/docs/scripts/fetch-playground-local.mjs index 572ce0f888..0f65e61f3b 100644 --- a/docs/scripts/fetch-playground-local.mjs +++ b/docs/scripts/fetch-playground-local.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -13,23 +13,20 @@ function parseDotenv(content) { const vars = {}; for (const lineRaw of content.split(/\r?\n/)) { const line = lineRaw.trim(); - if (!line || line.startsWith('#')) continue; - const eq = line.indexOf('='); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); let value = line.slice(eq + 1).trim(); if (!key) continue; // Strip surrounding quotes (simple .env compatibility) - if ( - (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || - (value.startsWith("'") && value.endsWith("'") && value.length >= 2) - ) { + if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) { value = value.slice(1, -1); } // Support basic escaped newlines in quoted values. - value = value.replaceAll('\\n', '\n'); + value = value.replaceAll("\\n", "\n"); vars[key] = value; } @@ -39,11 +36,11 @@ function parseDotenv(content) { async function loadDotenvIfPresent(docsRoot) { // Node scripts do not automatically read .env files. // Load .env.local first, then .env (do not override real env vars). - const candidates = [path.join(docsRoot, '.env.local'), path.join(docsRoot, '.env')]; + const candidates = [path.join(docsRoot, ".env.local"), path.join(docsRoot, ".env")]; for (const envPath of candidates) { try { - const content = await fs.readFile(envPath, 'utf8'); + const content = await fs.readFile(envPath, "utf8"); const vars = parseDotenv(content); for (const [k, v] of Object.entries(vars)) { if (process.env[k] === undefined) process.env[k] = v; @@ -56,29 +53,29 @@ async function loadDotenvIfPresent(docsRoot) { function parseArgs(argv) { const args = { - repo: process.env.PLAYGROUND_REPO || '', - ref: process.env.PLAYGROUND_REF || 'main', - token: process.env.PLAYGROUND_TOKEN || process.env.GITHUB_TOKEN || '', - snapshotsPath: process.env.PLAYGROUND_SNAPSHOTS_PATH || 'docs/playground-snapshots', - snapshotsMode: process.env.PLAYGROUND_SNAPSHOTS_MODE || 'actions', - snapshotsBranch: process.env.PLAYGROUND_SNAPSHOTS_BRANCH || '', - prefix: process.env.PLAYGROUND_ID_PREFIX || '', - mdx: process.env.PLAYGROUND_MDX || 'src/content/docs/playground/index.mdx', - workflowsDir: process.env.PLAYGROUND_WORKFLOWS_DIR || '.github/workflows', + repo: process.env.PLAYGROUND_REPO || "", + ref: process.env.PLAYGROUND_REF || "main", + token: process.env.PLAYGROUND_TOKEN || process.env.GITHUB_TOKEN || "", + snapshotsPath: process.env.PLAYGROUND_SNAPSHOTS_PATH || "docs/playground-snapshots", + snapshotsMode: process.env.PLAYGROUND_SNAPSHOTS_MODE || "actions", + snapshotsBranch: process.env.PLAYGROUND_SNAPSHOTS_BRANCH || "", + prefix: process.env.PLAYGROUND_ID_PREFIX || "", + mdx: process.env.PLAYGROUND_MDX || "src/content/docs/playground/index.mdx", + workflowsDir: process.env.PLAYGROUND_WORKFLOWS_DIR || ".github/workflows", }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; - if (a === '--repo') args.repo = argv[++i] || ''; - else if (a === '--ref') args.ref = argv[++i] || 'main'; - else if (a === '--token') args.token = argv[++i] || ''; - else if (a === '--snapshots-path') args.snapshotsPath = argv[++i] || args.snapshotsPath; - else if (a === '--snapshots-mode') args.snapshotsMode = argv[++i] || args.snapshotsMode; - else if (a === '--snapshots-branch') args.snapshotsBranch = argv[++i] || args.snapshotsBranch; - else if (a === '--workflows-dir') args.workflowsDir = argv[++i] || args.workflowsDir; - else if (a === '--prefix') args.prefix = argv[++i] || args.prefix; - else if (a === '--mdx') args.mdx = argv[++i] || args.mdx; - else if (a === '--help' || a === '-h') { + if (a === "--repo") args.repo = argv[++i] || ""; + else if (a === "--ref") args.ref = argv[++i] || "main"; + else if (a === "--token") args.token = argv[++i] || ""; + else if (a === "--snapshots-path") args.snapshotsPath = argv[++i] || args.snapshotsPath; + else if (a === "--snapshots-mode") args.snapshotsMode = argv[++i] || args.snapshotsMode; + else if (a === "--snapshots-branch") args.snapshotsBranch = argv[++i] || args.snapshotsBranch; + else if (a === "--workflows-dir") args.workflowsDir = argv[++i] || args.workflowsDir; + else if (a === "--prefix") args.prefix = argv[++i] || args.prefix; + else if (a === "--mdx") args.mdx = argv[++i] || args.mdx; + else if (a === "--help" || a === "-h") { printHelp(); process.exit(0); } else { @@ -111,13 +108,13 @@ Environment equivalents: } async function readWorkflowIdsFromMdx(mdxPath, prefix) { - const mdx = await fs.readFile(mdxPath, 'utf8'); + const mdx = await fs.readFile(mdxPath, "utf8"); const ids = new Set(); const re = /\bid\s*:\s*['"]([^'"]+)['"]/g; let m; while ((m = re.exec(mdx)) !== null) { - const id = String(m[1] || '').trim(); + const id = String(m[1] || "").trim(); if (!id) continue; if (prefix && !id.startsWith(prefix)) continue; ids.add(id); @@ -130,30 +127,30 @@ function runNodeScript({ scriptPath, cwd, env }) { const res = spawnSync(process.execPath, [scriptPath], { cwd, env: { ...process.env, ...env }, - stdio: 'inherit', + stdio: "inherit", }); if (res.error) throw res.error; - if (typeof res.status === 'number' && res.status !== 0) { + if (typeof res.status === "number" && res.status !== 0) { throw new Error(`Script failed: ${path.basename(scriptPath)} (exit ${res.status})`); } } async function main() { - const docsRoot = path.resolve(__dirname, '..'); + const docsRoot = path.resolve(__dirname, ".."); await loadDotenvIfPresent(docsRoot); const args = parseArgs(process.argv); if (!args.repo) { - console.error('[playground-local] Missing --repo (or PLAYGROUND_REPO).'); + console.error("[playground-local] Missing --repo (or PLAYGROUND_REPO)."); printHelp(); process.exit(2); } if (!args.token) { - console.error('[playground-local] Missing token. Set PLAYGROUND_TOKEN or pass --token.'); - console.error('[playground-local] For fine-grained PAT: give read access to Contents + Metadata on the repo.'); + console.error("[playground-local] Missing token. Set PLAYGROUND_TOKEN or pass --token."); + console.error("[playground-local] For fine-grained PAT: give read access to Contents + Metadata on the repo."); process.exit(2); } @@ -162,14 +159,11 @@ async function main() { const ids = await readWorkflowIdsFromMdx(mdxPath, args.prefix); if (ids.length === 0) { if (args.prefix) { - const fallbackIds = await readWorkflowIdsFromMdx(mdxPath, ''); + const fallbackIds = await readWorkflowIdsFromMdx(mdxPath, ""); if (fallbackIds.length > 0) { - console.warn( - `[playground-local] No workflow IDs found with prefix '${args.prefix}' in ${args.mdx}. ` + - `Falling back to fetching all workflows listed in that file.` - ); + console.warn(`[playground-local] No workflow IDs found with prefix '${args.prefix}' in ${args.mdx}. ` + `Falling back to fetching all workflows listed in that file.`); // eslint-disable-next-line no-param-reassign - args.prefix = ''; + args.prefix = ""; // eslint-disable-next-line no-param-reassign ids.length = 0; ids.push(...fallbackIds); @@ -184,12 +178,12 @@ async function main() { const repoPaths = []; for (const id of ids) { - repoPaths.push(`${args.workflowsDir.replace(/\/$/, '')}/${id}.md`); - repoPaths.push(`${args.workflowsDir.replace(/\/$/, '')}/${id}.lock.yml`); + repoPaths.push(`${args.workflowsDir.replace(/\/$/, "")}/${id}.md`); + repoPaths.push(`${args.workflowsDir.replace(/\/$/, "")}/${id}.lock.yml`); } - const workflowsScript = path.resolve(__dirname, 'fetch-playground-workflows.mjs'); - const snapshotsScript = path.resolve(__dirname, 'fetch-playground-snapshots.mjs'); + const workflowsScript = path.resolve(__dirname, "fetch-playground-workflows.mjs"); + const snapshotsScript = path.resolve(__dirname, "fetch-playground-snapshots.mjs"); console.log(`[playground-local] Repo: ${args.repo}@${args.ref}`); console.log(`[playground-local] Workflows: ${ids.length} (prefix '${args.prefix}')`); @@ -201,7 +195,7 @@ async function main() { PLAYGROUND_WORKFLOWS_REPO: args.repo, PLAYGROUND_WORKFLOWS_REF: args.ref, PLAYGROUND_WORKFLOWS_TOKEN: args.token, - PLAYGROUND_WORKFLOWS_FILES: repoPaths.join(','), + PLAYGROUND_WORKFLOWS_FILES: repoPaths.join(","), }, }); @@ -216,14 +210,14 @@ async function main() { PLAYGROUND_SNAPSHOTS_MODE: args.snapshotsMode, PLAYGROUND_SNAPSHOTS_BRANCH: args.snapshotsBranch || args.ref, PLAYGROUND_SNAPSHOTS_WORKFLOWS_DIR: args.workflowsDir, - PLAYGROUND_SNAPSHOTS_WORKFLOW_IDS: ids.join(','), + PLAYGROUND_SNAPSHOTS_WORKFLOW_IDS: ids.join(","), }, }); - console.log('[playground-local] Done. Start the dev server with: npm run dev'); + console.log("[playground-local] Done. Start the dev server with: npm run dev"); } -main().catch((err) => { +main().catch(err => { console.error(String(err?.stack || err)); process.exitCode = 1; }); diff --git a/docs/scripts/fetch-playground-org-owned.mjs b/docs/scripts/fetch-playground-org-owned.mjs index fe822589b8..cb713aecfe 100644 --- a/docs/scripts/fetch-playground-org-owned.mjs +++ b/docs/scripts/fetch-playground-org-owned.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; +import fs from "node:fs/promises"; +import path from "node:path"; -const outDir = path.resolve('src/assets/playground-workflows/org-owned'); +const outDir = path.resolve("src/assets/playground-workflows/org-owned"); const MAX_FILES = Number(process.env.PLAYGROUND_ORG_WORKFLOWS_MAX_FILES || 25); const MAX_FILE_BYTES = Number(process.env.PLAYGROUND_ORG_WORKFLOWS_MAX_FILE_BYTES || 1024 * 1024); @@ -13,15 +13,15 @@ const SAFE_BASENAME = /^[a-z0-9][a-z0-9._-]{0,200}$/; async function main() { // Comma-separated list of repo-relative file paths to copy into the docs bundle. - const filesCsv = process.env.PLAYGROUND_ORG_WORKFLOWS_FILES || ''; + const filesCsv = process.env.PLAYGROUND_ORG_WORKFLOWS_FILES || ""; const files = filesCsv - .split(',') - .map((s) => s.trim()) + .split(",") + .map(s => s.trim()) .filter(Boolean); if (files.length === 0) { - console.warn('[playground-org-owned] PLAYGROUND_ORG_WORKFLOWS_FILES not set; skipping.'); + console.warn("[playground-org-owned] PLAYGROUND_ORG_WORKFLOWS_FILES not set; skipping."); return; } @@ -30,7 +30,7 @@ async function main() { } // Script runs with CWD=docs/, so ".." is repo root. - const repoRoot = path.resolve('..'); + const repoRoot = path.resolve(".."); await fs.mkdir(outDir, { recursive: true }); @@ -48,16 +48,12 @@ async function main() { const bytes = await fs.readFile(srcPath); if (bytes.length > MAX_FILE_BYTES) { - throw new Error( - `[playground-org-owned] Refusing oversized file ${basename} (${bytes.length} bytes; max ${MAX_FILE_BYTES}).` - ); + throw new Error(`[playground-org-owned] Refusing oversized file ${basename} (${bytes.length} bytes; max ${MAX_FILE_BYTES}).`); } totalBytes += bytes.length; if (totalBytes > MAX_TOTAL_BYTES) { - throw new Error( - `[playground-org-owned] Refusing files total ${totalBytes} bytes (max ${MAX_TOTAL_BYTES}).` - ); + throw new Error(`[playground-org-owned] Refusing files total ${totalBytes} bytes (max ${MAX_TOTAL_BYTES}).`); } const destPath = path.join(outDir, basename); @@ -66,7 +62,7 @@ async function main() { } } -main().catch((err) => { +main().catch(err => { console.error(String(err?.stack || err)); process.exitCode = 1; }); diff --git a/docs/scripts/fetch-playground-workflows.mjs b/docs/scripts/fetch-playground-workflows.mjs index 1308f376d7..a60f554746 100644 --- a/docs/scripts/fetch-playground-workflows.mjs +++ b/docs/scripts/fetch-playground-workflows.mjs @@ -1,19 +1,19 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; -import path from 'node:path'; +import fs from "node:fs/promises"; +import path from "node:path"; const repo = process.env.PLAYGROUND_WORKFLOWS_REPO; // "owner/repo" -const ref = process.env.PLAYGROUND_WORKFLOWS_REF || 'main'; +const ref = process.env.PLAYGROUND_WORKFLOWS_REF || "main"; const token = process.env.PLAYGROUND_WORKFLOWS_TOKEN || process.env.GITHUB_TOKEN; // Comma-separated list of repo-relative file paths to fetch. // Example: // .github/workflows/playground-user-project-update-draft.md, // .github/workflows/playground-user-project-update-draft.lock.yml -const filesCsv = process.env.PLAYGROUND_WORKFLOWS_FILES || ''; +const filesCsv = process.env.PLAYGROUND_WORKFLOWS_FILES || ""; -const outDir = path.resolve('src/assets/playground-workflows/user-owned'); +const outDir = path.resolve("src/assets/playground-workflows/user-owned"); const MAX_FILES = Number(process.env.PLAYGROUND_WORKFLOWS_MAX_FILES || 25); const MAX_FILE_BYTES = Number(process.env.PLAYGROUND_WORKFLOWS_MAX_FILE_BYTES || 1024 * 1024); @@ -29,8 +29,8 @@ function headerAuth() { async function ghJson(url) { const res = await fetch(url, { headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", ...headerAuth(), }, }); @@ -49,26 +49,22 @@ async function verifyRepoAccess() { const url = `https://api.github.com/repos/${repo}`; const res = await fetch(url, { headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", ...headerAuth(), }, }); if (res.ok) return; - const body = await res.text().catch(() => ''); + const body = await res.text().catch(() => ""); if (res.status === 404) { throw new Error( - `[playground-workflows] Cannot access repo '${repo}'. GitHub returned 404 for the repo endpoint.\n` + - `This usually means the token is missing access to the private repo (or the repo name/ref is wrong).\n` + - `Response: ${body}` + `[playground-workflows] Cannot access repo '${repo}'. GitHub returned 404 for the repo endpoint.\n` + `This usually means the token is missing access to the private repo (or the repo name/ref is wrong).\n` + `Response: ${body}` ); } - throw new Error( - `[playground-workflows] Repo access check failed (${res.status} ${res.statusText}).\nResponse: ${body}` - ); + throw new Error(`[playground-workflows] Repo access check failed (${res.status} ${res.statusText}).\nResponse: ${body}`); } async function download(url) { @@ -79,19 +75,19 @@ async function download(url) { async function main() { if (!repo) { - console.warn('[playground-workflows] PLAYGROUND_WORKFLOWS_REPO not set; skipping fetch.'); + console.warn("[playground-workflows] PLAYGROUND_WORKFLOWS_REPO not set; skipping fetch."); return; } await verifyRepoAccess(); const files = filesCsv - .split(',') - .map((s) => s.trim()) + .split(",") + .map(s => s.trim()) .filter(Boolean); if (files.length === 0) { - console.warn('[playground-workflows] PLAYGROUND_WORKFLOWS_FILES not set; skipping fetch.'); + console.warn("[playground-workflows] PLAYGROUND_WORKFLOWS_FILES not set; skipping fetch."); return; } @@ -105,23 +101,19 @@ async function main() { let totalBytes = 0; for (const repoPath of files) { - const url = `https://api.github.com/repos/${repo}/contents/${repoPath.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`; + const url = `https://api.github.com/repos/${repo}/contents/${repoPath.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`; let info; try { info = await ghJson(url); } catch (err) { const msg = String(err?.message || err); - if (msg.includes('GitHub API 404')) { - throw new Error( - `[playground-workflows] File not found at '${repoPath}' (ref '${ref}').\n` + - `If the repo is private and you expected this file to exist, double-check token permissions and the path.\n` + - `Original error: ${msg}` - ); + if (msg.includes("GitHub API 404")) { + throw new Error(`[playground-workflows] File not found at '${repoPath}' (ref '${ref}').\n` + `If the repo is private and you expected this file to exist, double-check token permissions and the path.\n` + `Original error: ${msg}`); } throw err; } - if (!info || typeof info !== 'object' || info.type !== 'file' || typeof info.download_url !== 'string') { + if (!info || typeof info !== "object" || info.type !== "file" || typeof info.download_url !== "string") { throw new Error(`[playground-workflows] Unexpected contents API response for ${repoPath}`); } @@ -146,7 +138,7 @@ async function main() { } } -main().catch((err) => { +main().catch(err => { console.error(String(err?.stack || err)); process.exitCode = 1; });