Skip to content

Commit 72e360e

Browse files
Alex HolmbergAlex Holmberg
authored andcommitted
feat: wire validate command, fix per-directory vuln/dep scanning, add deploy preview/run, and pagination
- Implement validate handler with hadolint, dclint, kubelint, helmlint (was a stub) - Fix vulnerability scanning to run audit tools per-directory (monorepo support) - Fix dependency scanning to discover subdirectories recursively - Add deploy preview/run CLI subcommands (non-interactive for agents) - Add --service-name flag to deploy preview/run - Add --limit/--offset pagination to retrieve command - Fix compression to handle dependencies map and vulnerable_dependencies array - Fix stdout leaking in --agent mode (quiet handlers, SYNCABLE_QUIET env var) - Add source_dir tracking to VulnerableDependency - Clean per-directory scan progress output (no repeated tool banners)
1 parent f2dd6fe commit 72e360e

9 files changed

Lines changed: 408 additions & 133 deletions

File tree

src/agent/tools/compression.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,24 @@ fn extract_issues(output: &Value) -> Vec<Value> {
260260
if field == &"vulnerable_dependencies" && !arr.is_empty() {
261261
let mut flat = Vec::new();
262262
for dep in arr {
263-
let dep_name = dep.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
263+
let dep_name = dep
264+
.get("name")
265+
.and_then(|v| v.as_str())
266+
.unwrap_or("unknown");
264267
let dep_version = dep.get("version").and_then(|v| v.as_str()).unwrap_or("?");
265-
let language = dep.get("language").cloned().unwrap_or(serde_json::Value::Null);
268+
let language = dep
269+
.get("language")
270+
.cloned()
271+
.unwrap_or(serde_json::Value::Null);
266272
if let Some(vulns) = dep.get("vulnerabilities").and_then(|v| v.as_array()) {
267273
for vuln in vulns {
268274
let mut entry = vuln.clone();
269275
if let Some(obj) = entry.as_object_mut() {
270276
obj.insert("package".to_string(), serde_json::json!(dep_name));
271-
obj.insert("package_version".to_string(), serde_json::json!(dep_version));
277+
obj.insert(
278+
"package_version".to_string(),
279+
serde_json::json!(dep_version),
280+
);
272281
obj.insert("language".to_string(), language.clone());
273282
}
274283
flat.push(entry);
@@ -698,18 +707,29 @@ pub fn compress_tool_output_cli(
698707
// Handle dependency-map outputs (e.g. {"dependencies": {...}, "total": N})
699708
// These aren't issues/findings — compress by summarizing the dep map
700709
if let Some(deps_obj) = output.get("dependencies").and_then(|v| v.as_object()) {
701-
let total = output.get("total").and_then(|v| v.as_u64()).unwrap_or(deps_obj.len() as u64);
710+
let total = output
711+
.get("total")
712+
.and_then(|v| v.as_u64())
713+
.unwrap_or(deps_obj.len() as u64);
702714

703715
// Build a compact summary: counts by source, license distribution
704-
let mut by_source: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
705-
let mut by_license: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
716+
let mut by_source: std::collections::HashMap<String, usize> =
717+
std::collections::HashMap::new();
718+
let mut by_license: std::collections::HashMap<String, usize> =
719+
std::collections::HashMap::new();
706720
let mut dev_count = 0usize;
707721
let mut prod_count = 0usize;
708722

709723
for dep in deps_obj.values() {
710-
let source = dep.get("source").and_then(|v| v.as_str()).unwrap_or("unknown");
724+
let source = dep
725+
.get("source")
726+
.and_then(|v| v.as_str())
727+
.unwrap_or("unknown");
711728
*by_source.entry(source.to_string()).or_default() += 1;
712-
let license = dep.get("license").and_then(|v| v.as_str()).unwrap_or("Unknown");
729+
let license = dep
730+
.get("license")
731+
.and_then(|v| v.as_str())
732+
.unwrap_or("Unknown");
713733
*by_license.entry(license.to_string()).or_default() += 1;
714734
if dep.get("is_dev").and_then(|v| v.as_bool()).unwrap_or(false) {
715735
dev_count += 1;
@@ -1033,10 +1053,7 @@ mod tests {
10331053

10341054
let json = parsed.unwrap();
10351055
// Must contain CLI-syntax retrieval hint
1036-
let hint = json
1037-
.get("retrieval_hint")
1038-
.and_then(|v| v.as_str())
1039-
.unwrap();
1056+
let hint = json.get("retrieval_hint").and_then(|v| v.as_str()).unwrap();
10401057
assert!(
10411058
hint.contains("sync-ctl retrieve"),
10421059
"Hint should use CLI syntax, got: {}",

src/agent/tools/output_store.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,10 @@ pub fn retrieve_filtered(
301301
"next_command".to_string(),
302302
Value::String(format!(
303303
"sync-ctl retrieve '{}' --query '{}' --offset {} --limit {}",
304-
ref_id, query, offset + limit, limit
304+
ref_id,
305+
query,
306+
offset + limit,
307+
limit
305308
)),
306309
);
307310
}
@@ -328,7 +331,10 @@ fn truncate_result_value(mut value: Value) -> Value {
328331
let truncated: Vec<Value> = refs.iter().take(3).cloned().collect();
329332
let remaining = refs.len() - 3;
330333
obj.insert("references".to_string(), Value::Array(truncated));
331-
obj.insert("references_truncated".to_string(), Value::Number(remaining.into()));
334+
obj.insert(
335+
"references_truncated".to_string(),
336+
Value::Number(remaining.into()),
337+
);
332338
}
333339
}
334340
}
@@ -368,18 +374,31 @@ fn find_issues_array(data: &Value) -> Option<Vec<Value>> {
368374
if *field == "vulnerable_dependencies" && !arr.is_empty() {
369375
let mut flat = Vec::new();
370376
for dep in arr {
371-
let dep_name = dep.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
377+
let dep_name = dep
378+
.get("name")
379+
.and_then(|v| v.as_str())
380+
.unwrap_or("unknown");
372381
let dep_version = dep.get("version").and_then(|v| v.as_str()).unwrap_or("?");
373382
let source_dir = dep.get("source_dir").cloned();
374383
let language = dep.get("language").cloned();
375384
if let Some(vulns) = dep.get("vulnerabilities").and_then(|v| v.as_array()) {
376385
for vuln in vulns {
377386
let mut entry = vuln.clone();
378387
if let Some(obj) = entry.as_object_mut() {
379-
obj.insert("package".to_string(), Value::String(dep_name.to_string()));
380-
obj.insert("package_version".to_string(), Value::String(dep_version.to_string()));
381-
if let Some(sd) = &source_dir { obj.insert("source_dir".to_string(), sd.clone()); }
382-
if let Some(lang) = &language { obj.insert("language".to_string(), lang.clone()); }
388+
obj.insert(
389+
"package".to_string(),
390+
Value::String(dep_name.to_string()),
391+
);
392+
obj.insert(
393+
"package_version".to_string(),
394+
Value::String(dep_version.to_string()),
395+
);
396+
if let Some(sd) = &source_dir {
397+
obj.insert("source_dir".to_string(), sd.clone());
398+
}
399+
if let Some(lang) = &language {
400+
obj.insert("language".to_string(), lang.clone());
401+
}
383402
}
384403
flat.push(entry);
385404
}
@@ -1378,11 +1397,13 @@ mod tests {
13781397
fs::write(
13791398
output_dir.join("test_old_aaa111.json"),
13801399
serde_json::to_string(&old_data).unwrap(),
1381-
).unwrap();
1400+
)
1401+
.unwrap();
13821402
fs::write(
13831403
output_dir.join("test_new_bbb222.json"),
13841404
serde_json::to_string(&new_data).unwrap(),
1385-
).unwrap();
1405+
)
1406+
.unwrap();
13861407

13871408
let latest = resolve_latest();
13881409
assert!(latest.is_some());

src/agent/tools/platform/deploy_service.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,9 @@ User: "deploy this service"
373373
};
374374

375375
// Get service name — use override if provided, otherwise derive from path
376-
let service_name = args.service_name.clone()
376+
let service_name = args
377+
.service_name
378+
.clone()
377379
.unwrap_or_else(|| get_service_name(&analysis_path));
378380

379381
// Find existing config with same service name
@@ -949,9 +951,9 @@ User: "deploy this service"
949951
"available": p.available,
950952
"reason_if_unavailable": p.reason_if_unavailable,
951953
})).collect::<Vec<_>>(),
952-
"machine_types": if hetzner_availability.is_some() {
954+
"machine_types": if let Some(ref ha) = hetzner_availability {
953955
// Use real-time data for Hetzner
954-
hetzner_availability.as_ref().unwrap().server_types.iter().take(6).map(|st| json!({
956+
ha.server_types.iter().take(6).map(|st| json!({
955957
"machine_type": st.id,
956958
"vcpu": st.cores,
957959
"memory_gb": st.memory_gb,
@@ -966,9 +968,9 @@ User: "deploy this service"
966968
"description": m.description,
967969
})).collect::<Vec<_>>()
968970
},
969-
"regions": if hetzner_availability.is_some() {
971+
"regions": if let Some(ref ha) = hetzner_availability {
970972
// Use real-time data for Hetzner
971-
hetzner_availability.as_ref().unwrap().regions.iter().map(|r| json!({
973+
ha.regions.iter().map(|r| json!({
972974
"region": r.id,
973975
"display_name": format!("{}, {}", r.name, r.location),
974976
"available_server_types_count": r.available_server_types.len(),

src/analyzer/dependency_parser.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,30 @@ impl DependencyParser {
216216
dirs
217217
}
218218

219-
fn walk_for_manifests(&self, dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) {
219+
fn walk_for_manifests(
220+
&self,
221+
dir: &Path,
222+
depth: usize,
223+
max_depth: usize,
224+
out: &mut Vec<PathBuf>,
225+
) {
220226
if depth >= max_depth {
221227
return;
222228
}
223229

224230
let skip_dirs = [
225-
"node_modules", "target", ".git", "vendor", "dist", "build",
226-
".next", ".nuxt", "__pycache__", ".venv", "venv", ".cargo",
231+
"node_modules",
232+
"target",
233+
".git",
234+
"vendor",
235+
"dist",
236+
"build",
237+
".next",
238+
".nuxt",
239+
"__pycache__",
240+
".venv",
241+
"venv",
242+
".cargo",
227243
];
228244

229245
let entries = match std::fs::read_dir(dir) {
@@ -259,8 +275,14 @@ impl DependencyParser {
259275

260276
fn has_package_manifest(dir: &Path) -> bool {
261277
let manifests = [
262-
"Cargo.toml", "package.json", "requirements.txt", "pyproject.toml",
263-
"Pipfile", "go.mod", "pom.xml", "build.gradle",
278+
"Cargo.toml",
279+
"package.json",
280+
"requirements.txt",
281+
"pyproject.toml",
282+
"Pipfile",
283+
"go.mod",
284+
"pom.xml",
285+
"build.gradle",
264286
];
265287
manifests.iter().any(|m| dir.join(m).exists())
266288
}
@@ -275,7 +297,10 @@ impl DependencyParser {
275297
if dir.join("Cargo.toml").exists() {
276298
if let Ok(rust_deps) = self.parse_rust_deps(dir) {
277299
if !rust_deps.is_empty() {
278-
dependencies.entry(Language::Rust).or_default().extend(rust_deps);
300+
dependencies
301+
.entry(Language::Rust)
302+
.or_default()
303+
.extend(rust_deps);
279304
}
280305
}
281306
}
@@ -284,7 +309,10 @@ impl DependencyParser {
284309
if dir.join("package.json").exists() {
285310
if let Ok(js_deps) = self.parse_js_deps(dir) {
286311
if !js_deps.is_empty() {
287-
dependencies.entry(Language::JavaScript).or_default().extend(js_deps);
312+
dependencies
313+
.entry(Language::JavaScript)
314+
.or_default()
315+
.extend(js_deps);
288316
}
289317
}
290318
}
@@ -296,7 +324,10 @@ impl DependencyParser {
296324
{
297325
if let Ok(py_deps) = self.parse_python_deps(dir) {
298326
if !py_deps.is_empty() {
299-
dependencies.entry(Language::Python).or_default().extend(py_deps);
327+
dependencies
328+
.entry(Language::Python)
329+
.or_default()
330+
.extend(py_deps);
300331
}
301332
}
302333
}
@@ -305,7 +336,10 @@ impl DependencyParser {
305336
if dir.join("go.mod").exists() {
306337
if let Ok(go_deps) = self.parse_go_deps(dir) {
307338
if !go_deps.is_empty() {
308-
dependencies.entry(Language::Go).or_default().extend(go_deps);
339+
dependencies
340+
.entry(Language::Go)
341+
.or_default()
342+
.extend(go_deps);
309343
}
310344
}
311345
}
@@ -314,7 +348,10 @@ impl DependencyParser {
314348
if dir.join("pom.xml").exists() || dir.join("build.gradle").exists() {
315349
if let Ok(java_deps) = self.parse_java_deps(dir) {
316350
if !java_deps.is_empty() {
317-
dependencies.entry(Language::Java).or_default().extend(java_deps);
351+
dependencies
352+
.entry(Language::Java)
353+
.or_default()
354+
.extend(java_deps);
318355
}
319356
}
320357
}

src/handlers/deploy.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ pub async fn handle_deploy_run(
8787
is_secret: false,
8888
});
8989
} else {
90-
eprintln!("Warning: ignoring malformed --env '{}' (expected KEY=VALUE)", env_str);
90+
eprintln!(
91+
"Warning: ignoring malformed --env '{}' (expected KEY=VALUE)",
92+
env_str
93+
);
9194
}
9295
}
9396

@@ -111,7 +114,11 @@ pub async fn handle_deploy_run(
111114
}
112115
if let Some((key, value)) = line.split_once('=') {
113116
let key = key.trim().to_string();
114-
let value = value.trim().trim_matches('"').trim_matches('\'').to_string();
117+
let value = value
118+
.trim()
119+
.trim_matches('"')
120+
.trim_matches('\'')
121+
.to_string();
115122
// Detect likely secrets by key name
116123
let looks_secret = key.contains("SECRET")
117124
|| key.contains("KEY")
@@ -135,7 +142,10 @@ pub async fn handle_deploy_run(
135142
}
136143
}
137144
} else {
138-
eprintln!("Warning: could not read env file: {}", env_file_path.display());
145+
eprintln!(
146+
"Warning: could not read env file: {}",
147+
env_file_path.display()
148+
);
139149
}
140150
} else {
141151
eprintln!("Warning: env file not found: {}", env_file_path.display());
@@ -155,7 +165,11 @@ pub async fn handle_deploy_run(
155165
min_instances,
156166
max_instances,
157167
preview_only: false,
158-
secret_keys: if secret_keys.is_empty() { None } else { Some(secret_keys) },
168+
secret_keys: if secret_keys.is_empty() {
169+
None
170+
} else {
171+
Some(secret_keys)
172+
},
159173
};
160174

161175
// Use InteractiveCli so secrets can be prompted in terminal

0 commit comments

Comments
 (0)