Skip to content

Commit

Permalink
Manage .npmrc (#1413)
Browse files Browse the repository at this point in the history
  • Loading branch information
AaronMoat authored Feb 5, 2024
1 parent 74231ae commit 814175f
Show file tree
Hide file tree
Showing 17 changed files with 863 additions and 229 deletions.
7 changes: 7 additions & 0 deletions .changeset/itchy-avocados-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'skuba': minor
---

lint: Manage `.npmrc` for pnpm projects

skuba now manages a section of `.npmrc` when a project uses `pnpm` to enable [dependency hoisting](https://pnpm.io/npmrc#dependency-hoisting-settings). It will continue to avoid committing autofixes to the file if it contains auth secrets.
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
public-hoist-pattern[]="@types*"
public-hoist-pattern[]="*eslint*"
public-hoist-pattern[]="*prettier*"
public-hoist-pattern[]="esbuild"
public-hoist-pattern[]="jest"
public-hoist-pattern[]="tsconfig-seek"
# end managed by skuba

Expand Down
32 changes: 20 additions & 12 deletions src/cli/__snapshots__/format.int.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
exports[`fixable 1`] = `
"
skuba lints
Refreshed .eslintignore. refresh-ignore-files
Refreshed .gitignore. refresh-ignore-files
Refreshed .prettierignore. refresh-ignore-files
Updating skuba...
Patch skipped: Add empty exports to Jest files for compliance with TypeScript isolated modules
Expand All @@ -16,8 +13,13 @@ Patch skipped: Upgrade Node.js Distroless Docker image to -debian12 variant - no
Patch skipped: Add keepAliveTimeout to server listener - no listener file found
Patch skipped: Move .npmrc out of the .gitignore managed section - no .gitignore file found
skuba update complete.
Refreshed .eslintignore. refresh-config-files
Refreshed .gitignore. refresh-config-files
Refreshed .prettierignore. refresh-config-files
Processed skuba lints in <random>s.
ESLint
Expand Down Expand Up @@ -70,9 +72,6 @@ d.js
exports[`ok --debug 1`] = `
"
skuba lints
Refreshed .eslintignore. refresh-ignore-files
Refreshed .gitignore. refresh-ignore-files
Refreshed .prettierignore. refresh-ignore-files
Updating skuba...
Patch skipped: Add empty exports to Jest files for compliance with TypeScript isolated modules
Expand All @@ -83,8 +82,13 @@ Patch skipped: Upgrade Node.js Distroless Docker image to -debian12 variant - no
Patch skipped: Add keepAliveTimeout to server listener - no listener file found
Patch skipped: Move .npmrc out of the .gitignore managed section - no .gitignore file found
skuba update complete.
Refreshed .eslintignore. refresh-config-files
Refreshed .gitignore. refresh-config-files
Refreshed .prettierignore. refresh-config-files
Processed skuba lints in <random>s.
ESLint
Expand Down Expand Up @@ -130,9 +134,6 @@ exports[`ok --debug 2`] = `
exports[`ok 1`] = `
"
skuba lints
Refreshed .eslintignore. refresh-ignore-files
Refreshed .gitignore. refresh-ignore-files
Refreshed .prettierignore. refresh-ignore-files
Updating skuba...
Patch skipped: Add empty exports to Jest files for compliance with TypeScript isolated modules
Expand All @@ -143,8 +144,13 @@ Patch skipped: Upgrade Node.js Distroless Docker image to -debian12 variant - no
Patch skipped: Add keepAliveTimeout to server listener - no listener file found
Patch skipped: Move .npmrc out of the .gitignore managed section - no .gitignore file found
skuba update complete.
Refreshed .eslintignore. refresh-config-files
Refreshed .gitignore. refresh-config-files
Refreshed .prettierignore. refresh-config-files
Processed skuba lints in <random>s.
ESLint
Expand All @@ -163,9 +169,6 @@ exports[`ok 2`] = `
exports[`unfixable 1`] = `
"
skuba lints
Refreshed .eslintignore. refresh-ignore-files
Refreshed .gitignore. refresh-ignore-files
Refreshed .prettierignore. refresh-ignore-files
Updating skuba...
Patch skipped: Add empty exports to Jest files for compliance with TypeScript isolated modules
Expand All @@ -176,8 +179,13 @@ Patch skipped: Upgrade Node.js Distroless Docker image to -debian12 variant - no
Patch skipped: Add keepAliveTimeout to server listener - no listener file found
Patch skipped: Move .npmrc out of the .gitignore managed section - no .gitignore file found
skuba update complete.
Refreshed .eslintignore. refresh-config-files
Refreshed .gitignore. refresh-config-files
Refreshed .prettierignore. refresh-config-files
Processed skuba lints in <random>s.
ESLint
Expand Down
5 changes: 5 additions & 0 deletions src/cli/configure/upgrade/patches/7.3.1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Patches } from '../..';
import { tryPatchRenovateConfig } from '../../../patchRenovateConfig';

import { tryAddEmptyExports } from './addEmptyExports';
import { tryMoveNpmrcOutOfGitignoreManagedSection } from './moveNpmrcOutOfGitignoreManagedSection';
import { tryPatchDockerfile } from './patchDockerfile';
import { tryPatchServerListener } from './patchServerListener';

Expand All @@ -23,4 +24,8 @@ export const patches: Patches = [
apply: tryPatchServerListener,
description: 'Add keepAliveTimeout to server listener',
},
{
apply: tryMoveNpmrcOutOfGitignoreManagedSection,
description: 'Move .npmrc out of the .gitignore managed section',
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import fs from 'fs-extra';

import * as packageAnalysis from '../../../analysis/package';
import * as projectAnalysis from '../../../analysis/project';

import { tryMoveNpmrcOutOfGitignoreManagedSection } from './moveNpmrcOutOfGitignoreManagedSection';

jest
.spyOn(packageAnalysis, 'getDestinationManifest')
.mockResolvedValue({ path: '~/project/package.json' } as any);

const createDestinationFileReader = jest
.spyOn(projectAnalysis, 'createDestinationFileReader')
.mockReturnValue(() => {
throw new Error('Not implemented!');
});

const writeFile = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();

beforeEach(jest.clearAllMocks);

describe('tryMoveNpmrcOutOfGitignoreManagedSection', () => {
describe('format mode', () => {
it('moves a .gitignore out', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('format', '~/project'),
).resolves.toEqual({
result: 'apply',
});

expect(writeFile.mock.calls.flat().join('\n')).toMatchInlineSnapshot(`
"~/project/.gitignore
# managed by skuba
stuff
other stuff
# end managed by skuba
# Ignore .npmrc. This is no longer managed by skuba as pnpm projects use a managed .npmrc.
# IMPORTANT: if migrating to pnpm, remove this line and add an .npmrc IN THE SAME COMMIT.
# You can use \`skuba format\` to generate the file or otherwise commit an empty file.
# Doing so will conflict with a local .npmrc and make it more difficult to unintentionally commit auth secrets.
.npmrc
"
`);
});

it('should be a no-op if ignored then un-ignored', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba\n!.npmrc`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('format', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'not ignored',
});

expect(writeFile).not.toHaveBeenCalled();
});

it('should be a no-op if ignored out of managed section', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba\n.npmrc`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('format', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'already ignored in unmanaged section',
});

expect(writeFile).not.toHaveBeenCalled();
});

it('should be a no-op if not ignored', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(`# managed by skuba\nstuff\n# end managed by skuba`),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('format', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'not ignored',
});

expect(writeFile).not.toHaveBeenCalled();
});
});

describe('lint mode', () => {
it('flags moving a .gitignore out', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('lint', '~/project'),
).resolves.toEqual({
result: 'apply',
});

expect(writeFile).not.toHaveBeenCalled();
});

it('should be a no-op if ignored then un-ignored', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba\n!.npmrc`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('lint', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'not ignored',
});

expect(writeFile).not.toHaveBeenCalled();
});

it('should be a no-op if ignored out of managed section', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(
`# managed by skuba\nstuff\n.npmrc\nother stuff\n# end managed by skuba\n.npmrc`,
),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('lint', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'already ignored in unmanaged section',
});

expect(writeFile).not.toHaveBeenCalled();
});

it('should be a no-op if not ignored', async () => {
createDestinationFileReader.mockReturnValue(() =>
Promise.resolve(`# managed by skuba\nstuff\n# end managed by skuba`),
);

await expect(
tryMoveNpmrcOutOfGitignoreManagedSection('lint', '~/project'),
).resolves.toEqual({
result: 'skip',
reason: 'not ignored',
});

expect(writeFile).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import path from 'path';
import { inspect } from 'util';

import fs from 'fs-extra';

import type { PatchFunction, PatchReturnType } from '../..';
import { log } from '../../../../../utils/logging';
import { createDestinationFileReader } from '../../../analysis/project';

const NPMRC_IGNORE_SECTION = `
# Ignore .npmrc. This is no longer managed by skuba as pnpm projects use a managed .npmrc.
# IMPORTANT: if migrating to pnpm, remove this line and add an .npmrc IN THE SAME COMMIT.
# You can use \`skuba format\` to generate the file or otherwise commit an empty file.
# Doing so will conflict with a local .npmrc and make it more difficult to unintentionally commit auth secrets.
.npmrc
`;

const moveNpmrcOutOfGitignoreManagedSection = async (
mode: 'format' | 'lint',
dir: string,
): Promise<PatchReturnType> => {
const readFile = createDestinationFileReader(dir);

const gitignore = await readFile('.gitignore');

if (!gitignore) {
return { result: 'skip', reason: 'no .gitignore file found' };
}

let isIgnored: { inManaged: boolean } | undefined;
let currentlyInManagedSection = false;

for (const line of gitignore.split('\n')) {
if (line.trim() === '# managed by skuba') {
currentlyInManagedSection = true;
} else if (line.trim() === '# end managed by skuba') {
currentlyInManagedSection = false;
}

if (line.trim() === '.npmrc' || line.trim() === '/.npmrc') {
isIgnored = { inManaged: currentlyInManagedSection };
}

if (line.trim() === '!.npmrc' || line.trim() === '!/.npmrc') {
isIgnored = undefined;
}
}

if (isIgnored && !isIgnored.inManaged) {
return { result: 'skip', reason: 'already ignored in unmanaged section' };
}

if (!isIgnored) {
return { result: 'skip', reason: 'not ignored' };
}

if (mode === 'lint') {
return { result: 'apply' };
}

const newGitignore =
gitignore
.split('\n')
.filter((line) => line.trim().replace(/^[!/]+/g, '') !== '.npmrc')
.join('\n')
.trim() + NPMRC_IGNORE_SECTION;

await fs.promises.writeFile(path.join(dir, '.gitignore'), newGitignore);

return { result: 'apply' };
};

export const tryMoveNpmrcOutOfGitignoreManagedSection = (async (
mode: 'format' | 'lint',
dir = process.cwd(),
) => {
try {
return await moveNpmrcOutOfGitignoreManagedSection(mode, dir);
} catch (err) {
log.warn('Failed to move .npmrc out of .gitignore managed section.');
log.subtle(inspect(err));
return { result: 'skip', reason: 'due to an error' };
}
}) satisfies PatchFunction;
Loading

0 comments on commit 814175f

Please sign in to comment.