Skip to main content

opencode_ralph_loop_cli/commands/
init.rs

1use 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    // --- Phase 1: planning (no write I/O) ---
27    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    // --- Reject conflicts before writing anything ---
67    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    // --- Phase 2: atomic write (skip on dry-run) ---
77    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    // --- Phase 3: build report ---
92    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    // --- Phase 4: save manifest ---
106    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}