11import os
2+ from typing import Optional
23
34import typer
45
5- from cycode .cli .files_collector .sca .base_restore_dependencies import BaseRestoreDependencies
6+ from cycode .cli .files_collector .sca .base_restore_dependencies import BaseRestoreDependencies , build_dep_tree_path
67from cycode .cli .models import Document
8+ from cycode .cli .utils .path_utils import get_file_content
9+ from cycode .logger import get_logger
10+
11+ logger = get_logger ('NPM Restore Dependencies' )
712
813NPM_PROJECT_FILE_EXTENSIONS = ['.json' ]
914NPM_LOCK_FILE_NAME = 'package-lock.json'
10- NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME , 'yarn.lock' , 'pnpm-lock.yaml' , 'deno.lock' ]
15+ # Alternative lockfiles that should prevent npm install from running
16+ ALTERNATIVE_LOCK_FILES = ['yarn.lock' , 'pnpm-lock.yaml' , 'deno.lock' ]
17+ NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME ] + ALTERNATIVE_LOCK_FILES
1118NPM_MANIFEST_FILE_NAME = 'package.json'
12-
13-
1419class RestoreNpmDependencies (BaseRestoreDependencies ):
1520 def __init__ (self , ctx : typer .Context , is_git_diff : bool , command_timeout : int ) -> None :
1621 super ().__init__ (ctx , is_git_diff , command_timeout )
1722
1823 def is_project (self , document : Document ) -> bool :
1924 return any (document .path .endswith (ext ) for ext in NPM_PROJECT_FILE_EXTENSIONS )
2025
26+ def _resolve_manifest_directory (self , document : Document ) -> Optional [str ]:
27+ """Resolve the directory containing the manifest file.
28+
29+ Uses the same path resolution logic as get_manifest_file_path() to ensure consistency.
30+ Falls back to absolute_path or document.path if needed.
31+
32+ Returns:
33+ Directory path if resolved, None otherwise.
34+ """
35+ manifest_file_path = self .get_manifest_file_path (document )
36+ manifest_dir = os .path .dirname (manifest_file_path ) if manifest_file_path else None
37+
38+ # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path
39+ if not manifest_dir or manifest_dir == os .sep or manifest_dir == '.' :
40+ base_path = document .absolute_path if document .absolute_path else document .path
41+ if base_path :
42+ manifest_dir = os .path .dirname (base_path )
43+
44+ return manifest_dir
45+
46+ def _find_existing_lockfile (self , manifest_dir : str ) -> tuple [Optional [str ], list [str ]]:
47+ """Find the first existing lockfile in the manifest directory.
48+
49+ Args:
50+ manifest_dir: Directory to search for lockfiles.
51+
52+ Returns:
53+ Tuple of (lockfile_path if found, list of checked lockfiles with status).
54+ """
55+ all_lock_file_names = [NPM_LOCK_FILE_NAME ] + ALTERNATIVE_LOCK_FILES
56+ lock_file_paths = [
57+ os .path .join (manifest_dir , lock_file_name )
58+ for lock_file_name in all_lock_file_names
59+ ]
60+
61+ existing_lock_file = None
62+ checked_lockfiles = []
63+ for lock_file_path in lock_file_paths :
64+ lock_file_name = os .path .basename (lock_file_path )
65+ exists = os .path .isfile (lock_file_path )
66+ checked_lockfiles .append (f'{ lock_file_name } : { "exists" if exists else "not found" } ' )
67+ if exists :
68+ existing_lock_file = lock_file_path
69+ break
70+
71+ return existing_lock_file , checked_lockfiles
72+
73+ def _create_document_from_lockfile (
74+ self , document : Document , lockfile_path : str
75+ ) -> Optional [Document ]:
76+ """Create a Document from an existing lockfile.
77+
78+ Args:
79+ document: Original document (package.json).
80+ lockfile_path: Path to the existing lockfile.
81+
82+ Returns:
83+ Document with lockfile content if successful, None otherwise.
84+ """
85+ lock_file_name = os .path .basename (lockfile_path )
86+ logger .info (
87+ 'Skipping npm install: using existing lockfile, %s' ,
88+ {'path' : document .path , 'lockfile' : lock_file_name , 'lockfile_path' : lockfile_path },
89+ )
90+
91+ relative_restore_file_path = build_dep_tree_path (document .path , lock_file_name )
92+ restore_file_content = get_file_content (lockfile_path )
93+
94+ if restore_file_content is not None :
95+ logger .debug (
96+ 'Successfully loaded lockfile content, %s' ,
97+ {'path' : document .path , 'lockfile' : lock_file_name , 'content_size' : len (restore_file_content )},
98+ )
99+ return Document (relative_restore_file_path , restore_file_content , self .is_git_diff )
100+ else :
101+ logger .warning (
102+ 'Lockfile exists but could not read content, %s' ,
103+ {'path' : document .path , 'lockfile' : lock_file_name , 'lockfile_path' : lockfile_path },
104+ )
105+ return None
106+
107+ def try_restore_dependencies (self , document : Document ) -> Optional [Document ]:
108+ """Override to prevent npm install when any lockfile exists.
109+
110+ The base class uses document.absolute_path which might be None or incorrect.
111+ We need to use the same path resolution logic as get_manifest_file_path()
112+ to ensure we check for lockfiles in the correct location.
113+
114+ If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock),
115+ we use it directly without running npm install to avoid generating invalid lockfiles.
116+ """
117+ # Check if this is a project file first (same as base class caller does)
118+ if not self .is_project (document ):
119+ logger .debug ('Skipping restore: document is not recognized as npm project, %s' , {'path' : document .path })
120+ return None
121+
122+ # Resolve the manifest directory
123+ manifest_dir = self ._resolve_manifest_directory (document )
124+ if not manifest_dir :
125+ logger .debug (
126+ 'Cannot determine manifest directory, proceeding with base class restore flow, %s' ,
127+ {'path' : document .path },
128+ )
129+ return super ().try_restore_dependencies (document )
130+
131+ # Check for existing lockfiles
132+ logger .debug ('Checking for existing lockfiles in directory, %s' , {'directory' : manifest_dir , 'path' : document .path })
133+ existing_lock_file , checked_lockfiles = self ._find_existing_lockfile (manifest_dir )
134+
135+ logger .debug (
136+ 'Lockfile check results, %s' ,
137+ {'path' : document .path , 'checked_lockfiles' : ', ' .join (checked_lockfiles )},
138+ )
139+
140+ # If any lockfile exists, use it directly without running npm install
141+ if existing_lock_file :
142+ return self ._create_document_from_lockfile (document , existing_lock_file )
143+
144+ # No lockfile exists, proceed with the normal restore flow which will run npm install
145+ logger .info (
146+ 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s' ,
147+ {'path' : document .path , 'directory' : manifest_dir , 'checked_lockfiles' : ', ' .join (checked_lockfiles )},
148+ )
149+ return super ().try_restore_dependencies (document )
150+
21151 def get_commands (self , manifest_file_path : str ) -> list [list [str ]]:
22152 return [
23153 [
@@ -37,9 +167,16 @@ def get_restored_lock_file_name(self, restore_file_path: str) -> str:
37167 def get_lock_file_name (self ) -> str :
38168 return NPM_LOCK_FILE_NAME
39169
40- def get_lock_file_names (self ) -> str :
170+ def get_lock_file_names (self ) -> list [ str ] :
41171 return NPM_LOCK_FILE_NAMES
42172
43173 @staticmethod
44174 def prepare_manifest_file_path_for_command (manifest_file_path : str ) -> str :
45- return manifest_file_path .replace (os .sep + NPM_MANIFEST_FILE_NAME , '' )
175+ # Remove package.json from the path
176+ if manifest_file_path .endswith (NPM_MANIFEST_FILE_NAME ):
177+ # Handle both cases: with separator (e.g., '/path/to/package.json') and without (e.g., 'package.json')
178+ if os .sep in manifest_file_path :
179+ return manifest_file_path .replace (os .sep + NPM_MANIFEST_FILE_NAME , '' )
180+ else :
181+ return ''
182+ return manifest_file_path
0 commit comments