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        if args.common.json {
159            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
160                "status": "already_configured",
161                "updated": 0,
162                "alreadyConfigured": already_configured.len(),
163                "errors": errors.len(),
164                "files": preview_results.iter().map(|r| {
165                    serde_json::json!({
166                        "path": r.path,
167                        "status": match r.status {
168                            UpdateStatus::Updated => "updated",
169                            UpdateStatus::AlreadyConfigured => "already_configured",
170                            UpdateStatus::Error => "error",
171                        },
172                        "error": r.error,
173                    })
174                }).collect::<Vec<_>>(),
175            })).unwrap());
176        } else {
177            println!("All package.json files are already configured with socket-patch!");
178        }
179        return 0;
180    }
181
182    // If not dry-run, ask for confirmation
183    if !args.common.dry_run {
184        if !args.common.yes && !args.common.json {
185            if !stdin_is_tty() {
186                // Non-interactive: default to yes with warning
187                eprintln!("Non-interactive mode detected, proceeding automatically.");
188            } else {
189                print!("Proceed with these changes? (y/N): ");
190                io::stdout().flush().unwrap();
191                let mut answer = String::new();
192                io::stdin().read_line(&mut answer).unwrap();
193                let answer = answer.trim().to_lowercase();
194                if answer != "y" && answer != "yes" {
195                    println!("Aborted");
196                    return 0;
197                }
198            }
199        }
200
201        if !args.common.json {
202            println!("\nApplying changes...");
203        }
204        let mut results = Vec::new();
205        for loc in &package_json_files {
206            let result = update_package_json(&loc.path, false, pm).await;
207            results.push(result);
208        }
209
210        let updated = results.iter().filter(|r| r.status == UpdateStatus::Updated).count();
211        let already = results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count();
212        let errs = results.iter().filter(|r| r.status == UpdateStatus::Error).count();
213
214        if args.common.json {
215            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
216                "status": if errs > 0 { "partial_failure" } else { "success" },
217                "updated": updated,
218                "alreadyConfigured": already,
219                "errors": errs,
220                "packageManager": match pm {
221                    PackageManager::Npm => "npm",
222                    PackageManager::Pnpm => "pnpm",
223                },
224                "files": results.iter().map(|r| {
225                    serde_json::json!({
226                        "path": r.path,
227                        "status": match r.status {
228                            UpdateStatus::Updated => "updated",
229                            UpdateStatus::AlreadyConfigured => "already_configured",
230                            UpdateStatus::Error => "error",
231                        },
232                        "error": r.error,
233                    })
234                }).collect::<Vec<_>>(),
235            })).unwrap());
236        } else {
237            println!("\nSummary:");
238            println!("  {updated} file(s) updated");
239            println!("  {already} file(s) already configured");
240            if errs > 0 {
241                println!("  {errs} error(s)");
242            }
243        }
244
245        if errs > 0 { 1 } else { 0 }
246    } else {
247        let updated = preview_results.iter().filter(|r| r.status == UpdateStatus::Updated).count();
248        let already = preview_results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count();
249        let errs = preview_results.iter().filter(|r| r.status == UpdateStatus::Error).count();
250
251        if args.common.json {
252            println!("{}", serde_json::to_string_pretty(&serde_json::json!({
253                "status": "dry_run",
254                "wouldUpdate": updated,
255                "alreadyConfigured": already,
256                "errors": errs,
257                "dryRun": true,
258                "packageManager": match pm {
259                    PackageManager::Npm => "npm",
260                    PackageManager::Pnpm => "pnpm",
261                },
262                "files": preview_results.iter().map(|r| {
263                    serde_json::json!({
264                        "path": r.path,
265                        "status": match r.status {
266                            UpdateStatus::Updated => "updated",
267                            UpdateStatus::AlreadyConfigured => "already_configured",
268                            UpdateStatus::Error => "error",
269                        },
270                        "oldScript": r.old_script,
271                        "newScript": r.new_script,
272                        "oldDependenciesScript": r.old_dependencies_script,
273                        "newDependenciesScript": r.new_dependencies_script,
274                        "error": r.error,
275                    })
276                }).collect::<Vec<_>>(),
277            })).unwrap());
278        } else {
279            println!("\nSummary:");
280            println!("  {updated} file(s) would be updated");
281            println!("  {already} file(s) already configured");
282            if errs > 0 {
283                println!("  {errs} error(s)");
284            }
285        }
286        0
287    }
288}
289
290fn pathdiff(path: &str, base: &Path) -> String {
291    let p = Path::new(path);
292    p.strip_prefix(base)
293        .map(|r| r.display().to_string())
294        .unwrap_or_else(|_| path.to_string())
295}