socket_patch_cli/commands/
remove.rs1use clap::Args;
2use socket_patch_core::api::client::get_api_client_with_overrides;
3use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
4use socket_patch_core::manifest::schema::PatchManifest;
5use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result};
6use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed};
7use std::path::Path;
8use std::time::Duration;
9
10use super::rollback::rollback_patches;
11use crate::args::{apply_env_toggles, GlobalArgs};
12use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
13use crate::json_envelope::{
14 Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status,
15};
16use crate::output::confirm;
17
18fn emit_error_envelope(json: bool, code: &str, message: String) {
21 if json {
22 let mut env = Envelope::new(Command::Remove);
23 env.mark_error(EnvelopeError::new(code, message));
24 println!("{}", env.to_pretty_json());
25 } else {
26 eprintln!("Error: {message}");
27 }
28}
29
30#[derive(Args)]
31pub struct RemoveArgs {
32 pub identifier: String,
34
35 #[command(flatten)]
36 pub common: GlobalArgs,
37
38 #[arg(long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", default_value_t = false)]
40 pub skip_rollback: bool,
41}
42
43pub async fn run(args: RemoveArgs) -> i32 {
44 apply_env_toggles(&args.common);
45 let (telemetry_client, _) =
46 get_api_client_with_overrides(args.common.api_client_overrides()).await;
47 let api_token = telemetry_client.api_token().cloned();
48 let org_slug = telemetry_client.org_slug().cloned();
49
50 let manifest_path = args.common.resolved_manifest_path();
51
52 if tokio::fs::metadata(&manifest_path).await.is_err() {
53 emit_error_envelope(
54 args.common.json,
55 "manifest_not_found",
56 format!("Manifest not found at {}", manifest_path.display()),
57 );
58 return 1;
59 }
60
61 let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
67 let acquired = match acquire_or_emit(
68 socket_dir,
69 Command::Remove,
70 args.common.json,
71 false, false, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
74 args.common.break_lock,
75 ) {
76 Ok(acquired) => acquired,
77 Err(code) => return code,
78 };
79 let _lock = acquired.guard;
80 let lock_was_broken = acquired.broke_lock;
81
82 let manifest = match read_manifest(&manifest_path).await {
84 Ok(Some(m)) => m,
85 Ok(None) => {
86 emit_error_envelope(args.common.json, "manifest_invalid", "Invalid manifest".to_string());
87 return 1;
88 }
89 Err(e) => {
90 emit_error_envelope(args.common.json, "manifest_unreadable", e.to_string());
91 return 1;
92 }
93 };
94
95 let matching: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> =
97 if args.identifier.starts_with("pkg:") {
98 manifest
99 .patches
100 .iter()
101 .filter(|(purl, _)| *purl == &args.identifier)
102 .collect()
103 } else {
104 manifest
105 .patches
106 .iter()
107 .filter(|(_, patch)| patch.uuid == args.identifier)
108 .collect()
109 };
110
111 if matching.is_empty() {
112 let msg = format!("No patch found matching identifier: {}", args.identifier);
113 track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
114 if args.common.json {
115 let mut env = Envelope::new(Command::Remove);
116 env.status = Status::NotFound;
117 env.error = Some(EnvelopeError::new("not_found", msg));
118 println!("{}", env.to_pretty_json());
119 } else {
120 eprintln!(
121 "No patch found matching identifier: {}",
122 args.identifier
123 );
124 }
125 return 1;
126 }
127
128 if !args.common.json {
130 eprintln!("The following patch(es) will be removed:");
131 for (purl, patch) in &matching {
132 let file_count = patch.files.len();
133 eprintln!(" - {} (UUID: {}, {} file(s))", purl, &patch.uuid[..8], file_count);
134 }
135 eprintln!();
136 }
137
138 let prompt = format!(
139 "Remove {} patch(es) and rollback files?",
140 matching.len()
141 );
142 if !confirm(&prompt, true, args.common.yes, args.common.json) {
143 if !args.common.json {
144 println!("Removal cancelled.");
145 }
146 return 0;
147 }
148
149 let mut rollback_count = 0;
151 if !args.skip_rollback {
152 if !args.common.json {
153 println!("Rolling back patch before removal...");
154 }
155 match rollback_patches(
156 &args.common.cwd,
157 &manifest_path,
158 Some(&args.identifier),
159 false,
160 args.common.json, false,
162 args.common.global,
163 args.common.global_prefix.clone(),
164 None,
165 )
166 .await
167 {
168 Ok((success, results)) => {
169 if !success {
170 track_patch_remove_failed(
171 "Rollback failed during patch removal",
172 api_token.as_deref(),
173 org_slug.as_deref(),
174 )
175 .await;
176 emit_error_envelope(
177 args.common.json,
178 "rollback_failed",
179 "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(),
180 );
181 return 1;
182 }
183
184 rollback_count = results
185 .iter()
186 .filter(|r| r.success && !r.files_rolled_back.is_empty())
187 .count();
188 let already_original = results
189 .iter()
190 .filter(|r| {
191 r.success
192 && r.files_verified.iter().all(|f| {
193 f.status
194 == socket_patch_core::patch::rollback::VerifyRollbackStatus::AlreadyOriginal
195 })
196 })
197 .count();
198
199 if !args.common.json {
200 if rollback_count > 0 {
201 println!("Rolled back {rollback_count} package(s)");
202 }
203 if already_original > 0 {
204 println!("{already_original} package(s) already in original state");
205 }
206 if results.is_empty() {
207 println!("No packages found to rollback (not installed)");
208 }
209 println!();
210 }
211 }
212 Err(e) => {
213 track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
214 emit_error_envelope(
215 args.common.json,
216 "rollback_failed",
217 format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."),
218 );
219 return 1;
220 }
221 }
222 }
223
224 match remove_patch_from_manifest(&args.identifier, &manifest_path).await {
226 Ok((removed, manifest)) => {
227 if removed.is_empty() {
228 let msg = format!("No patch found matching identifier: {}", args.identifier);
229 track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
230 if args.common.json {
231 let mut env = Envelope::new(Command::Remove);
232 env.status = Status::NotFound;
233 env.error = Some(EnvelopeError::new("not_found", msg));
234 println!("{}", env.to_pretty_json());
235 } else {
236 eprintln!(
237 "No patch found matching identifier: {}",
238 args.identifier
239 );
240 }
241 return 1;
242 }
243
244 if !args.common.json {
245 println!("Removed {} patch(es) from manifest:", removed.len());
246 for purl in &removed {
247 println!(" - {purl}");
248 }
249 println!("\nManifest updated at {}", manifest_path.display());
250 }
251
252 let socket_dir = manifest_path.parent().unwrap();
254 let blobs_path = socket_dir.join("blobs");
255 let mut blobs_removed = 0;
256 if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, false).await {
257 blobs_removed = cleanup_result.blobs_removed;
258 if !args.common.json && cleanup_result.blobs_removed > 0 {
259 println!("\n{}", format_cleanup_result(&cleanup_result, false));
260 }
261 }
262
263 if args.common.json {
264 let mut env = Envelope::new(Command::Remove);
265 if lock_was_broken {
266 env.record(lock_broken_event(socket_dir));
267 }
268 for purl in &removed {
270 env.record(PatchEvent::new(PatchAction::Removed, purl.clone()));
271 }
272 if blobs_removed > 0 {
274 env.record(
275 PatchEvent::artifact(PatchAction::Removed).with_details(serde_json::json!({
276 "blobsRemoved": blobs_removed,
277 "rolledBack": rollback_count,
278 })),
279 );
280 }
281 println!("{}", env.to_pretty_json());
282 }
283
284 track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await;
285 0
286 }
287 Err(e) => {
288 track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
289 emit_error_envelope(args.common.json, "remove_failed", e);
290 1
291 }
292 }
293}
294
295async fn remove_patch_from_manifest(
296 identifier: &str,
297 manifest_path: &Path,
298) -> Result<(Vec<String>, PatchManifest), String> {
299 let mut manifest = read_manifest(manifest_path)
300 .await
301 .map_err(|e| e.to_string())?
302 .ok_or_else(|| "Invalid manifest".to_string())?;
303
304 let mut removed = Vec::new();
305
306 if identifier.starts_with("pkg:") {
307 if manifest.patches.remove(identifier).is_some() {
308 removed.push(identifier.to_string());
309 }
310 } else {
311 let purls_to_remove: Vec<String> = manifest
312 .patches
313 .iter()
314 .filter(|(_, patch)| patch.uuid == identifier)
315 .map(|(purl, _)| purl.clone())
316 .collect();
317
318 for purl in purls_to_remove {
319 manifest.patches.remove(&purl);
320 removed.push(purl);
321 }
322 }
323
324 if !removed.is_empty() {
325 write_manifest(manifest_path, &manifest)
326 .await
327 .map_err(|e| e.to_string())?;
328 }
329
330 Ok((removed, manifest))
331}