use std::collections::HashSet;
use std::fs;
use anyhow::{Context as _, Result};
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{ChecksumConfig, CrateConfig, InstallScriptConfig};
use anodizer_core::context::Context;
use anodizer_core::installer::{InstallerCases, installer_crate, render_installer_cases};
use anodizer_core::stage::Stage;
const SCRIPT_TEMPLATE: &str = include_str!("install.sh.tmpl");
const DEFAULT_FILENAME: &str = "install.sh";
const DEFAULT_INSTALL_DIR: &str = "/usr/local/bin";
const DEFAULT_BASE_URL: &str = "https://github.com";
const VERSION_PLACEHOLDER: &str = "${version}";
pub struct InstallScriptStage;
impl Stage for InstallScriptStage {
fn name(&self) -> &str {
"install-script"
}
fn run(&self, ctx: &mut Context) -> Result<()> {
let configs = ctx.config.install_scripts.clone();
if configs.is_empty() {
return Ok(());
}
validate_unique_ids(&configs)?;
let dry_run = ctx.options.dry_run;
let dist = ctx.config.dist.clone();
let project_name = ctx.config.project_name.clone();
let repo_default = default_repo(ctx);
let crate_cfg = installer_crate(&ctx.config);
let derived = match &crate_cfg {
Some(c) => Some(derive_engine_tables(ctx, c)?),
None => None,
};
let mut seen_filenames = HashSet::new();
let mut artifacts: Vec<Artifact> = Vec::new();
for cfg in &configs {
let id = cfg.id.as_deref().unwrap_or("default");
let filename = cfg
.filename
.as_deref()
.unwrap_or(DEFAULT_FILENAME)
.to_string();
if let Some(ref skip) = cfg.skip
&& skip
.try_evaluates_to_true(|tmpl| ctx.render_template(tmpl))
.with_context(|| "install-script: render skip template")?
{
ctx.logger("install-script")
.verbose(&format!("install-script config '{id}' skipped"));
continue;
}
if !seen_filenames.insert(filename.clone()) {
anyhow::bail!("install-script: duplicate filename '{}'", filename);
}
let Some(derived) = &derived else {
ctx.logger("install-script").status(&format!(
"skipped install script '{filename}' — no crate builds a binstallable \
archive for the '{project_name}' binary"
));
continue;
};
let repo = cfg
.repo
.clone()
.or_else(|| repo_default.clone())
.with_context(|| {
format!(
"install-script: config '{id}' needs a `repo:` (owner/name) — could not \
derive one from the git origin remote"
)
})?;
let binaries = cfg
.binaries
.clone()
.filter(|b| !b.is_empty())
.unwrap_or_else(|| vec![project_name.clone()]);
let base_url = cfg
.base_url
.as_deref()
.unwrap_or(DEFAULT_BASE_URL)
.trim_end_matches('/')
.to_string();
let install_dir = cfg
.install_dir
.as_deref()
.unwrap_or(DEFAULT_INSTALL_DIR)
.to_string();
let verify_checksum = cfg.verify_checksum.unwrap_or(true);
let name = cfg.name.clone().unwrap_or_else(|| project_name.clone());
let script = render_script(&ScriptParams {
repo: &repo,
base_url: &base_url,
binaries: &binaries,
install_dir: &install_dir,
verify_checksum,
name: &name,
description: cfg.description.as_deref().unwrap_or(""),
homepage: cfg.homepage.as_deref().unwrap_or(""),
filename: &filename,
checksums: &derived.checksums_filename,
tag_prefix: &derived.tag_prefix,
cases: &derived.cases,
});
if dry_run {
ctx.logger("install-script")
.status(&format!("(dry-run) would build install script {filename}"));
continue;
}
let output_path = dist.join(&filename);
fs::write(&output_path, script.as_bytes())
.with_context(|| format!("install-script: write {}", output_path.display()))?;
ctx.logger("install-script")
.status(&format!("built install script {filename}"));
artifacts.push(Artifact {
kind: ArtifactKind::InstallScript,
name: filename,
path: output_path,
target: None,
crate_name: project_name.clone(),
metadata: std::collections::HashMap::from([("id".to_string(), id.to_string())]),
size: None,
});
}
for artifact in artifacts {
ctx.artifacts.add(artifact);
}
Ok(())
}
}
pub fn env_requirements(_ctx: &Context) -> Vec<anodizer_core::EnvRequirement> {
Vec::new()
}
struct DerivedTables {
cases: InstallerCases,
checksums_filename: String,
tag_prefix: String,
}
fn derive_engine_tables(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<DerivedTables> {
let prior_version = ctx.template_vars().get("Version").cloned();
ctx.template_vars_mut().set("Version", VERSION_PLACEHOLDER);
let result = (|| -> Result<DerivedTables> {
let cases =
render_installer_cases(ctx).context("install-script: derive installer case tables")?;
let checksums_filename = resolve_checksums_filename(ctx, crate_cfg)?;
let tag_prefix = resolve_tag_prefix(ctx, crate_cfg)?;
Ok(DerivedTables {
cases,
checksums_filename,
tag_prefix,
})
})();
match prior_version {
Some(v) => ctx.template_vars_mut().set("Version", &v),
None => {
ctx.template_vars_mut().unset("Version");
}
}
result
}
fn resolve_checksums_filename(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<String> {
let global = ctx
.config
.defaults
.as_ref()
.and_then(|d| d.checksum.as_ref());
let template =
ChecksumConfig::resolve_combined_name_template(crate_cfg.checksum.as_ref(), global)
.to_string();
ctx.render_template(&template)
.with_context(|| "install-script: render checksums filename")
}
fn resolve_tag_prefix(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<String> {
let template = crate_cfg
.release
.as_ref()
.and_then(|r| r.tag.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| crate_cfg.resolved_tag_template().to_string());
let rendered = ctx
.render_template(&template)
.with_context(|| format!("install-script: render tag template '{template}'"))?;
match rendered.strip_suffix(VERSION_PLACEHOLDER) {
Some(prefix) => Ok(prefix.to_string()),
None => anyhow::bail!(
"install-script: tag template '{template}' must end with the version \
(e.g. `v{{{{ Version }}}}`), but rendered to '{rendered}', which places \
text after the version. A `curl | sh` installer reconstructs the release \
tag from a runtime-resolved version and cannot invert a version-infix \
template. Use a version-suffixed `release.tag` / crate `tag_template`, \
or skip the install-script stage for this project."
),
}
}
struct ScriptParams<'a> {
repo: &'a str,
base_url: &'a str,
binaries: &'a [String],
install_dir: &'a str,
verify_checksum: bool,
name: &'a str,
description: &'a str,
homepage: &'a str,
filename: &'a str,
checksums: &'a str,
tag_prefix: &'a str,
cases: &'a InstallerCases,
}
fn render_script(params: &ScriptParams) -> String {
let name = shell_dq_escape(params.name);
let description = comment_sanitize(params.description);
let homepage = comment_sanitize(params.homepage);
let filename = comment_sanitize(params.filename);
let repo = shell_dq_escape(params.repo);
let base_url = shell_dq_escape(params.base_url);
let binaries = shell_dq_escape(¶ms.binaries.join(" "));
let verify = if params.verify_checksum {
"true"
} else {
"false"
};
let map: &[(&str, &str)] = &[
("@REPO@", &repo),
("@BASE_URL@", &base_url),
("@BINARIES@", &binaries),
("@INSTALL_DIR@", params.install_dir),
("@VERIFY_CHECKSUM@", verify),
("@FILENAME@", &filename),
("@CHECKSUMS@", params.checksums),
("@TAG_PREFIX@", params.tag_prefix),
("@DETECT_OS_CASES@", ¶ms.cases.detect_os_cases),
("@DETECT_ARCH_CASES@", ¶ms.cases.detect_arch_cases),
("@ASSET_CASES@", ¶ms.cases.asset_cases),
("@SUPPORTED_PLATFORMS@", ¶ms.cases.supported_platforms),
("@NAME@", &name),
("@DESCRIPTION@", &description),
("@HOMEPAGE@", &homepage),
];
single_pass_replace(SCRIPT_TEMPLATE, map)
}
fn shell_dq_escape(s: &str) -> String {
let one_line: String = s
.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect();
one_line
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
}
fn comment_sanitize(s: &str) -> String {
s.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect()
}
fn single_pass_replace(template: &str, map: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len() + 512);
let mut rest = template;
while let Some(at) = rest.find('@') {
out.push_str(&rest[..at]);
let after = &rest[at..];
if let Some((marker, val)) = map.iter().find(|(m, _)| after.starts_with(*m)) {
out.push_str(val);
rest = &after[marker.len()..];
} else {
out.push('@');
rest = &after[1..];
}
}
out.push_str(rest);
out
}
fn default_repo(ctx: &Context) -> Option<String> {
let root = ctx
.options
.project_root
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("."));
anodizer_core::git::resolve_github_slug_in(None, None, &root)
.ok()
.map(|slug| slug.slug().to_string())
}
fn validate_unique_ids(configs: &[InstallScriptConfig]) -> Result<()> {
let mut seen = HashSet::new();
for cfg in configs {
let id = cfg.id.as_deref().unwrap_or("default");
if !seen.insert(id.to_string()) {
anyhow::bail!("install-script: duplicate id '{}'", id);
}
}
Ok(())
}
#[cfg(test)]
mod tests;