Skip to content

Commit 4fccaa7

Browse files
committed
fix: issue npm#7892 - fix for npm install creating directories and empty package.json file
1 parent 6995303 commit 4fccaa7

File tree

6 files changed

+242
-11
lines changed

6 files changed

+242
-11
lines changed

lib/commands/install.js

+16
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const pacote = require('pacote')
66
const checks = require('npm-install-checks')
77
const reifyFinish = require('../utils/reify-finish.js')
88
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
9+
const validateProjectStructure = require('../utils/validate-project.js')
910

1011
class Install extends ArboristWorkspaceCmd {
1112
static description = 'Install a package'
@@ -104,6 +105,21 @@ class Install extends ArboristWorkspaceCmd {
104105
const forced = this.npm.config.get('force')
105106
const scriptShell = this.npm.config.get('script-shell') || undefined
106107

108+
// Add validation for non-global installs with no args
109+
if (!isGlobalInstall && args.length === 0) {
110+
try {
111+
validateProjectStructure(this.npm.prefix)
112+
} catch (err) {
113+
if (err.code === 'ENOPROJECT') {
114+
throw Object.assign(
115+
new Error(err.message),
116+
{ code: err.code }
117+
)
118+
}
119+
throw err
120+
}
121+
}
122+
107123
// be very strict about engines when trying to update npm itself
108124
const npmInstall = args.find(arg => arg.startsWith('npm@') || arg === 'npm')
109125
if (isGlobalInstall && npmInstall) {

lib/commands/uninstall.js

+16
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const pkgJson = require('@npmcli/package-json')
33
const reifyFinish = require('../utils/reify-finish.js')
44
const completion = require('../utils/installed-shallow.js')
55
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
6+
const validateProjectStructure = require('../utils/validate-project.js')
67

78
class Uninstall extends ArboristWorkspaceCmd {
89
static description = 'Remove a package'
@@ -18,6 +19,21 @@ class Uninstall extends ArboristWorkspaceCmd {
1819
}
1920

2021
async exec (args) {
22+
// Add validation for non-global uninstalls
23+
if (!this.npm.global) {
24+
try {
25+
validateProjectStructure(this.npm.prefix)
26+
} catch (err) {
27+
if (err.code === 'ENOPROJECT') {
28+
throw Object.assign(
29+
new Error(err.message),
30+
{ code: err.code }
31+
)
32+
}
33+
throw err
34+
}
35+
}
36+
2137
if (!args.length) {
2238
if (!this.npm.global) {
2339
throw new Error('Must provide a package name to remove')

lib/utils/validate-project.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const fs = require('node:fs')
2+
const path = require('node:path')
3+
4+
// Validates that a package.json exists in the target directory
5+
function validateProjectStructure (prefix) {
6+
const projectPath = prefix || process.cwd()
7+
const packageJsonPath = path.join(projectPath, 'package.json')
8+
9+
// Check if directory exists when --prefix is used
10+
if (prefix && !fs.existsSync(projectPath)) {
11+
const err = new Error(`Dir "${projectPath}" does not exist. Run "npm init" first.`)
12+
err.code = 'ENOPROJECT'
13+
throw err
14+
}
15+
16+
// Check for package.json
17+
if (!fs.existsSync(packageJsonPath)) {
18+
const err = new Error('No package.json found. Run "npm init" to create a new package.')
19+
err.code = 'ENOPROJECT'
20+
throw err
21+
}
22+
23+
return true
24+
}
25+
26+
module.exports = validateProjectStructure

test/lib/commands/install.js

+44
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,47 @@ t.test('devEngines', async t => {
717717
t.ok(!output.includes('EBADDEVENGINES'))
718718
})
719719
})
720+
721+
t.test('package.json validation error handling', async t => {
722+
await t.test('handles ENOPROJECT errors', async t => {
723+
const { npm } = await loadMockNpm(t, {
724+
prefixDir: {},
725+
mocks: {
726+
'{LIB}/utils/validate-project.js': () => {
727+
const err = new Error('Custom error')
728+
err.code = 'ENOPROJECT'
729+
throw err
730+
},
731+
},
732+
})
733+
await t.rejects(
734+
npm.exec('install', []),
735+
{
736+
code: 'ENOPROJECT',
737+
message: 'Custom error',
738+
},
739+
'should preserve error code and message'
740+
)
741+
})
742+
743+
await t.test('handles non-ENOPROJECT errors', async t => {
744+
const { npm } = await loadMockNpm(t, {
745+
prefixDir: {},
746+
mocks: {
747+
'{LIB}/utils/validate-project.js': () => {
748+
const err = new Error('Different error')
749+
err.code = 'EDIFFERENT'
750+
throw err
751+
},
752+
},
753+
})
754+
await t.rejects(
755+
npm.exec('install', []),
756+
{
757+
code: 'EDIFFERENT',
758+
message: 'Different error',
759+
},
760+
'should throw through other errors unchanged'
761+
)
762+
})
763+
})

test/lib/commands/uninstall.js

+93-11
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ t.test('remove single installed lib', async t => {
8686
t.test('remove multiple installed libs', async t => {
8787
const { uninstall, prefix } = await mockNpm(t, {
8888
prefixDir: {
89+
'package.json': JSON.stringify({
90+
name: 'test-rm-multiple-lib',
91+
version: '1.0.0',
92+
dependencies: {
93+
a: '*',
94+
b: '*',
95+
},
96+
}),
8997
node_modules: {
9098
a: {
9199
'package.json': JSON.stringify({
@@ -139,18 +147,8 @@ t.test('remove multiple installed libs', async t => {
139147

140148
await uninstall(['b'])
141149

142-
t.throws(() => fs.statSync(a), 'should have removed a package from nm')
143150
t.throws(() => fs.statSync(b), 'should have removed b package from nm')
144-
})
145-
146-
t.test('no args local', async t => {
147-
const { uninstall } = await mockNpm(t)
148-
149-
await t.rejects(
150-
uninstall([]),
151-
/Must provide a package name to remove/,
152-
'should throw package name required error'
153-
)
151+
t.ok(fs.statSync(a), 'should not have removed a package from nm')
154152
})
155153

156154
t.test('no args global', async t => {
@@ -200,3 +198,87 @@ t.test('non ENOENT error reading from localPrefix package.json', async t => {
200198
'should throw non ENOENT error'
201199
)
202200
})
201+
202+
t.test('package.json validation', async t => {
203+
await t.test('no package.json in local uninstall', async t => {
204+
const { uninstall } = await mockNpm(t, {
205+
prefixDir: {}, // empty directory
206+
})
207+
await t.rejects(
208+
uninstall(['some-package']),
209+
{
210+
code: 'ENOPROJECT',
211+
message: 'No package.json found. Run "npm init" to create a new package.',
212+
},
213+
'should throw ENOPROJECT error'
214+
)
215+
})
216+
217+
await t.test('no package.json in local uninstall', async t => {
218+
const { uninstall } = await mockNpm(t, {
219+
prefixDir: {}, // empty directory
220+
})
221+
await t.rejects(
222+
uninstall(['some-package']),
223+
{
224+
code: 'ENOPROJECT',
225+
message: 'No package.json found. Run "npm init" to create a new package.',
226+
}
227+
)
228+
})
229+
})
230+
231+
t.test('validation error handling', async t => {
232+
const { uninstall } = await mockNpm(t, {
233+
prefixDir: {},
234+
mocks: {
235+
'{LIB}/utils/validate-project.js': () => {
236+
throw new Error('Generic error')
237+
},
238+
},
239+
})
240+
await t.rejects(
241+
uninstall(['some-package']),
242+
/Generic error/,
243+
'should throw through non-ENOPROJECT errors'
244+
)
245+
})
246+
247+
t.test('no args with package name validation', async t => {
248+
const { uninstall } = await mockNpm(t, {
249+
prefixDir: {
250+
'package.json': JSON.stringify({
251+
name: 'test-pkg',
252+
version: '1.0.0',
253+
}),
254+
},
255+
})
256+
await t.rejects(
257+
uninstall([]),
258+
{
259+
message: 'Must provide a package name to remove',
260+
},
261+
'should throw correct error message'
262+
)
263+
})
264+
265+
t.test('handles non-ENOPROJECT validation errors', async t => {
266+
const { uninstall } = await mockNpm(t, {
267+
prefixDir: {},
268+
mocks: {
269+
'{LIB}/utils/validate-project.js': () => {
270+
const err = new Error('Different error')
271+
err.code = 'EDIFFERENT'
272+
throw err
273+
},
274+
},
275+
})
276+
await t.rejects(
277+
uninstall(['some-package']),
278+
{
279+
code: 'EDIFFERENT',
280+
message: 'Different error',
281+
},
282+
'should throw through other errors unchanged'
283+
)
284+
})

test/lib/commands/validate-project.js

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const t = require('tap')
2+
3+
// Mock fs.existsSync to control file existence checks
4+
const mockFs = {
5+
existsSync: () => true,
6+
}
7+
8+
const validate = t.mock('../../../lib/utils/validate-project.js', {
9+
'node:fs': mockFs,
10+
})
11+
12+
t.test('validate project structure', async t => {
13+
t.test('returns true when package.json exists', async t => {
14+
mockFs.existsSync = () => true
15+
t.equal(validate('/some/path'), true, 'should validate successfully')
16+
})
17+
18+
t.test('uses cwd() when no prefix provided', async t => {
19+
mockFs.existsSync = () => true
20+
t.equal(validate(), true, 'should validate successfully with default path')
21+
})
22+
23+
t.test('throws ENOPROJECT when directory does not exist', async t => {
24+
mockFs.existsSync = () => false
25+
t.throws(
26+
() => validate('/non-existent-dir'),
27+
{
28+
code: 'ENOPROJECT',
29+
message: 'Dir "/non-existent-dir" does not exist. Run "npm init" to begin.',
30+
},
31+
'should throw correct error for missing directory'
32+
)
33+
})
34+
35+
t.test('throws ENOPROJECT when package.json is missing', async t => {
36+
// Directory exists but package.json doesn't
37+
mockFs.existsSync = (p) => !p.endsWith('package.json')
38+
t.throws(
39+
() => validate('/some/path'),
40+
{
41+
code: 'ENOPROJECT',
42+
message: 'No package.json found. Run "npm init" to create a new package.',
43+
},
44+
'should throw correct error for missing package.json'
45+
)
46+
})
47+
})

0 commit comments

Comments
 (0)