Skip to content

Commit 9e02256

Browse files
authored
Support pnpm for nodejs project (#247)
Signed-off-by: jiyeong.seok <[email protected]>
1 parent 13fe7a5 commit 9e02256

File tree

6 files changed

+171
-4
lines changed

6 files changed

+171
-4
lines changed

src/fosslight_dependency/_analyze_dependency.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from fosslight_dependency.package_manager.Helm import Helm
2121
from fosslight_dependency.package_manager.Unity import Unity
2222
from fosslight_dependency.package_manager.Cargo import Cargo
23+
from fosslight_dependency.package_manager.Pnpm import Pnpm
2324
import fosslight_util.constant as constant
2425

2526
logger = logging.getLogger(constant.LOGGER_NAME)
@@ -60,6 +61,8 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
6061
package_manager = Unity(input_dir, output_dir)
6162
elif package_manager_name == const.CARGO:
6263
package_manager = Cargo(input_dir, output_dir)
64+
elif package_manager_name == const.PNPM:
65+
package_manager = Pnpm(input_dir, output_dir)
6366
else:
6467
logger.error(f"Not supported package manager name: {package_manager_name}")
6568
ret = False
@@ -84,7 +87,10 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
8487
else:
8588
logger.error(f"Failed to open input file: {f_name}")
8689
ret = False
87-
90+
if package_manager_name == const.PNPM:
91+
logger.info("Parse oss information for pnpm")
92+
package_manager.parse_oss_information_for_pnpm()
93+
package_dep_item_list.extend(package_manager.dep_items)
8894
if ret:
8995
logger.warning(f"### Complete to analyze: {package_manager_name}")
9096
if package_manager.cover_comment:

src/fosslight_dependency/_help.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Gradle (Java)
1616
Maven (Java)
1717
NPM (Node.js)
18+
PNPM (Node.js)
1819
PIP (Python)
1920
Pub (Dart with flutter)
2021
Cocoapods (Swift/Obj-C)
@@ -32,7 +33,7 @@
3233
-v\t\t\t\t Print the version of the script.
3334
-m <package_manager>\t Enter the package manager.
3435
\t(npm, maven, gradle, pypi, pub, cocoapods, android, swift, carthage,
35-
\t go, nuget, helm, unity, cargo)
36+
\t go, nuget, helm, unity, cargo, pnpm)
3637
-p <input_path>\t\t Enter the path where the script will be run.
3738
-e <exclude_path>\t\t Enter the path where the analysis will not be performed.
3839
-o <output_path>\t\t Output path

src/fosslight_dependency/constant.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
HELM = 'helm'
2525
UNITY = 'unity'
2626
CARGO = 'cargo'
27+
PNPM = 'pnpm'
2728

2829
# Supported package name and manifest file
2930
SUPPORT_PACKAE = {
3031
PYPI: ['requirements.txt', 'setup.py', 'pyproject.toml'],
32+
PNPM: 'pnpm-lock.yaml',
3133
NPM: 'package.json',
3234
MAVEN: 'pom.xml',
3335
GRADLE: 'build.gradle',

src/fosslight_dependency/package_manager/Npm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def start_license_checker(self):
4646
ret = True
4747
license_checker_cmd = f'license-checker --production --json --out {self.input_file_name}'
4848
custom_path_option = ' --customPath '
49-
npm_install_cmd = 'npm install --production'
49+
npm_install_cmd = 'npm install --production --ignore-scripts'
5050

5151
if os.path.isdir(node_modules) != 1:
5252
logger.info(f"node_modules directory is not existed. So it executes '{npm_install_cmd}'.")
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# Copyright (c) 2025 LG Electronics Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
import logging
8+
import subprocess
9+
import json
10+
import shutil
11+
import fosslight_util.constant as constant
12+
import fosslight_dependency.constant as const
13+
from fosslight_dependency._package_manager import PackageManager, get_url_to_purl
14+
from fosslight_dependency.dependency_item import DependencyItem, change_dependson_to_purl
15+
from fosslight_dependency.package_manager.Npm import check_multi_license
16+
from fosslight_util.oss_item import OssItem
17+
18+
logger = logging.getLogger(constant.LOGGER_NAME)
19+
node_modules = 'node_modules'
20+
21+
22+
class Pnpm(PackageManager):
23+
package_manager_name = const.PNPM
24+
25+
dn_url = 'https://www.npmjs.com/package/'
26+
input_file_name = 'tmp_pnpm_license_output.json'
27+
flag_tmp_node_modules = False
28+
project_name_list = []
29+
pkg_list = {}
30+
31+
def __init__(self, input_dir, output_dir):
32+
super().__init__(self.package_manager_name, self.dn_url, input_dir, output_dir)
33+
34+
def __del__(self):
35+
if os.path.isfile(self.input_file_name):
36+
os.remove(self.input_file_name)
37+
if self.flag_tmp_node_modules:
38+
shutil.rmtree(node_modules, ignore_errors=True)
39+
40+
def run_plugin(self):
41+
ret = True
42+
43+
pnpm_install_cmd = 'pnpm install --prod --ignore-scripts --ignore-pnpmfile'
44+
if os.path.isdir(node_modules) != 1:
45+
logger.info(f"node_modules directory is not existed. So it executes '{pnpm_install_cmd}'.")
46+
self.flag_tmp_node_modules = True
47+
cmd_ret = subprocess.call(pnpm_install_cmd, shell=True)
48+
if cmd_ret != 0:
49+
logger.error(f"{pnpm_install_cmd} returns an error")
50+
ret = False
51+
if ret:
52+
project_cmd = 'pnpm ls -r --depth -1 -P --json'
53+
ret_txt = subprocess.check_output(project_cmd, text=True, shell=True)
54+
if ret_txt is not None:
55+
deps_l = json.loads(ret_txt)
56+
for items in deps_l:
57+
self.project_name_list.append(items["name"])
58+
return ret
59+
60+
def parse_direct_dependencies(self):
61+
if not self.direct_dep:
62+
return
63+
try:
64+
direct_cmd = 'pnpm ls -r --depth 0 -P --json'
65+
ret_txt = subprocess.check_output(direct_cmd, text=True, shell=True)
66+
if ret_txt is not None:
67+
deps_l = json.loads(ret_txt)
68+
for item in deps_l:
69+
if 'dependencies' in item and isinstance(item['dependencies'], dict):
70+
self.direct_dep_list.extend(item['dependencies'].keys())
71+
else:
72+
self.direct_dep = False
73+
logger.warning('Cannot print direct/transitive dependency')
74+
except Exception as e:
75+
logger.warning(f'Fail to print direct/transitive dependency: {e}')
76+
self.direct_dep = False
77+
if self.direct_dep:
78+
self.direct_dep_list = list(filter(lambda dep: dep not in self.project_name_list, self.direct_dep_list))
79+
80+
def extract_dependencies(self, dependencies, purl_dict):
81+
dep_item_list = []
82+
for dep_name, dep_info in dependencies.items():
83+
if dep_name not in self.project_name_list:
84+
if dep_name in self.pkg_list.keys():
85+
if dep_info.get('version') in self.pkg_list[dep_name]:
86+
continue
87+
self.pkg_list.setdefault(dep_name, []).append(dep_info.get('version'))
88+
dep_item = DependencyItem()
89+
oss_item = OssItem()
90+
oss_item.name = f'npm:{dep_name}'
91+
oss_item.version = dep_info.get('version')
92+
93+
license_name = dep_info.get('license')
94+
if license_name:
95+
multi_license, license_comment, multi_flag = check_multi_license(license_name, '')
96+
if multi_flag:
97+
oss_item.comment = license_comment
98+
license_name = multi_license
99+
else:
100+
license_name = license_name.replace(",", "")
101+
oss_item.license = license_name
102+
103+
oss_item.homepage = f'{self.dn_url}{dep_name}'
104+
oss_item.download_location = dep_info.get('repository')
105+
if oss_item.download_location:
106+
if oss_item.download_location.endswith('.git'):
107+
oss_item.download_location = oss_item.download_location[:-4]
108+
if oss_item.download_location.startswith('git://'):
109+
oss_item.download_location = 'https://' + oss_item.download_location[6:]
110+
elif oss_item.download_location.startswith('git+https://'):
111+
oss_item.download_location = 'https://' + oss_item.download_location[12:]
112+
elif oss_item.download_location.startswith('git+ssh://git@'):
113+
oss_item.download_location = 'https://' + oss_item.download_location[14:]
114+
else:
115+
oss_item.download_location = f'{self.dn_url}{dep_name}/v/{oss_item.version}'
116+
117+
dn_loc = f'{oss_item.homepage}/v/{oss_item.version}'
118+
dep_item.purl = get_url_to_purl(dn_loc, 'npm')
119+
purl_dict[f'{dep_name}({oss_item.version})'] = dep_item.purl
120+
121+
if dep_name in self.direct_dep_list:
122+
oss_item.comment = 'direct'
123+
else:
124+
oss_item.comment = 'transitive'
125+
126+
if 'dependencies' in dep_info:
127+
for dn, di in dep_info.get('dependencies').items():
128+
if dn not in self.project_name_list:
129+
dep_item.depends_on_raw.append(f"{dn}({di['version']})")
130+
131+
dep_item.oss_items.append(oss_item)
132+
dep_item_list.append(dep_item)
133+
134+
if 'dependencies' in dep_info:
135+
dep_item_list_inner, purl_dict_inner = self.extract_dependencies(dep_info['dependencies'], purl_dict)
136+
dep_item_list.extend(dep_item_list_inner)
137+
purl_dict.update(purl_dict_inner)
138+
139+
return dep_item_list, purl_dict
140+
141+
def parse_oss_information_for_pnpm(self):
142+
project_cmd = 'pnpm ls --json -r --depth Infinity -P --long'
143+
ret_txt = subprocess.check_output(project_cmd, text=True, shell=True)
144+
if ret_txt is not None:
145+
deps_l = json.loads(ret_txt)
146+
purl_dict = {}
147+
for items in deps_l:
148+
if 'dependencies' in items:
149+
dep_item_list_inner, purl_dict_inner = self.extract_dependencies(items['dependencies'], purl_dict)
150+
self.dep_items.extend(dep_item_list_inner)
151+
purl_dict.update(purl_dict_inner)
152+
if self.direct_dep:
153+
self.dep_items = change_dependson_to_purl(purl_dict, self.dep_items)
154+
else:
155+
logger.warning(f'No output for {project_cmd}')

src/fosslight_dependency/run_dependency_scanner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,11 @@ def find_package_manager(input_dir, abs_path_to_exclude=[], manifest_file_name=[
100100
if value == f_idx:
101101
found_package_manager[key] = [f_idx]
102102

103+
# both npm and pnpm are detected, remove npm.
104+
if 'npm' in found_package_manager and 'pnpm' in found_package_manager:
105+
del found_package_manager['npm']
103106
if len(found_package_manager) >= 1:
104-
manifest_file_w_path = map(lambda x: os.path.join(input_dir, x), found_manifest_file)
107+
manifest_file_w_path = [os.path.join(input_dir, file) for pkg, files in found_package_manager.items() for file in files]
105108
logger.info(f"Found the manifest file({','.join(manifest_file_w_path)}) automatically.")
106109
logger.warning(f"### Set Package Manager = {', '.join(found_package_manager.keys())}")
107110
else:

0 commit comments

Comments
 (0)