use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use crate::cli::{InitArgs, OutputFormat};
use crate::error::CliError;
use crate::manifest::{Manifest, ManifestFile};
use crate::output::{Action, FileEntry, Report};
use crate::templates::{
DEFAULT_PLUGIN_VERSION, RenderContext, canonical_templates, render_template,
template_to_output_path,
};
pub fn run(args: &InitArgs, output: &OutputFormat) -> Result<(), CliError> {
let start = Instant::now();
let target = resolve_target(&args.path)?;
let opencode_dir = target.join(".opencode");
let plugin_version = args
.plugin_version
.clone()
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
let ctx = RenderContext::with_version(&plugin_version);
let mut planned: Vec<(String, Vec<u8>, Action)> = Vec::new();
let mut conflict_paths: Vec<String> = Vec::new();
for &template_path in canonical_templates() {
if args.no_package_json && template_path == "package.json.template" {
continue;
}
if args.no_gitignore && template_path == ".gitignore.template" {
continue;
}
let output_path = template_to_output_path(template_path);
let dest = opencode_dir.join(output_path);
let rendered = render_template(template_path, &ctx)
.ok_or_else(|| CliError::Generic(format!("template not found: {template_path}")))?;
let new_hash = crate::hash::sha256_hex(&rendered);
let action = if dest.exists() {
let existing = fs::read(&dest)
.map_err(|e| CliError::io(dest.to_string_lossy().into_owned(), e))?;
let existing_hash = crate::hash::sha256_hex(&existing);
if existing_hash == new_hash {
Action::Skipped
} else if args.force {
Action::Updated
} else {
conflict_paths.push(output_path.to_string());
Action::Skipped
}
} else {
Action::Created
};
planned.push((output_path.to_string(), rendered, action));
}
if !conflict_paths.is_empty() {
for p in &conflict_paths {
eprintln!("CONFLICT {p}");
}
return Err(CliError::Conflict {
path: conflict_paths[0].clone(),
});
}
if !args.dry_run {
for d in &[opencode_dir.join("plugins"), opencode_dir.join("commands")] {
fs::create_dir_all(d).map_err(|e| CliError::io(d.to_string_lossy().into_owned(), e))?;
}
for (out_path, rendered, action) in &planned {
if *action == Action::Skipped {
continue;
}
let dest = opencode_dir.join(out_path);
crate::fs_atomic::write_atomic(&dest, rendered)?;
}
}
let mut report = Report::new("init", &plugin_version);
for (out_path, rendered, action) in &planned {
let sha256 = crate::hash::sha256_hex(rendered);
let size_bytes = rendered.len() as u64;
report.files.push(FileEntry {
path: out_path.clone(),
action: action.clone(),
sha256,
expected_sha256: None,
size_bytes,
});
}
if !args.dry_run && !args.no_manifest {
let mut manifest = Manifest::new(&plugin_version);
for entry in &report.files {
manifest.add_file(ManifestFile::new(
&entry.path,
&entry.sha256,
entry.size_bytes,
true,
entry.action.as_str().to_lowercase(),
));
}
crate::manifest::save(&opencode_dir, &manifest)?;
}
report.duration_ms = start.elapsed().as_millis() as u64;
report.finalize();
emit_report(&report, output);
if !args.dry_run && args.install_hint && matches!(output, OutputFormat::Text) {
crate::output::text::print_install_hint(&opencode_dir);
}
Ok(())
}
fn resolve_target(path: &Path) -> Result<PathBuf, CliError> {
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| CliError::io("current directory", e))?
.join(path)
};
if !resolved.exists() {
return Err(CliError::Io {
path: resolved.to_string_lossy().to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "directory does not exist"),
});
}
let canonical = resolved
.canonicalize()
.map_err(|e| CliError::io(resolved.to_string_lossy().into_owned(), e))?;
let has_parent = path
.components()
.any(|c| c == std::path::Component::ParentDir);
if has_parent {
let cwd = std::env::current_dir().map_err(|e| CliError::io("current directory", e))?;
let cwd_canonical = cwd
.canonicalize()
.map_err(|e| CliError::io("current directory", e))?;
if !canonical.starts_with(&cwd_canonical) {
return Err(CliError::Usage(
"path traversal detected: path points outside the working directory".to_string(),
));
}
}
Ok(canonical)
}
fn emit_report(report: &Report, format: &OutputFormat) {
match format {
OutputFormat::Text => crate::output::text::print_report(report),
OutputFormat::Json => crate::output::json::print_report(report),
OutputFormat::Ndjson => crate::output::ndjson::print_report(report),
OutputFormat::Quiet => {}
}
}