Skip to content
Closed
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
56 changes: 56 additions & 0 deletions sources/nodeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}
6 changes: 2 additions & 4 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions tests/Use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
58 changes: 58 additions & 0 deletions tests/nodeUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});