Skip to content

Commit ea04dfb

Browse files
committed
feat: require this packaged to be a pinned dep
Fixes: #88 Fixes: #93
1 parent f951c95 commit ea04dfb

File tree

9 files changed

+324
-56
lines changed

9 files changed

+324
-56
lines changed

lib/check/check-required.js

+40-21
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
1+
const log = require('proc-log')
2+
const npa = require('npm-package-arg')
3+
const { partition } = require('lodash')
14
const hasPackage = require('../util/has-package.js')
2-
const { groupBy } = require('lodash')
3-
4-
const run = ({ pkg, config: { requiredPackages = {} } }) => {
5-
// ensure required packages are present in the correct place
6-
7-
const mustHave = Object.entries(requiredPackages).flatMap(([location, pkgs]) =>
8-
// first make a flat array of {name, version, location}
9-
Object.entries(pkgs).map(([name, version]) => ({
10-
name,
11-
version,
12-
location,
13-
})))
14-
.filter(({ name, version, location }) => !hasPackage(pkg, name, version, [location]))
15-
16-
if (mustHave.length) {
17-
return Object.entries(groupBy(mustHave, 'location')).map(([location, specs]) => {
18-
const rm = specs.map(({ name }) => name).join(' ')
19-
const install = specs.map(({ name, version }) => `${name}@${version}`).join(' ')
20-
const installLocation = hasPackage.flags[location]
5+
6+
const rmCommand = (specs) =>
7+
`npm rm ${specs.map((s) => s.name).join(' ')}`.trim()
8+
9+
const installCommand = (specs, flags) => specs.length ?
10+
`npm i ${specs.map((s) => `${s.name}@${s.fetchSpec}`).join(' ')} ${flags.join(' ')}`.trim() : ''
11+
12+
// ensure required packages are present in the correct place
13+
const run = ({ pkg, path, config: { requiredPackages = {} } }) => {
14+
// keys are the dependency location in package.json
15+
// values are a filtered list of parsed specs that dont exist in the current package
16+
// { [location]: [spec1, spec2] }
17+
const requiredByLocation = Object.entries(requiredPackages)
18+
.reduce((acc, [location, pkgs]) => {
19+
acc[location] = pkgs
20+
.filter((spec) => !hasPackage(pkg, spec, [location], path))
21+
.map((spec) => npa(spec))
22+
log.verbose(location, pkg, pkgs)
23+
return acc
24+
}, {})
25+
26+
const requiredEntries = Object.entries(requiredByLocation)
27+
28+
log.verbose('check-required', requiredEntries)
29+
30+
if (requiredEntries.flatMap(([, specs]) => specs).length) {
31+
return requiredEntries.map(([location, specs]) => {
32+
const locationFlag = hasPackage.flags[location]
33+
const [exactSpecs, saveSpecs] = partition(specs, (s) => s.type === 'version')
34+
35+
log.verbose('check-required', location, specs)
2136

2237
return {
2338
title: `The following required ${location} were not found:`,
24-
body: specs.map(({ name, version }) => `${name}@${version}`),
39+
body: specs.map((s) => s.rawSpec ? `${s.name}@${s.rawSpec}` : s.name),
2540
// solution is to remove any existing all at once but add back in by --save-<location>
26-
solution: [`npm rm ${rm}`, `npm i ${install} ${installLocation}`].join(' && '),
41+
solution: [
42+
rmCommand(specs),
43+
installCommand(saveSpecs, [locationFlag]),
44+
installCommand(exactSpecs, [locationFlag, '--save-exact']),
45+
].filter(Boolean).join(' && '),
2746
}
2847
})
2948
}

lib/content/index.js

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { name: NAME, version: LATEST_VERSION } = require('../../package.json')
2+
13
// Changes applied to the root of the repo
24
const rootRepo = {
35
add: {
@@ -73,11 +75,11 @@ module.exports = {
7375
'standard',
7476
],
7577
requiredPackages: {
76-
devDependencies: {
77-
'@npmcli/template-oss': '*',
78-
'@npmcli/eslint-config': '>=3.0.0',
79-
tap: '>=15.0.0',
80-
},
78+
devDependencies: [
79+
`${NAME}@${LATEST_VERSION}`,
80+
'@npmcli/eslint-config',
81+
'tap'
82+
],
8183
},
8284
allowedPackages: [],
8385
changelogTypes: [

lib/util/has-package.js

+64-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
const intersects = require('semver/ranges/intersects')
1+
const semver = require('semver')
2+
const npa = require('npm-package-arg')
23
const { has } = require('lodash')
4+
const { join } = require('path')
35

46
const installLocations = [
57
'dependencies',
@@ -9,20 +11,71 @@ const installLocations = [
911
'optionalDependencies',
1012
]
1113

14+
// from a spec get either a semver version or range. it gets parsed with npa and
15+
// only a few appropriate types are handled. eg this doesnt match any git
16+
// shas/tags, etc
17+
const getSpecVersion = (spec, where) => {
18+
const arg = npa(spec, where)
19+
switch (arg.type) {
20+
case 'range':
21+
return new semver.Range(arg.fetchSpec)
22+
case 'tag': {
23+
// special case an empty spec to mean any version
24+
return arg.rawSpec === '' && new semver.Range('*')
25+
}
26+
case 'version':
27+
return new semver.SemVer(arg.fetchSpec)
28+
case 'directory': {
29+
// allows this repo to use a file spec as a devdep and pass this check
30+
const pkg = require(join(arg.fetchSpec, 'package.json'))
31+
return new semver.SemVer(pkg.version)
32+
}
33+
}
34+
return null
35+
}
36+
37+
const isVersion = (s) => s instanceof semver.SemVer
38+
39+
// Returns whether the pkg has the dependency in a semver
40+
// compatible version in one or more locationscccc
1241
const hasPackage = (
1342
pkg,
14-
name,
15-
version = '*',
16-
locations = installLocations
17-
) => locations
18-
.map((l) => pkg[l])
19-
.some((deps) =>
20-
has(deps, name) &&
21-
(version === '*' || intersects(deps[name], version))
22-
)
43+
spec,
44+
locations = installLocations,
45+
path
46+
) => {
47+
const name = npa(spec).name
48+
const requested = getSpecVersion(spec)
2349

24-
module.exports = hasPackage
50+
if (!requested) {
51+
return false
52+
}
2553

54+
const existingByLocation = locations
55+
.map((location) => pkg[location])
56+
.filter((deps) => has(deps, name))
57+
.map((deps) => getSpecVersion(`${name}@${deps[name]}`, path))
58+
.filter(Boolean)
59+
60+
return existingByLocation.some((existing) => {
61+
switch ([existing, requested].map((t) => isVersion(t) ? 'VER' : 'RNG').join('-')) {
62+
case `VER-VER`:
63+
// two versions, use semver.eq to check equality
64+
return semver.eq(existing, requested)
65+
case `RNG-RNG`:
66+
// two ranges, existing must be entirely within the requested
67+
return semver.subset(existing, requested)
68+
case `VER-RNG`:
69+
// requesting a range with existing version is ok if it satisfies
70+
return semver.satisfies(existing, requested)
71+
case `RNG-VER`:
72+
// requesting a pinned version but has a range, always false
73+
return false
74+
}
75+
})
76+
}
77+
78+
module.exports = hasPackage
2679
module.exports.flags = installLocations.reduce((acc, location) => {
2780
const type = location.replace(/dependencies/i, '')
2881
acc[location] = '--save' + (type ? `-${type}` : '')

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"json-parse-even-better-errors": "^2.3.1",
4141
"just-diff": "^5.0.1",
4242
"lodash": "^4.17.21",
43+
"npm-package-arg": "^9.0.1",
4344
"proc-log": "^2.0.0",
4445
"semver": "^7.3.5",
4546
"yaml": "^2.0.0-10"

tap-snapshots/test/check/index.js.test.cjs

+16-16
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ To correct it: npx template-oss-apply --force
7474
7575
The following required devDependencies were not found:
7676
77-
@npmcli/template-oss@*
78-
@npmcli/eslint-config@>=3.0.0
79-
tap@>=15.0.0
77+
@npmcli/template-oss@{{VERSION}}
78+
@npmcli/eslint-config
79+
tap
8080
81-
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/template-oss@* @npmcli/eslint-config@>=3.0.0 tap@>=15.0.0 --save-dev
81+
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/eslint-config@latest tap@latest --save-dev && npm i @npmcli/template-oss@{{VERSION}} --save-dev --save-exact
8282
8383
-------------------------------------------------------------------
8484
`
@@ -152,11 +152,11 @@ To correct it: npx template-oss-apply --force
152152
153153
The following required devDependencies were not found:
154154
155-
@npmcli/template-oss@*
156-
@npmcli/eslint-config@>=3.0.0
157-
tap@>=15.0.0
155+
@npmcli/template-oss@{{VERSION}}
156+
@npmcli/eslint-config
157+
tap
158158
159-
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/template-oss@* @npmcli/eslint-config@>=3.0.0 tap@>=15.0.0 --save-dev
159+
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/eslint-config@latest tap@latest --save-dev && npm i @npmcli/template-oss@{{VERSION}} --save-dev --save-exact
160160
161161
-------------------------------------------------------------------
162162
@@ -213,11 +213,11 @@ To correct it: npx template-oss-apply --force
213213
214214
The following required devDependencies were not found:
215215
216-
@npmcli/template-oss@*
217-
@npmcli/eslint-config@>=3.0.0
218-
tap@>=15.0.0
216+
@npmcli/template-oss@{{VERSION}}
217+
@npmcli/eslint-config
218+
tap
219219
220-
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/template-oss@* @npmcli/eslint-config@>=3.0.0 tap@>=15.0.0 --save-dev
220+
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/eslint-config@latest tap@latest --save-dev && npm i @npmcli/template-oss@{{VERSION}} --save-dev --save-exact
221221
222222
-------------------------------------------------------------------
223223
@@ -274,11 +274,11 @@ To correct it: npx template-oss-apply --force
274274
275275
The following required devDependencies were not found:
276276
277-
@npmcli/template-oss@*
278-
@npmcli/eslint-config@>=3.0.0
279-
tap@>=15.0.0
277+
@npmcli/template-oss@{{VERSION}}
278+
@npmcli/eslint-config
279+
tap
280280
281-
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/template-oss@* @npmcli/eslint-config@>=3.0.0 tap@>=15.0.0 --save-dev
281+
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/eslint-config@latest tap@latest --save-dev && npm i @npmcli/template-oss@{{VERSION}} --save-dev --save-exact
282282
283283
-------------------------------------------------------------------
284284
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/check/required.js TAP not ok without required > expect resolving Promise 1`] = `
9+
Some problems were detected:
10+
11+
-------------------------------------------------------------------
12+
13+
The following required devDependencies were not found:
14+
15+
@npmcli/template-oss@{{VERSION}}
16+
@npmcli/eslint-config
17+
tap
18+
19+
To correct it: npm rm @npmcli/template-oss @npmcli/eslint-config tap && npm i @npmcli/eslint-config@latest tap@latest --save-dev && npm i @npmcli/template-oss@{{VERSION}} --save-dev --save-exact
20+
21+
-------------------------------------------------------------------
22+
`
23+
24+
exports[`test/check/required.js TAP ok with required > expect resolving Promise 1`] = `
25+
Some problems were detected:
26+
27+
-------------------------------------------------------------------
28+
`

test/check/required.js

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
const t = require('tap')
2+
const setup = require('../setup.js')
3+
4+
t.cleanSnapshot = setup.clean
5+
t.formatSnapshot = setup.format.checks
6+
7+
t.test('ok with required', async (t) => {
8+
const s = await setup(t, {
9+
ok: true,
10+
})
11+
await s.apply()
12+
t.strictSame(await s.check(), [])
13+
})
14+
15+
t.test('not ok without required', async (t) => {
16+
const s = await setup(t)
17+
await s.apply()
18+
await t.resolveMatchSnapshot(s.check())
19+
})
20+
21+
t.test('required in each location', async (t) => {
22+
const s = await setup(t, {
23+
package: {
24+
dependencies: {
25+
a: '^100.0.0',
26+
// pinned is ok if requesting a range
27+
d: '5.0.0',
28+
},
29+
devDependencies: {
30+
b: '^4.5.0',
31+
},
32+
peerDependencies: {
33+
c: '1.2.3',
34+
},
35+
templateOSS: {
36+
requiredPackages: {
37+
dependencies: [
38+
'a', // any version
39+
'd',
40+
],
41+
devDependencies: [
42+
'b@4', // range
43+
],
44+
peerDependencies: [
45+
'[email protected]', // pinned
46+
],
47+
},
48+
},
49+
},
50+
})
51+
52+
await s.apply()
53+
t.strictSame(await s.check(), [])
54+
})
55+
56+
t.test('can be pinned', async (t) => {
57+
const config = {
58+
templateOSS: {
59+
requiredPackages: {
60+
devDependencies: [
61+
62+
],
63+
},
64+
},
65+
}
66+
67+
await t.test('ok', async (t) => {
68+
const s = await setup(t, {
69+
package: {
70+
devDependencies: {
71+
a: '1.0.0',
72+
},
73+
...config,
74+
},
75+
})
76+
77+
await s.apply()
78+
t.strictSame(await s.check(), [])
79+
})
80+
81+
await t.test('not ok', async (t) => {
82+
const s = await setup(t, {
83+
package: {
84+
devDependencies: {
85+
a: '^1.0.0',
86+
},
87+
...config,
88+
},
89+
})
90+
91+
await s.apply()
92+
const [res] = await s.check()
93+
t.strictSame(res.body, ['[email protected]'])
94+
t.strictSame(res.solution, 'npm rm a && npm i [email protected] --save-dev --save-exact')
95+
})
96+
})

0 commit comments

Comments
 (0)