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'
1219
1320
@@ -18,6 +25,127 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int)
1825 def is_project (self , document : Document ) -> bool :
1926 return any (document .path .endswith (ext ) for ext in NPM_PROJECT_FILE_EXTENSIONS )
2027
28+ def _resolve_manifest_directory (self , document : Document ) -> Optional [str ]:
29+ """Resolve the directory containing the manifest file.
30+
31+ Uses the same path resolution logic as get_manifest_file_path() to ensure consistency.
32+ Falls back to absolute_path or document.path if needed.
33+
34+ Returns:
35+ Directory path if resolved, None otherwise.
36+ """
37+ manifest_file_path = self .get_manifest_file_path (document )
38+ manifest_dir = os .path .dirname (manifest_file_path ) if manifest_file_path else None
39+
40+ # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path
41+ if not manifest_dir or manifest_dir == os .sep or manifest_dir == '.' :
42+ base_path = document .absolute_path if document .absolute_path else document .path
43+ if base_path :
44+ manifest_dir = os .path .dirname (base_path )
45+
46+ return manifest_dir
47+
48+ def _find_existing_lockfile (self , manifest_dir : str ) -> tuple [Optional [str ], list [str ]]:
49+ """Find the first existing lockfile in the manifest directory.
50+
51+ Args:
52+ manifest_dir: Directory to search for lockfiles.
53+
54+ Returns:
55+ Tuple of (lockfile_path if found, list of checked lockfiles with status).
56+ """
57+ lock_file_paths = [os .path .join (manifest_dir , lock_file_name ) for lock_file_name in NPM_LOCK_FILE_NAMES ]
58+
59+ existing_lock_file = None
60+ checked_lockfiles = []
61+ for lock_file_path in lock_file_paths :
62+ lock_file_name = os .path .basename (lock_file_path )
63+ exists = os .path .isfile (lock_file_path )
64+ checked_lockfiles .append (f'{ lock_file_name } : { "exists" if exists else "not found" } ' )
65+ if exists :
66+ existing_lock_file = lock_file_path
67+ break
68+
69+ return existing_lock_file , checked_lockfiles
70+
71+ def _create_document_from_lockfile (self , document : Document , lockfile_path : str ) -> Optional [Document ]:
72+ """Create a Document from an existing lockfile.
73+
74+ Args:
75+ document: Original document (package.json).
76+ lockfile_path: Path to the existing lockfile.
77+
78+ Returns:
79+ Document with lockfile content if successful, None otherwise.
80+ """
81+ lock_file_name = os .path .basename (lockfile_path )
82+ logger .info (
83+ 'Skipping npm install: using existing lockfile, %s' ,
84+ {'path' : document .path , 'lockfile' : lock_file_name , 'lockfile_path' : lockfile_path },
85+ )
86+
87+ relative_restore_file_path = build_dep_tree_path (document .path , lock_file_name )
88+ restore_file_content = get_file_content (lockfile_path )
89+
90+ if restore_file_content is not None :
91+ logger .debug (
92+ 'Successfully loaded lockfile content, %s' ,
93+ {'path' : document .path , 'lockfile' : lock_file_name , 'content_size' : len (restore_file_content )},
94+ )
95+ return Document (relative_restore_file_path , restore_file_content , self .is_git_diff )
96+
97+ logger .warning (
98+ 'Lockfile exists but could not read content, %s' ,
99+ {'path' : document .path , 'lockfile' : lock_file_name , 'lockfile_path' : lockfile_path },
100+ )
101+ return None
102+
103+ def try_restore_dependencies (self , document : Document ) -> Optional [Document ]:
104+ """Override to prevent npm install when any lockfile exists.
105+
106+ The base class uses document.absolute_path which might be None or incorrect.
107+ We need to use the same path resolution logic as get_manifest_file_path()
108+ to ensure we check for lockfiles in the correct location.
109+
110+ If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock),
111+ we use it directly without running npm install to avoid generating invalid lockfiles.
112+ """
113+ # Check if this is a project file first (same as base class caller does)
114+ if not self .is_project (document ):
115+ logger .debug ('Skipping restore: document is not recognized as npm project, %s' , {'path' : document .path })
116+ return None
117+
118+ # Resolve the manifest directory
119+ manifest_dir = self ._resolve_manifest_directory (document )
120+ if not manifest_dir :
121+ logger .debug (
122+ 'Cannot determine manifest directory, proceeding with base class restore flow, %s' ,
123+ {'path' : document .path },
124+ )
125+ return super ().try_restore_dependencies (document )
126+
127+ # Check for existing lockfiles
128+ logger .debug (
129+ 'Checking for existing lockfiles in directory, %s' , {'directory' : manifest_dir , 'path' : document .path }
130+ )
131+ existing_lock_file , checked_lockfiles = self ._find_existing_lockfile (manifest_dir )
132+
133+ logger .debug (
134+ 'Lockfile check results, %s' ,
135+ {'path' : document .path , 'checked_lockfiles' : ', ' .join (checked_lockfiles )},
136+ )
137+
138+ # If any lockfile exists, use it directly without running npm install
139+ if existing_lock_file :
140+ return self ._create_document_from_lockfile (document , existing_lock_file )
141+
142+ # No lockfile exists, proceed with the normal restore flow which will run npm install
143+ logger .info (
144+ 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s' ,
145+ {'path' : document .path , 'directory' : manifest_dir , 'checked_lockfiles' : ', ' .join (checked_lockfiles )},
146+ )
147+ return super ().try_restore_dependencies (document )
148+
21149 def get_commands (self , manifest_file_path : str ) -> list [list [str ]]:
22150 return [
23151 [
@@ -37,9 +165,16 @@ def get_restored_lock_file_name(self, restore_file_path: str) -> str:
37165 def get_lock_file_name (self ) -> str :
38166 return NPM_LOCK_FILE_NAME
39167
40- def get_lock_file_names (self ) -> str :
168+ def get_lock_file_names (self ) -> list [ str ] :
41169 return NPM_LOCK_FILE_NAMES
42170
43171 @staticmethod
44172 def prepare_manifest_file_path_for_command (manifest_file_path : str ) -> str :
45- return manifest_file_path .replace (os .sep + NPM_MANIFEST_FILE_NAME , '' )
173+ # Remove package.json from the path
174+ if manifest_file_path .endswith (NPM_MANIFEST_FILE_NAME ):
175+ # Use os.path.dirname to handle both Unix (/) and Windows (\) separators
176+ # This is cross-platform and handles edge cases correctly
177+ dir_path = os .path .dirname (manifest_file_path )
178+ # If dir_path is empty or just '.', return an empty string (package.json in current dir)
179+ return dir_path if dir_path and dir_path != '.' else ''
180+ return manifest_file_path
0 commit comments