diff --git a/src/cli.ts b/src/cli.ts index 4063ddd3..75fddf46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -919,6 +919,21 @@ program } }); +program + .command("export") + .description("Bundle the whole scaffold into a single Markdown document") + .option("--out ", "Write to a file instead of stdout") + .action(async (opts) => { + try { + const config = loadConfig(); + const { runExport } = await import("./export.js"); + await runExport(config, opts); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + }); + program .command("timeline") .description("Show recent mex event log entries") diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 00000000..5711dc9f --- /dev/null +++ b/src/export.ts @@ -0,0 +1,43 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { DEFAULT_SCAFFOLD_PATTERNS, findScaffoldFiles } from "./drift/index.js"; +import { toPosix } from "./paths.js"; +import type { MexConfig } from "./types.js"; + +export interface ExportOpts { + /** Write the bundle to this file instead of stdout. */ + out?: string; +} + +/** + * Bundle the whole scaffold into one Markdown document (#56). + * + * Section headers name the source file so a pasted copy stays navigable, and + * files are emitted in a deterministic order (sorted by path). Reuses the + * drift scanner's own file discovery, so what gets exported is exactly what + * `mex check` scans — nothing drifts between the two. + */ +export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promise { + const files = findScaffoldFiles(config.projectRoot, config.scaffoldRoot, DEFAULT_SCAFFOLD_PATTERNS) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + + if (files.length === 0) { + throw new Error("No scaffold files found. Run: mex setup"); + } + + const bundle: string[] = ["# mex scaffold export", ""]; + for (const file of files) { + const relativePath = toPosix(relative(config.scaffoldRoot, file)); + bundle.push(`## ${relativePath}`, "", readFileSync(file, "utf-8").trimEnd(), ""); + } + const document = bundle.join("\n"); + + if (opts.out) { + const target = resolve(config.projectRoot, opts.out); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, document, "utf-8"); + console.log(`Wrote ${files.length} scaffold file(s) to ${opts.out}`); + return; + } + process.stdout.write(document); +} diff --git a/test/export.test.ts b/test/export.test.ts new file mode 100644 index 00000000..7a70b2ad --- /dev/null +++ b/test/export.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runExport } from "../src/export.js"; +import type { MexConfig } from "../src/types.js"; + +let tmpDir: string; +let config: MexConfig; +let stdoutSpy: ReturnType; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mex-export-")); + mkdirSync(join(tmpDir, ".mex/context"), { recursive: true }); + mkdirSync(join(tmpDir, ".mex/patterns"), { recursive: true }); + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "# Router\n\nEntry point.\n"); + writeFileSync(join(tmpDir, ".mex/context/stack.md"), "# Stack\n\nNode 22.\n"); + writeFileSync(join(tmpDir, ".mex/patterns/retry.md"), "# Retry\n"); + config = { projectRoot: tmpDir, scaffoldRoot: join(tmpDir, ".mex"), aiTools: [] }; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("mex export (#56)", () => { + it("bundles every scaffold file under a header per source file", async () => { + await runExport(config, {}); + const document = stdoutSpy.mock.calls.map((call) => String(call[0])).join(""); + + expect(document).toContain("# mex scaffold export"); + expect(document).toContain("## ROUTER.md"); + expect(document).toContain("## context/stack.md"); + expect(document).toContain("## patterns/retry.md"); + // Content survives intact under its own header. + expect(document).toContain("Entry point."); + expect(document).toContain("Node 22."); + // Deterministic order: sorted by path. + expect(document.indexOf("## ROUTER.md")).toBeLessThan(document.indexOf("## context/stack.md")); + expect(document.indexOf("## context/stack.md")).toBeLessThan(document.indexOf("## patterns/retry.md")); + }); + + it("writes the same bundle to --out and reports the count", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runExport(config, { out: "exports/scaffold.md" }); + expect(stdoutSpy.mock.calls.map((call) => String(call[0])).join("")).toBe(""); + const written = readFileSync(join(tmpDir, "exports/scaffold.md"), "utf-8"); + expect(written).toContain("## ROUTER.md"); + expect(logSpy.mock.calls.map((call) => String(call[0])).join("")) + .toContain("Wrote 3 scaffold file(s) to exports/scaffold.md"); + }); + + it("fails with guidance when the scaffold is missing", async () => { + rmSync(join(tmpDir, ".mex"), { recursive: true, force: true }); + await expect(runExport(config, {})).rejects.toThrow("No scaffold files found. Run: mex setup"); + }); +}); diff --git a/test/wiki-architecture.test.ts b/test/wiki-architecture.test.ts index eceadb85..ce34a2bd 100644 --- a/test/wiki-architecture.test.ts +++ b/test/wiki-architecture.test.ts @@ -761,6 +761,7 @@ describe("no unscoped scaffold writes", () => { "src/config.ts": "writes config.json", "src/global-config.ts": "writes the global config and telemetry id", "src/events.ts": "appends to events/decisions.jsonl", + "src/export.ts": "writes one export bundle to a user-specified path — a brand-new file, never scaffold bytes", "src/pattern/index.ts": "creates a new pattern file from a template", "src/setup/anchor.ts": "edits root tool configs only, never .mex/, and only inside its own markers", "src/setup/ignore.ts": "creates or appends only the setup-managed .mex/.gitignore rules",