diff --git a/.changeset/init-keeps-line-endings.md b/.changeset/init-keeps-line-endings.md new file mode 100644 index 0000000..d9d89a4 --- /dev/null +++ b/.changeset/init-keeps-line-endings.md @@ -0,0 +1,5 @@ +--- +"@paramour-js/next": patch +--- + +`paramour init` keeps the line endings of the files it edits. In a CRLF checkout (git `core.autocrlf=true`), it used to report "updated paramour section" for an unchanged `AGENTS.md`/`CLAUDE.md` section and leave the file with mixed CRLF and LF line endings. It also rewrote a CRLF `package.json` or `next.config` entirely in LF. It now recognizes its own section regardless of line endings, and writes each edit using the file's existing line ending. Wrapping `next.config` also no longer drops the file's final newline. diff --git a/packages/next/src/emit.ts b/packages/next/src/emit.ts index b78daac..ad85f66 100644 --- a/packages/next/src/emit.ts +++ b/packages/next/src/emit.ts @@ -15,6 +15,16 @@ export interface WriteIfChangedResult { written: boolean; } +/** + * The line ending a user-owned file already uses. Init edits files the user + * owns (package.json, next.config, AGENTS.md), and writing LF text into a CRLF file + * leaves mixed endings, so those edits keep the file's own ending. Generated + * files are always written LF instead. + */ +export function lineEnding(text: string): "\n" | "\r\n" { + return text.includes("\r\n") ? "\r\n" : "\n"; +} + /** * Content equality that ignores CRLF vs LF. A consumer repo with git * `core.autocrlf=true` checks the committed LF artifact out as CRLF; that diff --git a/packages/next/src/init/agents-md.ts b/packages/next/src/init/agents-md.ts index 77867dd..85d5ca3 100644 --- a/packages/next/src/init/agents-md.ts +++ b/packages/next/src/init/agents-md.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { lineEnding, sameContent } from "../emit.js"; + export const AGENTS_MARKER_START = ""; export const AGENTS_MARKER_END = ""; @@ -59,20 +61,21 @@ export function upsertAgentsSection(text: string): { status: "added" | "unchanged" | "unterminated" | "updated"; text: string; } { + const eol = lineEnding(text); + const snippet = agentsSnippet().replaceAll("\n", eol); const start = text.indexOf(AGENTS_MARKER_START); if (start === -1) { - const base = text === "" || text.endsWith("\n") ? text : `${text}\n`; - const separator = base === "" ? "" : "\n"; + const base = text === "" || text.endsWith("\n") ? text : `${text}${eol}`; + const separator = base === "" ? "" : eol; return { status: "added", - text: `${base}${separator}${agentsSnippet()}\n`, + text: `${base}${separator}${snippet}${eol}`, }; } const end = text.indexOf(AGENTS_MARKER_END, start); if (end === -1) return { status: "unterminated", text }; const current = text.slice(start, end + AGENTS_MARKER_END.length); - const snippet = agentsSnippet(); - if (current === snippet) return { status: "unchanged", text }; + if (sameContent(current, snippet)) return { status: "unchanged", text }; return { status: "updated", text: diff --git a/packages/next/src/init/scaffold.ts b/packages/next/src/init/scaffold.ts index 885104d..fda9340 100644 --- a/packages/next/src/init/scaffold.ts +++ b/packages/next/src/init/scaffold.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { lineEnding } from "../emit.js"; import { resolveRouteDirs } from "../scan.js"; import { loadPackagedSkill } from "../skills/packaged.js"; import { auditTarget, isOutdated } from "../skills/sync.js"; @@ -15,9 +16,9 @@ export interface SetupCheck { /** * Insert `"paramour": "paramour generate"` into a package.json's scripts, - * preserving the file's own indentation and trailing-newline choice. - * Throws on malformed JSON — a broken package.json is init's one hard - * prerequisite failure. + * preserving the file's own indentation, line ending, and trailing-newline + * choice. Throws on malformed JSON — a broken package.json is init's one + * hard prerequisite failure. */ export function addPackageScript(text: string): { changed: boolean; @@ -41,8 +42,12 @@ export function addPackageScript(text: string): { if (scripts?.paramour !== undefined) return { changed: false, text }; pkg.scripts = { ...scripts, paramour: "paramour generate" }; const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; - const trailing = text.endsWith("\n") ? "\n" : ""; - return { changed: true, text: JSON.stringify(pkg, null, indent) + trailing }; + const eol = lineEnding(text); + const trailing = text.endsWith("\n") ? eol : ""; + return { + changed: true, + text: JSON.stringify(pkg, null, indent).replaceAll("\n", eol) + trailing, + }; } /** diff --git a/packages/next/src/init/wrap-next-config.ts b/packages/next/src/init/wrap-next-config.ts index 72cb0de..648008d 100644 --- a/packages/next/src/init/wrap-next-config.ts +++ b/packages/next/src/init/wrap-next-config.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { lineEnding } from "../emit.js"; + /** * The `paramour init` next.config codemod (magicast: recast printing + * babel-ts parsing, so TS/ESM configs transform format-preservingly). @@ -179,7 +181,12 @@ export async function wrapNextConfigSource( local ?? "withTypedRoutes", current, ); - return { code: generateCode(mod).code, status: "wrapped" }; + // recast prints new nodes with LF and drops the final newline; keep the + // user's file as it was on both counts. + const eol = lineEnding(source); + let code = generateCode(mod).code.replaceAll("\r\n", "\n"); + if (source.endsWith("\n") && !code.endsWith("\n")) code += "\n"; + return { code: code.replaceAll("\n", eol), status: "wrapped" }; } catch { return { snippet: manualSnippet(), status: "manual" }; } diff --git a/packages/next/test/init.test.ts b/packages/next/test/init.test.ts index 301fdf1..a50d339 100644 --- a/packages/next/test/init.test.ts +++ b/packages/next/test/init.test.ts @@ -573,6 +573,59 @@ describe("paramour init", () => { expect(snapshotTree(root)).toEqual(before); }); + it("treats a CRLF checkout of its own section as up to date", async () => { + // git core.autocrlf=true checks the committed LF file out as CRLF. + const checkedOut = `${AGENTS_MD}\n${agentsSnippet()}\n`.replaceAll( + "\n", + "\r\n", + ); + const root = makeProject(["app/page.tsx"], { + "CLAUDE.md": checkedOut, + "package.json": PACKAGE_JSON, + }); + const run = await init(["--no-wrap"]); + expect(run.out.join("\n")).toContain( + "• CLAUDE.md paramour section already up to date — skipped", + ); + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toBe(checkedOut); + }); + + it("keeps a CRLF instructions file CRLF when appending and updating", async () => { + const root = makeProject(["app/page.tsx"], { + "AGENTS.md": AGENTS_MD.replaceAll("\n", "\r\n"), + "package.json": PACKAGE_JSON, + }); + await init(["--no-wrap"]); + const appended = readFileSync(join(root, "AGENTS.md"), "utf8"); + expect(appended).toContain(AGENTS_MARKER_START); + expect(appended).not.toMatch(/(? { + const root = makeProject(["app/page.tsx"], { + "package.json": PACKAGE_JSON.replaceAll("\n", "\r\n"), + }); + const run = await init(["--no-wrap"]); + expect(run.out.join("\n")).toContain( + `✔ added "paramour" script to package.json`, + ); + const text = readFileSync(join(root, "package.json"), "utf8"); + expect(text).toContain('"paramour": "paramour generate"'); + expect(text).not.toMatch(/(? { const root = makeProject(["app/page.tsx"], { "AGENTS.md": "# Notes without newline", diff --git a/packages/next/test/wrap-next-config.test.ts b/packages/next/test/wrap-next-config.test.ts index 56172d5..e6f2cd6 100644 --- a/packages/next/test/wrap-next-config.test.ts +++ b/packages/next/test/wrap-next-config.test.ts @@ -30,6 +30,29 @@ describe("wrapNextConfigSource", () => { ); // The untouched parts keep their original formatting (recast). expect(result.code).toContain(" reactStrictMode: true,"); + expect( + result.code.endsWith("export default withTypedRoutes(nextConfig);\n"), + ).toBe(true); + }); + + it("keeps a CRLF config CRLF, including the added import", async () => { + const result = await wrapNextConfigSource( + IDENTIFIER_TS.replaceAll("\n", "\r\n"), + ); + expect(result.status).toBe("wrapped"); + if (result.status !== "wrapped") return; + expect(result.code).toContain( + "export default withTypedRoutes(nextConfig);", + ); + expect(result.code).not.toMatch(/(? { + const result = await wrapNextConfigSource(IDENTIFIER_TS.trimEnd()); + expect(result.status).toBe("wrapped"); + if (result.status !== "wrapped") return; + expect(result.code.endsWith("withTypedRoutes(nextConfig);")).toBe(true); }); it("wraps an object-literal default export", async () => {