/** * Sync docs from main CopilotKit docs to the showcase platform. * * Reads changed files from docs/content/docs/ and docs/snippets/, * applies structural transforms, and writes to showcase/shell-docs/src/content/. * * Usage: * npx tsx showcase/scripts/sync-docs-from-main.ts * npx tsx showcase/scripts/sync-docs-from-main.ts --dry-run * npx tsx showcase/scripts/sync-docs-from-main.ts --all (sync all files, not just changed) */ import fs from "fs"; import os from "os"; import path from "path"; import { execFileSync } from "child_process"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, "../.."); const MAIN_DOCS = path.join(ROOT, "docs/content/docs"); const MAIN_SNIPPETS = path.join(ROOT, "docs/snippets"); // MDX docs content moved from shell to shell-docs (which owns the docs // hostname). Sync target updated in lockstep — all docs authoring/sync // flows through shell-docs now. const SHOWCASE_DOCS = path.join(ROOT, "showcase/shell-docs/src/content/docs"); const SHOWCASE_SNIPPETS = path.join( ROOT, "showcase/shell-docs/src/content/snippets", ); const SYNC_MARKER = path.join(ROOT, "showcase/shell-docs/.docs-sync-sha"); // LangChain -> LangGraph exclusions (these intentionally keep "LangChain") const LANGCHAIN_EXCLUSIONS = [ "LangChain's tool call definitions", "LangChain tool call format", "langchain.agents", "langchain_core", ]; /** * Path patterns that should never be synced from upstream into shell-docs, * applied to relative paths (e.g. `docs/content/docs/integrations/mastra/(other)/...`). * * Per-framework files that exist only as one-line snippet stubs (``, * ``, etc.) are excluded here. Shell-docs renders the real * content at the root path via the same shared-snippet component, and the * framework-scoped router falls back to root MDX when no per-framework * override exists, so the stubs add nothing — they only cluttered the disk * and reappeared on every sync. * * Per-framework `(other)/` subtrees ship byte-duplicate `contributing/` + * `telemetry/` content across every framework; shell-docs owns the * canonical copy at root `(other)/`. * * The `/learn/` tree is retired in shell-docs (PRs #4494/#4496 promoted * the seven explanation pages into Concepts/Premium and the multi- * conversation tutorial into /tutorials/). All `/learn/*` URLs are * served via redirects in `next.config.ts`, and the physical files * must NOT be re-introduced by the sync script. * * The root `ag-ui-middleware.mdx` was moved to * `agentic-protocols/ag-ui-middleware.mdx` in PR #4496 (with a 302 * redirect). The root path stays excluded so future syncs don't restore * the duplicate. * * The shared `mcp-server-setup.mdx` snippet is also excluded * (PDX-117 — https://linear.app/copilotkit/issue/PDX-117). Shell-docs * ships an enhanced version with HTTP/SSE transport Tabs, the * `mcp-remote` bridge, and a Tadata Callout; upstream still carries * the older single-`url` JSON shape, so syncing would regress the * shell-docs experience. * * Upstream keeps all of these copies — removing them there means touching * every parallel framework tree, which is upstream-IA work outside this * branch's scope. The exclusion is the durable shell-docs-only fix. */ const PATH_EXCLUSIONS: RegExp[] = [ /^docs\/content\/docs\/integrations\/[^/]+\/\(other\)\//, /^docs\/content\/docs\/integrations\/[^/]+\/threads\.mdx$/, /^docs\/content\/docs\/integrations\/[^/]+\/premium\/self-hosting\.mdx$/, /^docs\/content\/docs\/learn\//, /^docs\/content\/docs\/\(root\)\/ag-ui-middleware\.mdx$/, /^docs\/snippets\/shared\/guides\/mcp-server-setup\.mdx$/, // PR #4494 dropped these stale workflow-execution / state-inputs-outputs // duplicates from shell-docs. Each shared-state meta.json wires only one // of the two files; the other was an orphan. Block the sync from // restoring them. /^docs\/content\/docs\/integrations\/langgraph\/shared-state\/workflow-execution\.mdx$/, /^docs\/content\/docs\/integrations\/adk\/shared-state\/(workflow-execution|state-inputs-outputs)\.mdx$/, /^docs\/content\/docs\/integrations\/llamaindex\/shared-state\/state-inputs-outputs\.mdx$/, // AgentCore content was inlined into the canonical // `deploy/agentcore.mdx` page (see PR #4514 follow-up). Block the // upstream 3-shell + shared-snippet sources from re-flowing in: // - the upstream root shell that delegates to `` // - the per-framework shells that delegate to `` // - the 355-line shared snippet that powers them /^docs\/content\/docs\/\(root\)\/deploy\/agentcore\.mdx$/, /^docs\/content\/docs\/integrations\/[^/]+\/deploy-agentcore\.mdx$/, /^docs\/snippets\/integrations\/agentcore\//, // Landing-page collapse (commit 8adbebd30 "merge docs landing + // /quickstart picker"): the upstream root `index.mdx` + `quickstart.mdx` // were consolidated into a single shell-docs `/` route. Block the // upstream sources from re-introducing the separate landing pages. /^docs\/content\/docs\/\(root\)\/index\.mdx$/, /^docs\/content\/docs\/\(root\)\/quickstart\.mdx$/, // The top-level `prebuilt-components.mdx` page is duplicative of the // `prebuilt-components/` directory's index + sub-pages in shell-docs. // Block the upstream single-file version from re-flowing in. /^docs\/content\/docs\/\(root\)\/prebuilt-components\.mdx$/, // Framework landing pages (commit d1cd9f06a "collapse framework landing // into shell"): the upstream `integrations//index.mdx` pages were // collapsed into the canonical `//` route generated by the shell-docs // framework picker. Block the upstream sources from re-introducing the // per-framework landing files. /^docs\/content\/docs\/integrations\/langgraph\/index\.mdx$/, /^docs\/content\/docs\/integrations\/microsoft-agent-framework\/index\.mdx$/, // reference/v2/* duplicates: shell-docs's canonical reference path is // `showcase/shell-docs/src/content/reference/**` (no `v2` segment, no // `docs/` prefix). The upstream `docs/content/docs/reference/v2/*` // files mirror that content under a parallel `docs/`-prefixed path // that doesn't exist in shell-docs's routing. Syncing them in creates // duplicate parallel files. Per-file follow-up: mirror legitimate // updates from upstream's reference/v2 into the canonical reference/ // tree as needed (see e.g. `useCopilotKit.mdx` diff in PR #4771). /^docs\/content\/docs\/reference\/v2\/index\.mdx$/, /^docs\/content\/docs\/reference\/v2\/components\/CopilotChat\.mdx$/, /^docs\/content\/docs\/reference\/v2\/components\/CopilotKit\.mdx$/, /^docs\/content\/docs\/reference\/v2\/hooks\/useCopilotKit\.mdx$/, /^docs\/content\/docs\/reference\/v2\/hooks\/useThreads\.mdx$/, ]; function isExcludedPath(relPath: string): boolean { return PATH_EXCLUSIONS.some((re) => re.test(relPath)); } // --------------------------------------------------------------------------- // Re-introduction detector // --------------------------------------------------------------------------- /** * Compute the set of paths that exist in showcase docs git history as * deletions but are NOT currently present in the working tree. * * Background: PATH_EXCLUSIONS is the durable mechanism for keeping retired * upstream paths out of shell-docs. But it requires whoever retires a page * to also remember to add the regex — and historically that step has been * missed (PR #4521 brought back /learn/* and the root ag-ui-middleware * duplicate that earlier PRs had deliberately removed). * * This detector is the safety net: if a sync run is about to create a file * at a path that previously existed and was deleted, surface it in the * "needs review" PR body so a human can confirm the re-introduction is * intentional. If it isn't, the fix is to add the upstream regex to * PATH_EXCLUSIONS and delete the file again — at which point this detector * picks it up on the next sync, the loop closes. * * Cached: `git log` over the docs tree is non-trivial; we run it once. */ let cachedHistoricallyDeleted: Set | null = null; function getHistoricallyDeletedShowcasePaths(): Set { if (cachedHistoricallyDeleted !== null) return cachedHistoricallyDeleted; const result = new Set(); try { const out = execFileSync( "git", [ "log", "--all", "--diff-filter=D", "--pretty=format:", "--name-only", "--", "showcase/shell-docs/src/content/docs/", "showcase/shell-docs/src/content/snippets/", ], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }, ); for (const line of out.split("\n")) { const trimmed = line.trim(); if (!trimmed || !trimmed.endsWith(".mdx")) continue; // Only flag paths the working tree no longer carries — files that were // deleted and later re-created intentionally aren't re-introductions. if (!fs.existsSync(path.join(ROOT, trimmed))) { result.add(trimmed); } } } catch (err: unknown) { console.warn( `[WARN] could not compute historical deletions; re-intro detector disabled: ${err instanceof Error ? err.message : err}`, ); } cachedHistoricallyDeleted = result; return result; } /** * `true` when this sync would write to a showcase path that previously * existed in shell-docs and was deleted. Caller flags for manual review. * * Returns `false` for files that currently exist in the worktree (those * are updates, not re-introductions) and for any path that has no record * of prior deletion in git history. */ function isReintroductionOfDeletedPath(showcaseAbsolutePath: string): boolean { if (fs.existsSync(showcaseAbsolutePath)) return false; const rel = path.relative(ROOT, showcaseAbsolutePath); return getHistoricallyDeletedShowcasePaths().has(rel); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Normalize trailing-EOL + leading BOM for idempotency comparisons. * * - Strips a leading UTF-8 BOM (U+FEFF) that editors sometimes insert. * - Strips ALL trailing whitespace (including \r\n, extra blank lines, * trailing spaces, tabs). Editors that re-save a file with an extra * trailing newline or BOM should not trigger spurious rewrites. * * Intentional internal whitespace (inside the file body) is preserved — * only the edges are normalized. */ function stripTrailingEol(s: string): string { // Strip leading BOM if present. if (s.charCodeAt(0) === 0xfeff) { s = s.slice(1); } // Strip all trailing whitespace (newlines, spaces, tabs, \r). return s.replace(/\s+$/, ""); } // --------------------------------------------------------------------------- // Transform pipeline // --------------------------------------------------------------------------- /** * Strip all MDX-level import statements between frontmatter and first content. * Preserves imports inside code fences. */ function stripMdxImports(content: string): string { const lines = content.split("\n"); const result: string[] = []; let inFrontmatter = false; let frontmatterClosed = false; let inCodeFence = false; let importSectionDone = false; let inMultiLineImport = false; for (const line of lines) { // Track frontmatter boundaries if (line.trim() === "---") { if (!inFrontmatter && !frontmatterClosed) { inFrontmatter = true; result.push(line); continue; } else if (inFrontmatter) { inFrontmatter = false; frontmatterClosed = true; result.push(line); continue; } } if (inFrontmatter) { result.push(line); continue; } // Track code fences if (line.trim().startsWith("```")) { inCodeFence = !inCodeFence; result.push(line); continue; } if (inCodeFence) { result.push(line); continue; } // After frontmatter, before first content: strip import lines if (frontmatterClosed && !importSectionDone) { // If we're inside a multi-line import, skip until closing line if (inMultiLineImport) { if (line.includes("} from") || line.trimStart().startsWith("from ")) { inMultiLineImport = false; } // Skip this line either way (it's part of the import) continue; } if ( line.trim().startsWith("import ") || line.trim().startsWith("import{") ) { // Check if this is a multi-line import (no `from` on same line) if ( !line.includes(" from ") && !line.includes(' from"') && !line.includes(" from'") ) { inMultiLineImport = true; } // Skip this import line continue; } // Also skip blank lines in the import section if (line.trim() === "") { continue; } // First non-import, non-blank line: import section is done importSectionDone = true; } result.push(line); } return result.join("\n"); } /** * Remove `components={props.components}` from component usage. */ function stripComponentsProps(content: string): string { return content.replace(/ components=\{props\.components\}/g, ""); } /** * Replace "LangChain" with "LangGraph" in langgraph integration files, * respecting known exclusions and preserving code fence content. * Code blocks may contain legitimate `langchain` package references * that must not be renamed (validated by executable doc tests on main). */ function replaceLangChainWithLangGraph( content: string, filePath: string, ): string { // Only apply in langgraph integration directory if (!filePath.includes("integrations/langgraph")) { return content; } const lines = content.split("\n"); const result: string[] = []; let inCodeFence = false; for (const line of lines) { // Track code fences — never rename inside them if (line.trim().startsWith("```")) { inCodeFence = !inCodeFence; result.push(line); continue; } if (inCodeFence) { result.push(line); continue; } // Check if this line contains an exclusion const hasExclusion = LANGCHAIN_EXCLUSIONS.some((exc) => line.includes(exc)); if (hasExclusion) { result.push(line); } else { result.push(line.replace(/LangChain/g, "LangGraph")); } } return result.join("\n"); } /** * Apply the full transform pipeline to a file's content. */ function transformContent(content: string, filePath: string): string { let result = content; result = stripMdxImports(result); result = stripComponentsProps(result); result = replaceLangChainWithLangGraph(result, filePath); return result; } // --------------------------------------------------------------------------- // Path mapping // --------------------------------------------------------------------------- /** * Map a main docs path to its showcase equivalent. * Strips the (root)/ directory prefix if present. */ function mainToShowcasePath(mainPath: string): string { // docs/content/docs/(root)/quickstart.mdx -> showcase/shell-docs/src/content/docs/quickstart.mdx let rel = path.relative(MAIN_DOCS, mainPath); // Strip (root)/ prefix if (rel.startsWith("(root)/") || rel.startsWith("(root)\\")) { rel = rel.slice(7); } return path.join(SHOWCASE_DOCS, rel); } function mainSnippetToShowcasePath(mainPath: string): string { const rel = path.relative(MAIN_SNIPPETS, mainPath); return path.join(SHOWCASE_SNIPPETS, rel); } // --------------------------------------------------------------------------- // Showcase-local modification detection // --------------------------------------------------------------------------- /** * Check if a showcase file has been locally modified beyond standard transforms. * Compares the current showcase file against a "clean transform" of the previous main version. */ function hasShowcaseLocalModifications( showcasePath: string, mainPath: string, lastSyncSha: string, ): boolean { if (!fs.existsSync(showcasePath)) { return false; // New file, no local mods } // Get the main file content at the last sync point try { const mainRelative = path.relative(ROOT, mainPath); const previousMainContent = execFileSync( "git", ["show", `${lastSyncSha}:${mainRelative}`], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }, ); const cleanTransform = transformContent(previousMainContent, showcasePath); const currentShowcase = fs.readFileSync(showcasePath, "utf-8"); return ( stripTrailingEol(cleanTransform) !== stripTrailingEol(currentShowcase) ); } catch (err: unknown) { // File didn't exist at last sync — safe to overwrite if (err instanceof Error && "status" in err) { return false; } // Unexpected error — be conservative, flag for review console.warn( `[WARN] hasShowcaseLocalModifications failed for ${showcasePath}: ${err}`, ); return true; } } // --------------------------------------------------------------------------- // Diff detection // --------------------------------------------------------------------------- function getLastSyncSha(): string { if (fs.existsSync(SYNC_MARKER)) { return fs.readFileSync(SYNC_MARKER, "utf-8").trim(); } // No marker: use a reasonable default (100 commits ago) try { return execFileSync("git", ["rev-parse", "HEAD~100"], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], }).trim(); } catch { throw new Error( "Cannot determine sync baseline: HEAD~100 is unreachable (shallow clone?). " + "Run with --all to sync all files, or deepen the clone with `git fetch --unshallow`.", ); } } function getChangedFiles(sinceSha: string): string[] { const output = execFileSync( "git", [ "diff", "--name-only", `${sinceSha}..HEAD`, "--", "docs/content/docs/", "docs/snippets/", ], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }, ); return output .split("\n") .filter((f) => f.trim() && f.endsWith(".mdx") && !isExcludedPath(f)); } function getAllFiles(): string[] { const files: string[] = []; function walk(dir: string): void { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); } else if (entry.name.endsWith(".mdx")) { const rel = path.relative(ROOT, full); if (!isExcludedPath(rel)) files.push(rel); } } } walk(MAIN_DOCS); walk(MAIN_SNIPPETS); return files; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- interface ConflictEntry { relPath: string; showcasePath: string; content: string; } interface SyncResult { copied: string[]; transformed: string[]; needsReview: string[]; autoMerged: string[]; mergeConflict: string[]; skipped: string[]; deleted: string[]; reintroduced: string[]; conflictManifest: ConflictEntry[]; } // --------------------------------------------------------------------------- // 3-way merge for showcase-local modifications // --------------------------------------------------------------------------- /** * Attempt a 3-way merge for a file with showcase-local modifications. * * - base = clean transform of main file at the last sync sha * - local = current showcase file on disk * - remote = clean transform of main file at HEAD * * Returns { success: true, content } if `git merge-file` produced a clean * merge (exit 0, no conflict markers). * * Returns { success: false, content: upstreamTransformed } if merge produced * conflicts or failed to compute a base. Caller writes upstream-wins content * and flags the file for manual review in the PR body. */ function attemptThreeWayMerge( showcasePath: string, mainPath: string, lastSyncSha: string, ): { success: boolean; content: string } { const upstreamContent = fs.readFileSync(mainPath, "utf-8"); const upstreamTransformed = transformContent(upstreamContent, showcasePath); if (!fs.existsSync(showcasePath)) { return { success: true, content: upstreamTransformed }; } const localContent = fs.readFileSync(showcasePath, "utf-8"); let baseContent: string; try { const mainRelative = path.relative(ROOT, mainPath); const previousMainContent = execFileSync( "git", ["show", `${lastSyncSha}:${mainRelative}`], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }, ); baseContent = transformContent(previousMainContent, showcasePath); } catch { // No base available — cannot 3-way merge safely return { success: false, content: upstreamTransformed }; } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docs-sync-")); const localFile = path.join(tmpDir, "local"); const baseFile = path.join(tmpDir, "base"); const remoteFile = path.join(tmpDir, "remote"); try { fs.writeFileSync(localFile, localContent); fs.writeFileSync(baseFile, baseContent); fs.writeFileSync(remoteFile, upstreamTransformed); // `git merge-file` writes merged result in-place into `localFile`. // Exit code 0 = clean merge; >0 = number of conflicts remaining; null = // killed by signal (treat as failure). let cleanMerge = false; try { execFileSync( "git", [ "merge-file", "-L", "showcase", "-L", "base", "-L", "upstream", localFile, baseFile, remoteFile, ], { stdio: "pipe" }, ); cleanMerge = true; } catch (err: unknown) { // status === null means killed by signal — treat as failure, not clean. // status > 0 means N conflicts remaining — treat as failure. if (err instanceof Error && "status" in err) { const status = (err as { status: number | null }).status; if (status === null) { return { success: false, content: upstreamTransformed }; } // conflicts remain; fall through to upstream-wins } else { return { success: false, content: upstreamTransformed }; } } if (cleanMerge) { const merged = fs.readFileSync(localFile, "utf-8"); return { success: true, content: merged }; } return { success: false, content: upstreamTransformed }; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } } } function parseArgs(): { dryRun: boolean; all: boolean } { return { dryRun: process.argv.includes("--dry-run"), all: process.argv.includes("--all"), }; } function main(): SyncResult { const { dryRun, all } = parseArgs(); const lastSyncSha = getLastSyncSha(); const headSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8", cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], }).trim(); console.log("=== Docs Sync ==="); console.log(`Last sync: ${lastSyncSha.slice(0, 8)}`); console.log(`Current HEAD: ${headSha.slice(0, 8)}`); if (dryRun) console.log("[DRY RUN]"); // Get files to process const changedRelPaths = all ? getAllFiles() : getChangedFiles(lastSyncSha); console.log(`Files to process: ${changedRelPaths.length}`); const result: SyncResult = { copied: [], transformed: [], needsReview: [], autoMerged: [], mergeConflict: [], skipped: [], deleted: [], reintroduced: [], conflictManifest: [], }; // Upstream-wins content for merge-conflict files. Not written to the // current worktree — workflow applies these to the PR branch only so // that future sync runs on main still detect local drift until the // needs-review PR is merged by a human. const conflictManifest: ConflictEntry[] = result.conflictManifest; for (const relPath of changedRelPaths) { const mainAbsolute = path.join(ROOT, relPath); const isSnippet = relPath.startsWith("docs/snippets/"); const showcaseAbsolute = isSnippet ? mainSnippetToShowcasePath(mainAbsolute) : mainToShowcasePath(mainAbsolute); // File deleted on main if (!fs.existsSync(mainAbsolute)) { if (fs.existsSync(showcaseAbsolute)) { result.deleted.push(relPath); console.log( ` [DELETE?] ${relPath} (removed on main, exists in showcase)`, ); } continue; } // Check for showcase-local modifications — attempt 3-way merge and // always produce file content. Clean merge = auto-applied; conflicting // merge = upstream-wins CONTENT captured but NOT written to the current // worktree — workflow applies it to the PR branch only so that future // sync runs on main still detect the local drift until a human merges. if ( hasShowcaseLocalModifications(showcaseAbsolute, mainAbsolute, lastSyncSha) ) { result.needsReview.push(relPath); const merge = attemptThreeWayMerge( showcaseAbsolute, mainAbsolute, lastSyncSha, ); // If the result is identical to what's already on disk, nothing to do. const existingContent = fs.existsSync(showcaseAbsolute) ? fs.readFileSync(showcaseAbsolute, "utf-8") : null; if ( existingContent !== null && stripTrailingEol(existingContent) === stripTrailingEol(merge.content) ) { result.skipped.push(relPath); if (merge.success) { console.log( ` [REVIEW/NOOP] ${relPath} (merged content matches existing)`, ); } else { console.log( ` [REVIEW/NOOP] ${relPath} (upstream-wins content matches existing)`, ); } continue; } if (merge.success) { // Clean 3-way merge: safe to write to the worktree; it reflects an // auto-applied resolution of both local + upstream changes. if (!dryRun) { fs.mkdirSync(path.dirname(showcaseAbsolute), { recursive: true }); fs.writeFileSync(showcaseAbsolute, merge.content); } result.autoMerged.push(relPath); console.log(` [REVIEW/AUTO-MERGED] ${relPath} (3-way merge clean)`); } else { // Conflict: DO NOT write upstream-wins to the current worktree. // Record into a manifest — the workflow applies these files to the // PR branch only. Leaving the worktree untouched preserves the // invariant that future sync runs on main still flag this file for // review until a human merges the needs-review PR. conflictManifest.push({ relPath, showcasePath: path.relative(ROOT, showcaseAbsolute), content: merge.content, }); result.mergeConflict.push(relPath); console.log( ` [REVIEW/CONFLICT] ${relPath} (3-way merge failed; upstream-wins content staged to manifest — manual review required)`, ); } continue; } // Read and transform const mainContent = fs.readFileSync(mainAbsolute, "utf-8"); const transformed = transformContent(mainContent, showcaseAbsolute); // Check if transform changed anything const isIdentical = mainContent === transformed; // Check if showcase already has this content if (fs.existsSync(showcaseAbsolute)) { const existing = fs.readFileSync(showcaseAbsolute, "utf-8"); if (stripTrailingEol(existing) === stripTrailingEol(transformed)) { result.skipped.push(relPath); continue; // Already up to date } } // Detect re-introduction of a previously-deleted showcase path BEFORE // writing — fs.existsSync would flip to true after the write. The check // is informational; we still write the file so the sync stays in flow, // but we flag the path for manual review (and force the PR off the // auto-merge path via exit 3). const isReintro = isReintroductionOfDeletedPath(showcaseAbsolute); // Write if (!dryRun) { fs.mkdirSync(path.dirname(showcaseAbsolute), { recursive: true }); fs.writeFileSync(showcaseAbsolute, transformed); } if (isIdentical) { result.copied.push(relPath); console.log(` [COPY] ${relPath}`); } else { result.transformed.push(relPath); console.log(` [TRANSFORM] ${relPath}`); } if (isReintro) { result.reintroduced.push(relPath); console.log( ` [REVIEW/REINTRODUCED] ${relPath} → ${path.relative(ROOT, showcaseAbsolute)} (previously deleted in shell-docs; verify intent)`, ); } } // Update sync marker whenever all files were successfully resolved: // clean transforms, clean auto-merges, or no changes at all. A successful // 3-way auto-merge means the file IS resolved — its content on disk // reflects the merged state, so the next run should treat HEAD as the // new base. Only outstanding merge conflicts (upstream-wins written to // PR branch only) or deletions (not auto-applied) keep the old sha so // future runs still flag them until a human merges the needs-review PR. if ( !dryRun && result.mergeConflict.length === 0 && result.deleted.length === 0 && (result.copied.length > 0 || result.transformed.length > 0 || result.autoMerged.length > 0) ) { fs.writeFileSync(SYNC_MARKER, headSha + "\n"); } // Summary console.log("\n=== Summary ==="); console.log(`Copied (identical): ${result.copied.length}`); console.log(`Transformed: ${result.transformed.length}`); console.log(`Auto-merged (3-way clean): ${result.autoMerged.length}`); console.log( `Merge conflict (upstream-wins, needs review): ${result.mergeConflict.length}`, ); console.log(`Needs review (total): ${result.needsReview.length}`); console.log(`Skipped (up to date): ${result.skipped.length}`); console.log(`Deleted on main: ${result.deleted.length}`); console.log( `Re-introduced from deletion history (review required): ${result.reintroduced.length}`, ); if (result.autoMerged.length > 0) { console.log("\nFiles auto-merged (3-way clean):"); for (const f of result.autoMerged) console.log(` ${f}`); } if (result.mergeConflict.length > 0) { console.log( "\nFiles written with upstream-wins (merge conflict — manual review):", ); for (const f of result.mergeConflict) console.log(` ${f}`); } if (result.deleted.length > 0) { console.log("\nFiles deleted on main (not auto-deleted in showcase):"); for (const f of result.deleted) { console.log(` ${f}`); } } if (result.reintroduced.length > 0) { console.log( "\nFiles re-introduced from shell-docs deletion history (verify intent before merging):", ); for (const f of result.reintroduced) { console.log(` ${f}`); } } return result; } const result = main(); // Exit codes for CI: // 0 = changes auto-applied (clean transforms / clean 3-way merges); safe to // push + auto-merge // 2 = nothing changed // 3 = has review items (either 3-way merge conflicts where upstream won, // or files deleted on main); CI opens a PR but does NOT auto-merge const hasAutoApplied = result.copied.length > 0 || result.transformed.length > 0 || result.autoMerged.length > 0; // Only files that still need human attention force push_and_pr: // - mergeConflict: 3-way merge failed, upstream-wins content staged to PR // branch, human must reconcile // - deleted: files gone on main, not auto-deleted in showcase, human decides // - reintroduced: sync wrote to a path previously deleted in shell-docs; // human confirms the re-introduction is intentional or adds the upstream // regex to PATH_EXCLUSIONS // Auto-merged files (clean 3-way merge) are considered RESOLVED — they go // through the auto_push fast path and the marker advances. `needsReview` // is the superset tracker (local-mods detected pre-merge) and is NOT a // gating condition on its own. const hasReviewItems = result.mergeConflict.length > 0 || result.deleted.length > 0 || result.reintroduced.length > 0; if (!hasAutoApplied && !hasReviewItems) { process.exit(2); } else if (hasReviewItems) { // Write review items to a file for CI to include in PR body. // These files are already written to disk — CI will commit them and open // a PR flagged "needs review" (no auto-merge). const reviewLines: string[] = []; if (result.mergeConflict.length > 0) { reviewLines.push( "Files where 3-way merge FAILED — upstream content written as-is, local modifications overridden. Manual review REQUIRED before merging this PR:", ); for (const f of result.mergeConflict) reviewLines.push(` - ${f}`); } if (result.autoMerged.length > 0) { reviewLines.push(""); reviewLines.push( "Files auto-merged via 3-way merge (clean, no conflict markers — still worth a glance):", ); for (const f of result.autoMerged) reviewLines.push(` - ${f}`); } if (result.deleted.length > 0) { reviewLines.push(""); reviewLines.push( "Files deleted on main (review whether to delete in showcase):", ); for (const f of result.deleted) reviewLines.push(` - ${f}`); } if (result.reintroduced.length > 0) { reviewLines.push(""); reviewLines.push( "Files re-introduced from shell-docs deletion history — these paths previously existed and were intentionally removed. Confirm the re-introduction is wanted; if not, add a PATH_EXCLUSIONS regex in showcase/scripts/sync-docs-from-main.ts and delete the file again:", ); for (const f of result.reintroduced) reviewLines.push(` - ${f}`); } // Write manifest + review items at the invocation cwd (the repo root // when run from CI, matching the workflow's `[ -f conflict-manifest.json ]` // and `cat review-items.txt` checks). Using process.cwd() rather than // ROOT makes the contract explicit: whoever invokes the script picks // where these artifacts land, and the workflow invokes from repo root. const artifactDir = process.cwd(); const reviewItemsPath = path.join(artifactDir, "review-items.txt"); fs.writeFileSync(reviewItemsPath, reviewLines.join("\n") + "\n"); // Emit the absolute path to GITHUB_OUTPUT so the workflow's PR-body and // Slack payload steps can read the file without hardcoding a location. // (Downstream steps use `steps.sync.outputs.review_items_file`.) Skipped // outside CI — process.env.GITHUB_OUTPUT is unset for local runs. if (process.env.GITHUB_OUTPUT) { fs.appendFileSync( process.env.GITHUB_OUTPUT, `review_items_file=${reviewItemsPath}\n`, ); } // Persist the conflict manifest so the workflow can apply upstream-wins // content to the PR branch (NOT the current worktree — see // conflictManifest comment above). if (result.conflictManifest.length > 0) { fs.writeFileSync( path.join(artifactDir, "conflict-manifest.json"), JSON.stringify(result.conflictManifest, null, 2) + "\n", ); } process.exit(3); } else { process.exit(0); }