Skip to main content

src/version_bump/plugins/npm.gleam

//// The npm publish plugin (plugin name "npm").
////
//// Mirrors `@semantic-release/npm`. It manages a `package.json` and the npm
//// registry across three hooks:
////
////   - verify_conditions: there must be a `package.json` in `context.cwd` and an
////     `NPM_TOKEN` available (checked in `context.env`, then the process env).
////   - prepare: rewrite the `"version"` field of `package.json` to the next
////     release version, preserving the rest of the file verbatim.
////   - publish: run `npm publish` in `context.cwd`, returning a `Release`.
////
//// The only PURE function is `set_version`, which performs the `package.json`
//// version rewrite as a string transformation so it can be unit-tested without
//// any IO. It is exported for that reason.

import envoy
import gleam/dict
import gleam/int
import gleam/option.{type Option, None, Some}
import gleam/regexp.{type Regexp, Match}
import gleam/result
import gleam/string
import shellout
import simplifile

import version_bump/config.{type PluginSpec}
import version_bump/context.{type Context}
import version_bump/error.{type ReleaseError, PluginError}
import version_bump/plugin.{type Plugin}
import version_bump/release.{type NextRelease, type Release, Release}
import version_bump/task.{type Task}

/// The plugin's own name; used in `PluginError`s and the produced `Release`.
const plugin_name = "npm"

/// Build the npm plugin: implements `verify_conditions`, `prepare`, and
/// `publish`.
pub fn plugin() -> Plugin {
  plugin.Plugin(
    ..plugin.new(plugin_name),
    verify_conditions: Some(verify_conditions),
    prepare: Some(prepare),
    publish: Some(publish),
  )
}

// --- verify_conditions ------------------------------------------------------

/// Ensure a `package.json` exists in `context.cwd` and an npm auth token is present.
fn verify_conditions(
  _spec: PluginSpec,
  context: Context,
) -> Result(Nil, ReleaseError) {
  use _ <- result.try(ensure_package_json_exists(context.cwd))
  // A dry run never publishes, so npm credentials are not required to preview
  // the next release — matching @semantic-release/npm's dry-run behaviour.
  case context.dry_run {
    True -> Ok(Nil)
    False -> ensure_npm_token(context)
  }
}

/// Verify that `package.json` is present in the working directory.
fn ensure_package_json_exists(cwd: String) -> Result(Nil, ReleaseError) {
  let path = package_json_path(cwd)
  case simplifile.is_file(path) {
    Ok(True) -> Ok(Nil)
    Ok(False) | Error(_) ->
      Error(PluginError(plugin_name, "no package.json found at " <> path))
  }
}

/// Verify that an `NPM_TOKEN` is available, checking the context environment
/// first and falling back to the live process environment.
fn ensure_npm_token(context: Context) -> Result(Nil, ReleaseError) {
  case npm_token(context) {
    Some(_) -> Ok(Nil)
    None ->
      Error(PluginError(
        plugin_name,
        "NPM_TOKEN environment variable is not set",
      ))
  }
}

/// Resolve the npm auth token from `context.env`, falling back to the live process
/// environment. Empty/whitespace-only values are treated as absent.
fn npm_token(context: Context) -> Option(String) {
  let from_ctx = case dict.get(context.env, "NPM_TOKEN") {
    Ok(value) -> Some(value)
    Error(_) -> None
  }
  let token = case from_ctx {
    Some(value) -> Some(value)
    None ->
      case envoy.get("NPM_TOKEN") {
        Ok(value) -> Some(value)
        Error(_) -> None
      }
  }
  case token {
    Some(value) ->
      case string.trim(value) {
        "" -> None
        trimmed -> Some(trimmed)
      }
    None -> None
  }
}

// --- prepare ----------------------------------------------------------------

/// Rewrite `package.json`'s `version` to the next release version and write it
/// back to disk.
fn prepare(_spec: PluginSpec, context: Context) -> Result(Nil, ReleaseError) {
  use next <- result.try(require_next_release(context))
  let path = package_json_path(context.cwd)
  use contents <- result.try(read_file(path))
  use updated <- result.try(set_version(contents, next.version))
  write_file(path, updated)
}

/// Read a file, mapping any failure to a `PluginError`.
fn read_file(path: String) -> Result(String, ReleaseError) {
  simplifile.read(path)
  |> result.map_error(fn(err) {
    PluginError(
      plugin_name,
      "could not read " <> path <> ": " <> simplifile.describe_error(err),
    )
  })
}

/// Write a file, mapping any failure to a `PluginError`.
fn write_file(path: String, contents: String) -> Result(Nil, ReleaseError) {
  simplifile.write(to: path, contents: contents)
  |> result.map_error(fn(err) {
    PluginError(
      plugin_name,
      "could not write " <> path <> ": " <> simplifile.describe_error(err),
    )
  })
}

/// Replace the top-level `"version"` field in a `package.json` document with
/// `version`, leaving the rest of the document byte-for-byte intact.
///
/// PURE: a string transformation, no IO. Returns a `PluginError` when no
/// `"version"` field is present so callers can surface a clear message rather
/// than silently producing a package.json without a version.
///
/// The match targets the first `"version": "..."` pair, which in a well-formed
/// `package.json` is the top-level package version. Only the quoted value is
/// rewritten; surrounding whitespace and formatting are preserved.
pub fn set_version(
  package_json: String,
  version: String,
) -> Result(String, ReleaseError) {
  case version_regexp() {
    Error(message) -> Error(PluginError(plugin_name, message))
    Ok(re) ->
      case regexp.scan(with: re, content: package_json) {
        [Match(content: matched, submatches: [Some(prefix), _old]), ..] -> {
          let replacement = prefix <> escape_json_string(version) <> "\""
          Ok(replace_first(package_json, matched, replacement))
        }
        _ ->
          Error(PluginError(
            plugin_name,
            "no \"version\" field found in package.json",
          ))
      }
  }
}

/// Compile the regexp matching a JSON `"version": "<value>"` pair.
///
/// Submatch 1 captures everything up to and including the opening quote of the
/// value (the key, colon, whitespace, and opening `"`); submatch 2 captures the
/// existing value. Rebuilding `prefix <> new_value <> "\""` preserves the
/// original key spacing while swapping only the value.
fn version_regexp() -> Result(Regexp, String) {
  let pattern = "(\"version\"\\s*:\\s*\")((?:\\\\.|[^\"\\\\])*)\""
  regexp.from_string(pattern)
  |> result.map_error(fn(err) { "invalid version regexp: " <> err.error })
}

/// Replace only the first occurrence of `needle` in `haystack` with
/// `replacement`. Used so that rewriting the top-level `version` never also
/// rewrites an identical `"version": "x"` pair nested deeper in the document.
fn replace_first(
  haystack: String,
  needle: String,
  replacement: String,
) -> String {
  case string.split_once(haystack, needle) {
    Ok(#(before, after)) -> before <> replacement <> after
    Error(_) -> haystack
  }
}

/// Escape a string for safe inclusion inside a JSON string literal. A semantic
/// version contains only `[0-9A-Za-z.+-]`, so in practice nothing needs
/// escaping, but this keeps `set_version` correct for arbitrary input.
fn escape_json_string(value: String) -> String {
  value
  |> string.replace(each: "\\", with: "\\\\")
  |> string.replace(each: "\"", with: "\\\"")
}

// --- publish ----------------------------------------------------------------

/// Run `npm publish` in `context.cwd` and report the resulting release. `npm publish`
/// is synchronous (a subprocess), so the result is wrapped in an already-resolved
/// task to satisfy the asynchronous `publish` contract.
fn publish(
  _spec: PluginSpec,
  context: Context,
) -> Task(Result(Option(Release), ReleaseError)) {
  task.resolve({
    use next <- result.try(require_next_release(context))
    use _ <- result.try(run_npm_publish(context.cwd))
    Ok(
      Some(Release(
        name: plugin_name,
        url: None,
        version: next.version,
        git_tag: next.git_tag,
        channel: next.channel,
        plugin_name: plugin_name,
      )),
    )
  })
}

/// Shell out to `npm publish`, mapping a non-zero exit to a `PluginError`.
fn run_npm_publish(cwd: String) -> Result(Nil, ReleaseError) {
  case shellout.command(run: "npm", with: ["publish"], in: cwd, opt: []) {
    Ok(_) -> Ok(Nil)
    Error(#(code, message)) ->
      Error(PluginError(
        plugin_name,
        "`npm publish` failed (exit "
          <> int.to_string(code)
          <> "): "
          <> string.trim(message),
      ))
  }
}

// --- shared helpers ---------------------------------------------------------

/// The path to `package.json` within the working directory.
fn package_json_path(cwd: String) -> String {
  case string.ends_with(cwd, "/") {
    True -> cwd <> "package.json"
    False -> cwd <> "/package.json"
  }
}

/// Extract the next release from the context, failing when the pipeline has not
/// determined one (which should never happen by the time `prepare`/`publish`
/// run, but is handled rather than asserted).
fn require_next_release(context: Context) -> Result(NextRelease, ReleaseError) {
  case context.next_release {
    Some(next) -> Ok(next)
    None -> Error(PluginError(plugin_name, "no next release determined"))
  }
}