diff --git a/sources/nodeUtils.ts b/sources/nodeUtils.ts index c83f59bb9..36e8c8164 100644 --- a/sources/nodeUtils.ts +++ b/sources/nodeUtils.ts @@ -52,3 +52,59 @@ export function readPackageJson(content: string) { indent: getIndent(content), }; } + +export function setPackageManager(content: string, packageManager: string) { + const {data, indent} = readPackageJson(content); + const value = JSON.stringify(packageManager); + + if (content === ``) + return normalizeLineEndings(content, `${JSON.stringify({packageManager}, null, indent)}\n`); + + if (typeof data !== `object` || data === null || Array.isArray(data)) + throw new TypeError(`Expected package.json to contain an object`); + + const edits: Array<{start: number, end: number}> = []; + let depth = 0; + let name: string | undefined; + let valueStart = -1; + let lastTokenEnd = 0; + + // JSON.parse above validates the input. Tokenize strings as a whole so that + // escaped quotes and punctuation inside them cannot affect nesting depth. + for (const match of content.matchAll(/"(?:\\.|[^"\\])*"|[^\s]/g)) { + const token = match[0]; + if (depth === 1) { + if (token === `,` || token === `}`) { + if (name === `packageManager`) + edits.push({start: valueStart, end: lastTokenEnd}); + + name = undefined; + valueStart = -1; + } else if (name === undefined) { + name = JSON.parse(token); + } else if (token !== `:` && valueStart === -1) { + valueStart = match.index; + } + + if (token === `}`) { + break; + } + } + + if (token === `{` || token === `[`) depth++; + if (token === `}` || token === `]`) depth--; + lastTokenEnd = match.index + token.length; + } + + if (edits.length > 0) { + // Update duplicate keys too, without touching similarly named nested keys. + for (const {start, end} of edits.reverse()) + content = content.slice(0, start) + value + content.slice(end); + + return content; + } + + const separator = Object.keys(data).length > 0 ? `,` : ``; + const spacing = /\r?\n/.test(content.slice(0, content.lastIndexOf(`}`))) ? `${getEndOfLine(content)}${indent}` : ``; + return `${content.slice(0, lastTokenEnd)}${separator}${spacing}"packageManager": ${value}${content.slice(lastTokenEnd)}`; +} diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 275c49058..d25d58e53 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -143,12 +143,10 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM ? await fs.promises.readFile(lookup.target, `utf8`) : ``; - const {data, indent} = nodeUtils.readPackageJson(content); + const {data} = nodeUtils.readPackageJson(content); const previousPackageManager = data.packageManager ?? (devEnginesValue ? `${devEnginesValue.name}@${devEnginesValue.version ?? `*`}` : `unknown`); - data.packageManager = `${info.locator.name}@${info.locator.reference}`; - - const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`); + const newContent = nodeUtils.setPackageManager(content, `${info.locator.name}@${info.locator.reference}`); await fs.promises.writeFile(lookup.target, newContent, `utf8`); return { diff --git a/tests/Use.test.ts b/tests/Use.test.ts index 6dd37939e..532f69c6f 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -17,6 +17,33 @@ beforeEach(async () => { }); describe(`UseCommand`, () => { + it.each([ + {command: [`use`, `pnpm@1.9998.9999`], existing: false}, + {command: [`use`, `pnpm@1.9998.9999`], existing: true}, + {command: [`up`], existing: true}, + {command: [`pnpm`, `--version`], existing: false}, + ])(`preserves manifest formatting with $command (existing: $existing)`, async ({command, existing}) => { + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; + process.env.TEST_INTEGRITY = `valid`; + process.env.COREPACK_DEFAULT_TO_LATEST = `1`; + process.env.COREPACK_ENABLE_AUTO_PIN = `1`; + + await xfs.mktempPromise(async cwd => { + const manifest = ppath.join(cwd, `package.json`); + const before = `{\r\n\t"name" : "example",\r\n\r\n\t"scripts": {"test": "echo ok"}${existing ? `,\r\n\t"packageManager" : "pnpm@1.0.0"` : ``}\r\n}\r\n`; + await xfs.writeFilePromise(manifest, before); + + await expect(runCli(cwd, command, true)).resolves.toMatchObject({exitCode: 0}); + + const after = await xfs.readFilePromise(manifest, `utf8`); + const {packageManager} = JSON.parse(after); + expect(packageManager).toMatch(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-f]{128}$/); + expect(after).toBe(existing + ? before.replace(`pnpm@1.0.0`, packageManager) + : before.replace(`}\r\n}`, `},\r\n\t"packageManager": "${packageManager}"\r\n}`)); + }); + }); + describe(`should set the package manager in the current project`, () => { it(`With an existing 'packageManager' field`, async () => { await xfs.mktempPromise(async cwd => { diff --git a/tests/nodeUtils.test.ts b/tests/nodeUtils.test.ts new file mode 100644 index 000000000..194cd0ab5 --- /dev/null +++ b/tests/nodeUtils.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it} from 'vitest'; + +import {setPackageManager} from '../sources/nodeUtils.ts'; + +describe(`setPackageManager`, () => { + const version = `pnpm@9.0.0+sha512.abc`; + const value = JSON.stringify(version); + + it.each([`\n`, `\r\n`])(`preserves blank lines and unrelated values with %j line endings`, eol => { + const before = [ + `{`, + `\t"name" : "example",`, + ``, + `\t"scripts": {"test": "echo \\"ok\\""},`, + `\t"number": 1e+2,`, + ``, + `\t"dependencies": {`, + `\t\t"a": "1",`, + ``, + `\t\t"b": "2"`, + `\t}`, + ``, + `}`, + ``, + ].join(eol); + const expected = before.replace(`${eol}\t}${eol}`, `${eol}\t},${eol}\t"packageManager": ${value}${eol}`); + + expect(setPackageManager(before, version)).toBe(expected); + expect(JSON.parse(expected).packageManager).toBe(version); + }); + + it.each([`"old"`, `null`, `false`, `123`, `[]`, `{"nested": ["}", {"packageManager": "untouched"}]}`])(`replaces an existing %s value without reformatting`, old => { + const before = `{\n "name": "example",\n\n "packageManager" : ${old} ,\n "private": true\n}`; + expect(setPackageManager(before, version)).toBe(before.replace(`: ${old}`, `: ${value}`)); + }); + + it(`recognizes escaped and duplicate top-level keys`, () => { + const before = `{"packageManager":"first", "package\\u004danager": ["last"]}`; + expect(setPackageManager(before, version)).toBe(`{"packageManager":${value}, "package\\u004danager": ${value}}`); + }); + + it(`does not replace nested keys or punctuation inside strings`, () => { + const before = `{"nested":[{"packageManager":"old"}],"text":"\\"},[packageManager"}`; + expect(setPackageManager(before, version)).toBe(`${before.slice(0, -1)},"packageManager": ${value}}`); + }); + + it.each([`{}`, `{}\n`, `{}\r\n`, `{ }`, `\uFEFF{}\n`])(`preserves whitespace around an empty object %j`, before => { + expect(setPackageManager(before, version)).toBe(before.replace(`{`, `{"packageManager": ${value}`)); + }); + + it(`creates a new manifest`, () => { + expect(JSON.parse(setPackageManager(``, version))).toEqual({packageManager: version}); + }); + + it(`rejects invalid JSON before editing`, () => { + expect(() => setPackageManager(`{"name": }`, version)).toThrow(SyntaxError); + }); +});