Skip to main content

socket_patch_cli/commands/
setup.rs

1use clap::Args;
2use socket_patch_core::package_json::detect::PackageManager;
3use socket_patch_core::package_json::find::{
4    detect_package_manager, find_package_json_files, WorkspaceType,
5};
6use socket_patch_core::package_json::update::{update_package_json, UpdateStatus};
7use socket_patch_core::utils::telemetry::track_patch_setup;
8use std::io::{self, Write};
9use std::path::Path;
10
11use crate::args::GlobalArgs;
12use crate::output::stdin_is_tty;
13
14/// Stringify the detected manager for telemetry.
15fn manager_name(pm: PackageManager) -> &'static str {
16    match pm {
17        PackageManager::Npm => "npm",
18        PackageManager::Pnpm => "pnpm",
19    }
20}
21
22#[derive(Args)]
23pub struct SetupArgs {
24    #[command(flatten)]
25    pub common: GlobalArgs,
26}
27
28pub async fn run(args: SetupArgs) -> i32 {
29    if !args.common.json {
30        println!("Searching for package.json files...");
31    }
32
33    let find_result = find_package_json_files(&args.common.cwd).await;
34
35    // For pnpm monorepos, only update root package.json.
36    // pnpm runs root postinstall on `pnpm install`, so workspace-level
37    // postinstall scripts are unnecessary. Individual workspaces may not
38    // have `@socketsecurity/socket-patch` as a dependency, causing
39    // `npx @socketsecurity/socket-patch apply` to fail due to pnpm's
40    // strict module isolation.
41    let package_json_files = match find_result.workspace_type {
42        WorkspaceType::Pnpm => find_result
43            .files
44            .into_iter()
45            .filter(|loc| loc.is_root)
46            .collect(),
47        _ => find_result.files,
48    };
49
50    if package_json_files.is_empty() {
51        if args.common.json {
52            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
53                "status": "no_files",
54                "updated": 0,
55                "alreadyConfigured": 0,
56                "errors": 0,
57                "files": [],
58            })).unwrap());
59        } else {
60            println!("No package.json files found");
61        }
62        return 0;
63    }
64
65    // Detect package manager from lockfiles in the project root.
66    let pm = detect_package_manager(&args.common.cwd).await;
67
68    // Setup telemetry: emit once we know a real setup is being attempted
69    // (past the "no files found" early exit) and the package manager is
70    // resolved. Carries the detected manager so we can see which install
71    // hooks are exercised in the wild.
72    track_patch_setup(
73        manager_name(pm),
74        args.common.api_token.as_deref(),
75        args.common.org.as_deref(),
76    )
77    .await;
78
79    if !args.common.json {
80        println!("Found {} package.json file(s)", package_json_files.len());
81        if pm == PackageManager::Pnpm {
82            println!("Detected pnpm project (using pnpm dlx)");
83        }
84    }
85
86    // Preview changes (always preview first)
87    let mut preview_results = Vec::new();
88    for loc in &package_json_files {
89        let result = update_package_json(&loc.path, true, pm).await;
90        preview_results.push(result);
91    }
92
93    // Display preview
94    let to_update: Vec<_> = preview_results
95        .iter()
96        .filter(|r| r.status == UpdateStatus::Updated)
97        .collect();
98    let already_configured: Vec<_> = preview_results
99        .iter()
100        .filter(|r| r.status == UpdateStatus::AlreadyConfigured)
101        .collect();
102    let errors: Vec<_> = preview_results
103        .iter()
104        .filter(|r| r.status == UpdateStatus::Error)
105        .collect();
106
107    if !args.common.json {
108        println!("\nPackage.json files to be updated:\n");
109
110        if !to_update.is_empty() {
111            println!("Will update:");
112            for result in &to_update {
113                let rel_path = pathdiff(&result.path, &args.common.cwd);
114                println!("  + {rel_path}");
115                if result.old_script.is_empty() {
116                    println!("    postinstall:   (no script)");
117                } else {
118                    println!("    postinstall:   \"{}\"", result.old_script);
119                }
120                println!("    -> postinstall: \"{}\"", result.new_script);
121                if result.old_dependencies_script.is_empty() {
122                    println!("    dependencies:  (no script)");
123                } else {
124                    println!("    dependencies:  \"{}\"", result.old_dependencies_script);
125                }
126                println!(
127                    "    -> dependencies: \"{}\"",
128                    result.new_dependencies_script
129                );
130            }
131            println!();
132        }
133
134        if !already_configured.is_empty() {
135            println!("Already configured (will skip):");
136            for result in &already_configured {
137                let rel_path = pathdiff(&result.path, &args.common.cwd);
138                println!("  = {rel_path}");
139            }
140            println!();
141        }
142
143        if !errors.is_empty() {
144            println!("Errors:");
145            for result in &errors {
146                let rel_path = pathdiff(&result.path, &args.common.cwd);
147                println!(
148                    "  ! {}: {}",
149                    rel_path,
150                    result.error.as_deref().unwrap_or("unknown error")
151                );
152            }
153            println!();
154        }
155    }
156
157    if to_update.is_empty() {
158        // Nothing to update — but that can mean two very different things:
159        // every file is already configured (a clean exit 0), or some files
160        // failed to process (e.g. malformed JSON). Errors must surface with
161        // an honest status and a non-zero exit; otherwise a parse failure is
162        // silently reported as "already configured" and CI reads it as success.
163        let errs = errors.len();
164        if args.common.json {
165            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
166                "status": if errs > 0 { "error" } else { "already_configured" },
167                "updated": 0,
168                "alreadyConfigured": already_configured.len(),
169                "errors": errs,
170                "files": preview_results.iter().map(|r| {
171                    serde_json::json!({
172                        "path": r.path,
173                        "status": match r.status {
174                            UpdateStatus::Updated => "updated",
175                            UpdateStatus::AlreadyConfigured => "already_configured",
176                            UpdateStatus::Error => "error",
177                        },
178                        "error": r.error,
179                    })
180                }).collect::<Vec<_>>(),
181            })).unwrap());
182        } else if errs > 0 {
183            // Individual errors were already listed in the preview above.
184            println!(
185                "No files were updated; {errs} file(s) could not be processed (see errors above)."
186            );
187        } else {
188            println!("All package.json files are already configured with socket-patch!");
189        }
190        return if errs > 0 { 1 } else { 0 };
191    }
192
193    // If not dry-run, ask for confirmation
194    if !args.common.dry_run {
195        if !args.common.yes && !args.common.json {
196            if !stdin_is_tty() {
197                // Non-interactive: default to yes with warning
198                eprintln!("Non-interactive mode detected, proceeding automatically.");
199            } else {
200                print!("Proceed with these changes? (y/N): ");
201                io::stdout().flush().unwrap();
202                let mut answer = String::new();
203                io::stdin().read_line(&mut answer).unwrap();
204                let answer = answer.trim().to_lowercase();
205                if answer != "y" && answer != "yes" {
206                    println!("Aborted");
207                    return 0;
208                }
209            }
210        }
211
212        if !args.common.json {
213            println!("\nApplying changes...");
214        }
215        let mut results = Vec::new();
216        for loc in &package_json_files {
217            let result = update_package_json(&loc.path, false, pm).await;
218            results.push(result);
219        }
220
221        let updated = results.iter().filter(|r| r.status == UpdateStatus::Updated).count();
222        let already = results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count();
223        let errs = results.iter().filter(|r| r.status == UpdateStatus::Error).count();
224
225        if args.common.json {
226            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
227                "status": if errs > 0 { "partial_failure" } else { "success" },
228                "updated": updated,
229                "alreadyConfigured": already,
230                "errors": errs,
231                "packageManager": match pm {
232                    PackageManager::Npm => "npm",
233                    PackageManager::Pnpm => "pnpm",
234                },
235                "files": results.iter().map(|r| {
236                    serde_json::json!({
237                        "path": r.path,
238                        "status": match r.status {
239                            UpdateStatus::Updated => "updated",
240                            UpdateStatus::AlreadyConfigured => "already_configured",
241                            UpdateStatus::Error => "error",
242                        },
243                        "error": r.error,
244                    })
245                }).collect::<Vec<_>>(),
246            })).unwrap());
247        } else {
248            println!("\nSummary:");
249            println!("  {updated} file(s) updated");
250            println!("  {already} file(s) already configured");
251            if errs > 0 {
252                println!("  {errs} error(s)");
253            }
254        }
255
256        if errs > 0 { 1 } else { 0 }
257    } else {
258        let updated = preview_results.iter().filter(|r| r.status == UpdateStatus::Updated).count();
259        let already = preview_results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count();
260        let errs = preview_results.iter().filter(|r| r.status == UpdateStatus::Error).count();
261
262        if args.common.json {
263            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
264                "status": "dry_run",
265                "wouldUpdate": updated,
266                "alreadyConfigured": already,
267                "errors": errs,
268                "dryRun": true,
269                "packageManager": match pm {
270                    PackageManager::Npm => "npm",
271                    PackageManager::Pnpm => "pnpm",
272                },
273                "files": preview_results.iter().map(|r| {
274                    serde_json::json!({
275                        "path": r.path,
276                        "status": match r.status {
277                            UpdateStatus::Updated => "updated",
278                            UpdateStatus::AlreadyConfigured => "already_configured",
279                            UpdateStatus::Error => "error",
280                        },
281                        "oldScript": r.old_script,
282                        "newScript": r.new_script,
283                        "oldDependenciesScript": r.old_dependencies_script,
284                        "newDependenciesScript": r.new_dependencies_script,
285                        "error": r.error,
286                    })
287                }).collect::<Vec<_>>(),
288            })).unwrap());
289        } else {
290            println!("\nSummary:");
291            println!("  {updated} file(s) would be updated");
292            println!("  {already} file(s) already configured");
293            if errs > 0 {
294                println!("  {errs} error(s)");
295            }
296        }
297        // Mirror the non-dry-run path: an unprocessable package.json is a
298        // failure regardless of dry-run, so it must yield a non-zero exit.
299        if errs > 0 { 1 } else { 0 }
300    }
301}
302
303fn pathdiff(path: &str, base: &Path) -> String {
304    let p = Path::new(path);
305    p.strip_prefix(base)
306        .map(|r| r.display().to_string())
307        .unwrap_or_else(|_| path.to_string())
308}