opencode_ralph_loop_cli/commands/
init.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use crate::cli::{InitArgs, OutputFormat};
6use crate::error::CliError;
7use crate::manifest::{Manifest, ManifestFile};
8use crate::output::{Action, FileEntry, Report};
9use crate::templates::{
10 DEFAULT_PLUGIN_VERSION, RenderContext, canonical_templates, render_template,
11 template_to_output_path,
12};
13
14pub fn run(args: &InitArgs, output: &OutputFormat) -> Result<(), CliError> {
15 let start = Instant::now();
16
17 let target = resolve_target(&args.path)?;
18 let opencode_dir = target.join(".opencode");
19 let plugin_version = args
20 .plugin_version
21 .clone()
22 .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
23
24 let ctx = RenderContext::with_version(&plugin_version);
25
26 let mut planned: Vec<(String, Vec<u8>, Action)> = Vec::new();
28 let mut conflict_paths: Vec<String> = Vec::new();
29
30 for &template_path in canonical_templates() {
31 if args.no_package_json && template_path == "package.json.template" {
32 continue;
33 }
34 if args.no_gitignore && template_path == ".gitignore.template" {
35 continue;
36 }
37
38 let output_path = template_to_output_path(template_path);
39 let dest = opencode_dir.join(output_path);
40
41 let rendered = render_template(template_path, &ctx)
42 .ok_or_else(|| CliError::Generic(format!("template not found: {template_path}")))?;
43
44 let new_hash = crate::hash::sha256_hex(&rendered);
45
46 let action = if dest.exists() {
47 let existing = fs::read(&dest)
48 .map_err(|e| CliError::io(dest.to_string_lossy().into_owned(), e))?;
49 let existing_hash = crate::hash::sha256_hex(&existing);
50
51 if existing_hash == new_hash {
52 Action::Skipped
53 } else if args.force {
54 Action::Updated
55 } else {
56 conflict_paths.push(output_path.to_string());
57 Action::Skipped
58 }
59 } else {
60 Action::Created
61 };
62
63 planned.push((output_path.to_string(), rendered, action));
64 }
65
66 if !conflict_paths.is_empty() {
68 for p in &conflict_paths {
69 eprintln!("CONFLICT {p}");
70 }
71 return Err(CliError::Conflict {
72 path: conflict_paths[0].clone(),
73 });
74 }
75
76 if !args.dry_run {
78 for d in &[opencode_dir.join("plugins"), opencode_dir.join("commands")] {
79 fs::create_dir_all(d).map_err(|e| CliError::io(d.to_string_lossy().into_owned(), e))?;
80 }
81
82 for (out_path, rendered, action) in &planned {
83 if *action == Action::Skipped {
84 continue;
85 }
86 let dest = opencode_dir.join(out_path);
87 crate::fs_atomic::write_atomic(&dest, rendered)?;
88 }
89 }
90
91 let mut report = Report::new("init", &plugin_version);
93 for (out_path, rendered, action) in &planned {
94 let sha256 = crate::hash::sha256_hex(rendered);
95 let size_bytes = rendered.len() as u64;
96 report.files.push(FileEntry {
97 path: out_path.clone(),
98 action: action.clone(),
99 sha256,
100 expected_sha256: None,
101 size_bytes,
102 });
103 }
104
105 if !args.dry_run && !args.no_manifest {
107 let mut manifest = Manifest::new(&plugin_version);
108 for entry in &report.files {
109 manifest.add_file(ManifestFile::new(
110 &entry.path,
111 &entry.sha256,
112 entry.size_bytes,
113 true,
114 entry.action.as_str().to_lowercase(),
115 ));
116 }
117 crate::manifest::save(&opencode_dir, &manifest)?;
118 }
119
120 report.duration_ms = start.elapsed().as_millis() as u64;
121 report.finalize();
122
123 emit_report(&report, output);
124
125 if !args.dry_run && args.install_hint && matches!(output, OutputFormat::Text) {
126 crate::output::text::print_install_hint(&opencode_dir);
127 }
128
129 Ok(())
130}
131
132fn resolve_target(path: &Path) -> Result<PathBuf, CliError> {
133 let resolved = if path.is_absolute() {
134 path.to_path_buf()
135 } else {
136 std::env::current_dir()
137 .map_err(|e| CliError::io("current directory", e))?
138 .join(path)
139 };
140
141 if !resolved.exists() {
142 return Err(CliError::Io {
143 path: resolved.to_string_lossy().to_string(),
144 source: std::io::Error::new(std::io::ErrorKind::NotFound, "directory does not exist"),
145 });
146 }
147
148 let canonical = resolved
149 .canonicalize()
150 .map_err(|e| CliError::io(resolved.to_string_lossy().into_owned(), e))?;
151
152 let has_parent = path
153 .components()
154 .any(|c| c == std::path::Component::ParentDir);
155
156 if has_parent {
157 let cwd = std::env::current_dir().map_err(|e| CliError::io("current directory", e))?;
158 let cwd_canonical = cwd
159 .canonicalize()
160 .map_err(|e| CliError::io("current directory", e))?;
161 if !canonical.starts_with(&cwd_canonical) {
162 return Err(CliError::Usage(
163 "path traversal detected: path points outside the working directory".to_string(),
164 ));
165 }
166 }
167
168 Ok(canonical)
169}
170
171fn emit_report(report: &Report, format: &OutputFormat) {
172 match format {
173 OutputFormat::Text => crate::output::text::print_report(report),
174 OutputFormat::Json => crate::output::json::print_report(report),
175 OutputFormat::Ndjson => crate::output::ndjson::print_report(report),
176 OutputFormat::Quiet => {}
177 }
178}