synopkg 14.0.1

Consistent dependency versions in large JavaScript Monorepos
use {
  crate::{
    cli::Cli,
    rcfile::{
      error::{NodeJsResult, RcfileError},
      Rcfile,
    },
  },
  log::debug,
  std::{path::Path, process::Command},
};

pub fn from_javascript_path(file_path: &Path) -> Result<Rcfile, RcfileError> {
  let escaped_file_path_for_nodejs = file_path.to_string_lossy().replace('\\', "\\\\");
  let nodejs_script = format!(
    r#"
    import('{escaped_file_path_for_nodejs}')
      .then(findConfig)
      .then((value) => {{
        if (isNonEmptyObject(value)) {{
          console.log(JSON.stringify({{
            _tag: 'Ok',
            value: JSON.stringify(value),
            source: 'import',
          }}));
        }} else {{
          tryRequire('Config expected at default export');
        }}
      }})
      .catch((err) => {{
        tryRequire(err.stack || err.message || 'Unknown error in import()');
      }});

    function tryRequire(importError) {{
      Promise.resolve(null)
        .then(() => require('{escaped_file_path_for_nodejs}'))
        .then(findConfig)
        .then((value) => {{
          if (isNonEmptyObject(value)) {{
            console.log(JSON.stringify({{
              _tag: 'Ok',
              value: JSON.stringify(value),
              source: 'require',
            }}));
          }} else {{
            console.log(JSON.stringify({{
              _tag: 'Err',
              importError,
              requireError: 'Config expected at module.exports',
            }}));
          }}
        }})
        .catch((err) => {{
          console.log(JSON.stringify({{
            _tag: 'Err',
            importError,
            requireError: err.stack || err.message || 'Unknown require error'
          }}));
        }});
    }};

    function isNonEmptyObject(value) {{
      return value && typeof value === 'object' && value.constructor === Object && Object.keys(value).length > 0;
    }}

    function findConfig(mod) {{
      return mod.default && mod.default.default ? mod.default.default : mod.default;
    }}
    "#
  );

  let is_typescript = file_path.to_string_lossy().ends_with("ts");
  let mut args = vec![];

  if is_typescript {
    args.push("--experimental-strip-types");
  }

  args.push("--eval");
  args.push(&nodejs_script);

  Command::new("node")
    .args(args)
    .current_dir(file_path.parent().unwrap_or_else(|| Path::new(".")))
    .output()
    .map_err(RcfileError::NodeJsExecutionFailed)
    .and_then(|output| {
      if output.status.success() {
        Ok(output.stdout)
      } else {
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        if stderr.contains("experimental-strip-types") {
          Err(RcfileError::NodeJsCannotStripTypes { stderr })
        } else {
          Err(RcfileError::ProcessFailed { stderr })
        }
      }
    })
    .and_then(|stdout| String::from_utf8(stdout).map_err(RcfileError::InvalidUtf8))
    .inspect(|json_str| {
      debug!("Raw output from {:?}: {}", file_path, json_str.trim());
    })
    .and_then(|json_str| serde_json::from_str::<NodeJsResult>(&json_str).map_err(RcfileError::JsonParseFailed))
    .and_then(|response| match response {
      NodeJsResult::Success { value } => serde_json::from_str::<Rcfile>(&value).map_err(RcfileError::InvalidConfig),
      NodeJsResult::Error {
        import_error,
        require_error,
      } => Err(RcfileError::JavaScriptImportFailed {
        import_error,
        require_error,
      }),
    })
}

pub fn try_from_js_candidates(cli: &Cli) -> Option<Result<Rcfile, RcfileError>> {
  let candidates = vec![
    ".synopkgrc.js",
    ".synopkgrc.ts",
    ".synopkgrc.mjs",
    ".synopkgrc.cjs",
    "synopkg.config.js",
    "synopkg.config.ts",
    "synopkg.config.mjs",
    "synopkg.config.cjs",
  ];
  for candidate in candidates {
    let config_path = cli.cwd.join(candidate);
    if config_path.exists() {
      debug!("Found JavaScript/TypeScript config file: {config_path:?}");
      return Some(from_javascript_path(&config_path));
    }
  }
  None
}