1use anyhow::Result;
2use serde_json::json;
3use std::io::{self, IsTerminal, Write};
4use std::path::Path;
5
6use crate::auth;
7use crate::auth::output::{self, AuthRemoveResult};
8use crate::paths;
9use nils_common::fs;
10
11pub fn run(target: &str, yes: bool) -> Result<i32> {
12 run_with_json(target, yes, false)
13}
14
15pub fn run_with_json(target: &str, yes: bool, output_json: bool) -> Result<i32> {
16 if target.is_empty() {
17 return usage_error(
18 output_json,
19 "codex-remove: usage: codex-remove [--yes] <secret|secret.json>",
20 );
21 }
22
23 if auth::is_invalid_secret_target(target) {
24 if output_json {
25 output::emit_error(
26 "auth remove",
27 "invalid-secret-file-name",
28 format!("codex-remove: invalid secret file name: {target}"),
29 Some(json!({ "target": target })),
30 )?;
31 } else {
32 eprintln!("codex-remove: invalid secret file name: {target}");
33 }
34 return Ok(64);
35 }
36
37 let secret_dir = match paths::resolve_secret_dir_from_env() {
38 Some(path) => path,
39 None => {
40 if output_json {
41 output::emit_error(
42 "auth remove",
43 "secret-dir-not-configured",
44 "codex-remove: CODEX_SECRET_DIR is not configured",
45 None,
46 )?;
47 } else {
48 eprintln!("codex-remove: CODEX_SECRET_DIR is not configured");
49 print_secret_dir_setup_hint("codex-remove");
50 }
51 return Ok(1);
52 }
53 };
54
55 if !secret_dir.is_dir() {
56 if output_json {
57 output::emit_error(
58 "auth remove",
59 "secret-dir-not-found",
60 format!(
61 "codex-remove: CODEX_SECRET_DIR not found: {}",
62 secret_dir.display()
63 ),
64 Some(json!({
65 "secret_dir": secret_dir.display().to_string(),
66 })),
67 )?;
68 } else {
69 eprintln!(
70 "codex-remove: CODEX_SECRET_DIR not found: {}",
71 secret_dir.display()
72 );
73 print_secret_dir_setup_hint("codex-remove");
74 }
75 return Ok(1);
76 }
77
78 let secret_name = auth::normalize_secret_file_name(target);
79 let target_file = secret_dir.join(&secret_name);
80 if !target_file.is_file() {
81 if output_json {
82 output::emit_error(
83 "auth remove",
84 "target-not-found",
85 format!(
86 "codex-remove: secret file not found: {}",
87 target_file.display()
88 ),
89 Some(json!({
90 "target_file": target_file.display().to_string(),
91 })),
92 )?;
93 } else {
94 eprintln!(
95 "codex-remove: secret file not found: {}",
96 target_file.display()
97 );
98 }
99 return Ok(1);
100 }
101
102 if !yes {
103 if !interactive_io_available() {
104 emit_confirmation_usage_error(output_json, &target_file)?;
105 return Ok(64);
106 }
107 if output_json {
108 emit_confirmation_usage_error(true, &target_file)?;
109 return Ok(64);
110 }
111
112 if !confirm_remove(&target_file)? {
113 eprintln!(
114 "codex-remove: removal declined for {}",
115 target_file.display()
116 );
117 return Ok(1);
118 }
119 }
120
121 if let Err(err) = std::fs::remove_file(&target_file) {
122 if output_json {
123 output::emit_error(
124 "auth remove",
125 "remove-failed",
126 format!("codex-remove: failed to remove {}", target_file.display()),
127 Some(json!({
128 "target_file": target_file.display().to_string(),
129 "error": err.to_string(),
130 })),
131 )?;
132 } else {
133 eprintln!("codex-remove: failed to remove {}", target_file.display());
134 }
135 return Ok(1);
136 }
137
138 remove_target_timestamp(&target_file);
139
140 if output_json {
141 output::emit_result(
142 "auth remove",
143 AuthRemoveResult {
144 target_file: target_file.display().to_string(),
145 removed: true,
146 },
147 )?;
148 } else {
149 println!("codex: removed {}", target_file.display());
150 }
151 Ok(0)
152}
153
154fn emit_confirmation_usage_error(output_json: bool, target_file: &Path) -> Result<()> {
155 if output_json {
156 output::emit_error(
157 "auth remove",
158 "usage-error",
159 format!(
160 "codex-remove: {} exists; rerun with --yes to remove",
161 target_file.display()
162 ),
163 Some(json!({
164 "target_file": target_file.display().to_string(),
165 "removed": false,
166 })),
167 )?;
168 } else {
169 eprintln!(
170 "codex-remove: {} exists; rerun with --yes to remove",
171 target_file.display()
172 );
173 }
174 Ok(())
175}
176
177fn usage_error(output_json: bool, message: &str) -> Result<i32> {
178 if output_json {
179 output::emit_error("auth remove", "invalid-usage", message, None)?;
180 } else {
181 eprintln!("{message}");
182 }
183 Ok(64)
184}
185
186fn interactive_io_available() -> bool {
187 io::stdin().is_terminal() && io::stdout().is_terminal()
188}
189
190fn print_secret_dir_setup_hint(prefix: &str) {
191 eprintln!("{prefix}: hint: export CODEX_SECRET_DIR=\"$HOME/.config/codex_secrets\"");
192 eprintln!("{prefix}: hint: mkdir -p \"$CODEX_SECRET_DIR\" && chmod 700 \"$CODEX_SECRET_DIR\"");
193}
194
195fn confirm_remove(target: &Path) -> Result<bool> {
196 let _ = target;
197 eprint!("codex-remove: remove target file? [y/N]: ");
198 io::stderr().flush()?;
199
200 let mut line = String::new();
201 io::stdin().read_line(&mut line)?;
202 let normalized = line.trim().to_ascii_lowercase();
203 Ok(matches!(normalized.as_str(), "y" | "yes"))
204}
205
206fn remove_target_timestamp(target_file: &Path) {
207 let Some(timestamp_file) = paths::resolve_secret_timestamp_path(target_file) else {
208 return;
209 };
210 let _ = fs::write_timestamp(×tamp_file, None);
211}
212
213#[cfg(test)]
214mod tests {
215 use crate::auth::is_invalid_secret_target;
216 use crate::paths;
217 use nils_test_support::{EnvGuard, GlobalStateLock};
218
219 #[test]
220 fn invalid_target_rejects_paths_and_traversal() {
221 assert!(is_invalid_secret_target("../a.json"));
222 assert!(is_invalid_secret_target("a/b.json"));
223 assert!(is_invalid_secret_target(r"a\b.json"));
224 assert!(!is_invalid_secret_target("alpha.json"));
225 }
226
227 #[test]
228 fn resolve_secret_dir_uses_codex_secret_dir_only() {
229 let lock = GlobalStateLock::new();
230 let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
231 assert_eq!(
232 paths::resolve_secret_dir_from_env().expect("secret dir"),
233 std::path::PathBuf::from("/tmp/secrets")
234 );
235 }
236}