Skip to main content

src/version_bump@plugins@npm.erl

-module(version_bump@plugins@npm).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/plugins/npm.gleam").
-export([set_version/2, plugin/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

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

-file("src/version_bump/plugins/npm.gleam", 237).
?DOC(" Shell out to `npm publish`, mapping a non-zero exit to a `PluginError`.\n").
-spec run_npm_publish(binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
run_npm_publish(Cwd) ->
    case shellout:command(<<"npm"/utf8>>, [<<"publish"/utf8>>], Cwd, []) of
        {ok, _} ->
            {ok, nil};

        {error, {Code, Message}} ->
            {error,
                {plugin_error,
                    <<"npm"/utf8>>,
                    <<<<<<"`npm publish` failed (exit "/utf8,
                                (erlang:integer_to_binary(Code))/binary>>/binary,
                            "): "/utf8>>/binary,
                        (gleam@string:trim(Message))/binary>>}}
    end.

-file("src/version_bump/plugins/npm.gleam", 264).
?DOC(
    " Extract the next release from the context, failing when the pipeline has not\n"
    " determined one (which should never happen by the time `prepare`/`publish`\n"
    " run, but is handled rather than asserted).\n"
).
-spec require_next_release(version_bump@context:context()) -> {ok,
        version_bump@release:next_release()} |
    {error, version_bump@error:release_error()}.
require_next_release(Context) ->
    case erlang:element(9, Context) of
        {some, Next} ->
            {ok, Next};

        none ->
            {error,
                {plugin_error,
                    <<"npm"/utf8>>,
                    <<"no next release determined"/utf8>>}}
    end.

-file("src/version_bump/plugins/npm.gleam", 216).
?DOC(
    " Run `npm publish` in `context.cwd` and report the resulting release. `npm publish`\n"
    " is synchronous (a subprocess), so the result is wrapped in an already-resolved\n"
    " task to satisfy the asynchronous `publish` contract.\n"
).
-spec publish(version_bump@config:plugin_spec(), version_bump@context:context()) -> version_bump@task:task({ok,
        gleam@option:option(version_bump@release:release())} |
    {error, version_bump@error:release_error()}).
publish(_, Context) ->
    version_bump_task_ffi:resolve(
        begin
            gleam@result:'try'(
                require_next_release(Context),
                fun(Next) ->
                    gleam@result:'try'(
                        run_npm_publish(erlang:element(2, Context)),
                        fun(_) ->
                            {ok,
                                {some,
                                    {release,
                                        <<"npm"/utf8>>,
                                        none,
                                        erlang:element(2, Next),
                                        erlang:element(4, Next),
                                        erlang:element(6, Next),
                                        <<"npm"/utf8>>}}}
                        end
                    )
                end
            )
        end
    ).

-file("src/version_bump/plugins/npm.gleam", 135).
?DOC(" Write a file, mapping any failure to a `PluginError`.\n").
-spec write_file(binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
write_file(Path, Contents) ->
    _pipe = simplifile:write(Path, Contents),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            {plugin_error,
                <<"npm"/utf8>>,
                <<<<<<"could not write "/utf8, Path/binary>>/binary, ": "/utf8>>/binary,
                    (simplifile:describe_error(Err))/binary>>}
        end
    ).

-file("src/version_bump/plugins/npm.gleam", 191).
?DOC(
    " Replace only the first occurrence of `needle` in `haystack` with\n"
    " `replacement`. Used so that rewriting the top-level `version` never also\n"
    " rewrites an identical `\"version\": \"x\"` pair nested deeper in the document.\n"
).
-spec replace_first(binary(), binary(), binary()) -> binary().
replace_first(Haystack, Needle, Replacement) ->
    case gleam@string:split_once(Haystack, Needle) of
        {ok, {Before, After}} ->
            <<<<Before/binary, Replacement/binary>>/binary, After/binary>>;

        {error, _} ->
            Haystack
    end.

-file("src/version_bump/plugins/npm.gleam", 205).
?DOC(
    " Escape a string for safe inclusion inside a JSON string literal. A semantic\n"
    " version contains only `[0-9A-Za-z.+-]`, so in practice nothing needs\n"
    " escaping, but this keeps `set_version` correct for arbitrary input.\n"
).
-spec escape_json_string(binary()) -> binary().
escape_json_string(Value) ->
    _pipe = Value,
    _pipe@1 = gleam@string:replace(_pipe, <<"\\"/utf8>>, <<"\\\\"/utf8>>),
    gleam@string:replace(_pipe@1, <<"\""/utf8>>, <<"\\\""/utf8>>).

-file("src/version_bump/plugins/npm.gleam", 182).
?DOC(
    " Compile the regexp matching a JSON `\"version\": \"<value>\"` pair.\n"
    "\n"
    " Submatch 1 captures everything up to and including the opening quote of the\n"
    " value (the key, colon, whitespace, and opening `\"`); submatch 2 captures the\n"
    " existing value. Rebuilding `prefix <> new_value <> \"\\\"\"` preserves the\n"
    " original key spacing while swapping only the value.\n"
).
-spec version_regexp() -> {ok, gleam@regexp:regexp()} | {error, binary()}.
version_regexp() ->
    Pattern = <<"(\"version\"\\s*:\\s*\")((?:\\\\.|[^\"\\\\])*)\""/utf8>>,
    _pipe = gleam@regexp:from_string(Pattern),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            <<"invalid version regexp: "/utf8, (erlang:element(2, Err))/binary>>
        end
    ).

-file("src/version_bump/plugins/npm.gleam", 155).
?DOC(
    " Replace the top-level `\"version\"` field in a `package.json` document with\n"
    " `version`, leaving the rest of the document byte-for-byte intact.\n"
    "\n"
    " PURE: a string transformation, no IO. Returns a `PluginError` when no\n"
    " `\"version\"` field is present so callers can surface a clear message rather\n"
    " than silently producing a package.json without a version.\n"
    "\n"
    " The match targets the first `\"version\": \"...\"` pair, which in a well-formed\n"
    " `package.json` is the top-level package version. Only the quoted value is\n"
    " rewritten; surrounding whitespace and formatting are preserved.\n"
).
-spec set_version(binary(), binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
set_version(Package_json, Version) ->
    case version_regexp() of
        {error, Message} ->
            {error, {plugin_error, <<"npm"/utf8>>, Message}};

        {ok, Re} ->
            case gleam@regexp:scan(Re, Package_json) of
                [{match, Matched, [{some, Prefix}, _]} | _] ->
                    Replacement = <<<<Prefix/binary,
                            (escape_json_string(Version))/binary>>/binary,
                        "\""/utf8>>,
                    {ok, replace_first(Package_json, Matched, Replacement)};

                _ ->
                    {error,
                        {plugin_error,
                            <<"npm"/utf8>>,
                            <<"no \"version\" field found in package.json"/utf8>>}}
            end
    end.

-file("src/version_bump/plugins/npm.gleam", 124).
?DOC(" Read a file, mapping any failure to a `PluginError`.\n").
-spec read_file(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
read_file(Path) ->
    _pipe = simplifile:read(Path),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            {plugin_error,
                <<"npm"/utf8>>,
                <<<<<<"could not read "/utf8, Path/binary>>/binary, ": "/utf8>>/binary,
                    (simplifile:describe_error(Err))/binary>>}
        end
    ).

-file("src/version_bump/plugins/npm.gleam", 254).
?DOC(" The path to `package.json` within the working directory.\n").
-spec package_json_path(binary()) -> binary().
package_json_path(Cwd) ->
    case gleam_stdlib:string_ends_with(Cwd, <<"/"/utf8>>) of
        true ->
            <<Cwd/binary, "package.json"/utf8>>;

        false ->
            <<Cwd/binary, "/package.json"/utf8>>
    end.

-file("src/version_bump/plugins/npm.gleam", 115).
?DOC(
    " Rewrite `package.json`'s `version` to the next release version and write it\n"
    " back to disk.\n"
).
-spec prepare(version_bump@config:plugin_spec(), version_bump@context:context()) -> {ok,
        nil} |
    {error, version_bump@error:release_error()}.
prepare(_, Context) ->
    gleam@result:'try'(
        require_next_release(Context),
        fun(Next) ->
            Path = package_json_path(erlang:element(2, Context)),
            gleam@result:'try'(
                read_file(Path),
                fun(Contents) ->
                    gleam@result:'try'(
                        set_version(Contents, erlang:element(2, Next)),
                        fun(Updated) -> write_file(Path, Updated) end
                    )
                end
            )
        end
    ).

-file("src/version_bump/plugins/npm.gleam", 88).
?DOC(
    " Resolve the npm auth token from `context.env`, falling back to the live process\n"
    " environment. Empty/whitespace-only values are treated as absent.\n"
).
-spec npm_token(version_bump@context:context()) -> gleam@option:option(binary()).
npm_token(Context) ->
    From_ctx = case gleam_stdlib:map_get(
        erlang:element(3, Context),
        <<"NPM_TOKEN"/utf8>>
    ) of
        {ok, Value} ->
            {some, Value};

        {error, _} ->
            none
    end,
    Token = case From_ctx of
        {some, Value@1} ->
            {some, Value@1};

        none ->
            case envoy_ffi:get(<<"NPM_TOKEN"/utf8>>) of
                {ok, Value@2} ->
                    {some, Value@2};

                {error, _} ->
                    none
            end
    end,
    case Token of
        {some, Value@3} ->
            case gleam@string:trim(Value@3) of
                <<""/utf8>> ->
                    none;

                Trimmed ->
                    {some, Trimmed}
            end;

        none ->
            none
    end.

-file("src/version_bump/plugins/npm.gleam", 75).
?DOC(
    " Verify that an `NPM_TOKEN` is available, checking the context environment\n"
    " first and falling back to the live process environment.\n"
).
-spec ensure_npm_token(version_bump@context:context()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
ensure_npm_token(Context) ->
    case npm_token(Context) of
        {some, _} ->
            {ok, nil};

        none ->
            {error,
                {plugin_error,
                    <<"npm"/utf8>>,
                    <<"NPM_TOKEN environment variable is not set"/utf8>>}}
    end.

-file("src/version_bump/plugins/npm.gleam", 64).
?DOC(" Verify that `package.json` is present in the working directory.\n").
-spec ensure_package_json_exists(binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
ensure_package_json_exists(Cwd) ->
    Path = package_json_path(Cwd),
    case simplifile_erl:is_file(Path) of
        {ok, true} ->
            {ok, nil};

        {ok, false} ->
            {error,
                {plugin_error,
                    <<"npm"/utf8>>,
                    <<"no package.json found at "/utf8, Path/binary>>}};

        {error, _} ->
            {error,
                {plugin_error,
                    <<"npm"/utf8>>,
                    <<"no package.json found at "/utf8, Path/binary>>}}
    end.

-file("src/version_bump/plugins/npm.gleam", 50).
?DOC(" Ensure a `package.json` exists in `context.cwd` and an npm auth token is present.\n").
-spec verify_conditions(
    version_bump@config:plugin_spec(),
    version_bump@context:context()
) -> {ok, nil} | {error, version_bump@error:release_error()}.
verify_conditions(_, Context) ->
    gleam@result:'try'(
        ensure_package_json_exists(erlang:element(2, Context)),
        fun(_) -> case erlang:element(12, Context) of
                true ->
                    {ok, nil};

                false ->
                    ensure_npm_token(Context)
            end end
    ).

-file("src/version_bump/plugins/npm.gleam", 38).
?DOC(
    " Build the npm plugin: implements `verify_conditions`, `prepare`, and\n"
    " `publish`.\n"
).
-spec plugin() -> version_bump@plugin:plugin().
plugin() ->
    _record = version_bump@plugin:new(<<"npm"/utf8>>),
    {plugin,
        erlang:element(2, _record),
        {some, fun verify_conditions/2},
        erlang:element(4, _record),
        erlang:element(5, _record),
        erlang:element(6, _record),
        erlang:element(7, _record),
        {some, fun prepare/2},
        {some, fun publish/2},
        erlang:element(10, _record),
        erlang:element(11, _record)}.