Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,21 @@ program
}
});

program
.command("export")
.description("Bundle the whole scaffold into a single Markdown document")
.option("--out <path>", "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")
Expand Down
43 changes: 43 additions & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
59 changes: 59 additions & 0 deletions test/export.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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");
});
});
1 change: 1 addition & 0 deletions test/wiki-architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading