<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bryandero98</title>
    <description>The latest articles on DEV Community by Bryandero98 (@bryandero98).</description>
    <link>https://dev.to/bryandero98</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4099110%2F6f2d5686-8da7-487e-84f4-bff93db59d32.png</url>
      <title>DEV Community: Bryandero98</title>
      <link>https://dev.to/bryandero98</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bryandero98"/>
    <language>en</language>
    <item>
      <title>CLI Snapshot Testing: Stop Your Internal Tools From Breaking Everyone Else's Pipeline</title>
      <dc:creator>Bryandero98</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:23:57 +0000</pubDate>
      <link>https://dev.to/bryandero98/cli-snapshot-testing-stop-your-internal-tools-from-breaking-everyone-elses-pipeline-3ln8</link>
      <guid>https://dev.to/bryandero98/cli-snapshot-testing-stop-your-internal-tools-from-breaking-everyone-elses-pipeline-3ln8</guid>
      <description>&lt;p&gt;Every team with more than a couple of engineers ends up with at least one&lt;br&gt;
internal CLI - a deploy script, a codegen tool, a scaffold generator,&lt;br&gt;
something wrapping three other tools into one command. It starts small.&lt;br&gt;
Then other repos start depending on it in their own CI pipelines, other&lt;br&gt;
scripts start shelling out to it, and at some point it quietly becomes&lt;br&gt;
load-bearing infrastructure that nobody explicitly signed up to maintain a&lt;br&gt;
compatibility promise for.&lt;/p&gt;

&lt;p&gt;Then someone - reasonably, with good intentions - notices that a flag&lt;br&gt;
really &lt;em&gt;should&lt;/em&gt; be required, or that a subcommand name doesn't match the&lt;br&gt;
new naming convention, and changes it. It merges clean. Tests pass, because&lt;br&gt;
the CLI's own tests only ever tested its own repo. Twenty minutes later,&lt;br&gt;
three other teams' CI pipelines are red, and nobody connects the dots for&lt;br&gt;
another hour because the error is "missing required argument," not "your&lt;br&gt;
CLI's contract changed."&lt;/p&gt;

&lt;p&gt;REST APIs stopped having this problem years ago - most serious API teams&lt;br&gt;
run some flavor of contract testing (Pact, Specmatic, oasdiff) as a matter&lt;br&gt;
of course, specifically to catch exactly this class of accidental breaking&lt;br&gt;
change before it ships. CLIs never got the equivalent tooling, even though&lt;br&gt;
the failure mode is identical: a public interface changed shape, and&lt;br&gt;
whoever depends on that interface found out the hard way.&lt;/p&gt;
&lt;h2&gt;
  
  
  How CLI snapshotting works
&lt;/h2&gt;

&lt;p&gt;The idea behind &lt;a href="https://github.com/Bryandero98/cliguard" rel="noopener noreferrer"&gt;&lt;code&gt;cliguard&lt;/code&gt;&lt;/a&gt; is&lt;br&gt;
the same one behind Jest's snapshot tests, applied to a CLI's public&lt;br&gt;
surface instead of a rendered component:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture.&lt;/strong&gt; &lt;code&gt;cliguard init&lt;/code&gt; extracts your CLI's full contract - every
command, subcommand, flag (with its type, default, and whether it's
required), and positional argument - and writes it to
&lt;code&gt;.cliguard/contract.json&lt;/code&gt;. You commit that file like any other snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check.&lt;/strong&gt; On every PR, &lt;code&gt;cliguard check&lt;/code&gt; re-extracts the &lt;em&gt;current&lt;/em&gt;
surface and diffs it against the committed contract. Every difference
gets classified:

&lt;ul&gt;
&lt;li&gt;🔴 &lt;strong&gt;BREAKING&lt;/strong&gt; - something was removed, an optional thing became
required, a default or value type changed.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;ADDITIVE&lt;/strong&gt; - a new optional command/flag/argument was added.
Nothing that already calls your CLI can break because of it.&lt;/li&gt;
&lt;li&gt;🟡 &lt;strong&gt;PATCH&lt;/strong&gt; - cosmetic only (a description changed, an alias was
added, something required became optional).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail loud, in the right place.&lt;/strong&gt; &lt;code&gt;cliguard check&lt;/code&gt; exits &lt;code&gt;1&lt;/code&gt; the moment
it finds even one &lt;code&gt;BREAKING&lt;/code&gt; change. That failure shows up on the PR
that introduced it - not as a mystery failure in someone else's
pipeline three hours later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part worth calling out specifically: extraction never parses rendered&lt;br&gt;
&lt;code&gt;--help&lt;/code&gt; text with regular expressions. It loads your CLI's entry file into&lt;br&gt;
the Node process and reads the framework's own object graph directly - for&lt;br&gt;
Commander.js, that's &lt;code&gt;command.options&lt;/code&gt;, &lt;code&gt;command.commands&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;command.registeredArguments&lt;/code&gt;. Every field in the contract is guaranteed to&lt;br&gt;
match what the framework will actually do at runtime, because it &lt;em&gt;is&lt;/em&gt; what&lt;br&gt;
the framework will do at runtime, not a text rendering of it that has to be&lt;br&gt;
parsed back apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  Quick tutorial
&lt;/h2&gt;

&lt;p&gt;Your CLI's entry file needs to export its Commander &lt;code&gt;Command&lt;/code&gt; instance&lt;br&gt;
instead of calling &lt;code&gt;.parse()&lt;/code&gt; on it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bin/cli.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Command&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;commander&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;program&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;program&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;build&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requiredOption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-t, --target &amp;lt;target&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;build target&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;program&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// cliguard reads this - it never runs your CLI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install it as a dev dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; cliguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capture the baseline and commit it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx cliguard init ./bin/cli.js
git add .cliguard/contract.json
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore: add cliguard contract"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire the check into CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/cliguard.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CLI contract&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22.x&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx cliguard check ./bin/cli.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, the moment a PR removes a flag, flips one from optional to required,&lt;br&gt;
or changes a default value, the build fails right there with exactly what&lt;br&gt;
changed and where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 [root -&amp;gt; build -&amp;gt; option[--target]] Option "--target" was removed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you add something new and safe, the check passes straight&lt;br&gt;
through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🟢 [root -&amp;gt; build -&amp;gt; option[--dry-run]] New optional option "--dry-run" was added.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you make an intentional breaking change - a real major version bump -&lt;br&gt;
just accept the new contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx cliguard update ./bin/cli.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it's headed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cliguard&lt;/code&gt; supports Commander.js and CAC today - the core (the contract&lt;br&gt;
types and the diff engine) is 100% framework-agnostic, with every&lt;br&gt;
framework-specific detail behind a small &lt;code&gt;CliAdapter&lt;/code&gt; interface. Yargs is&lt;br&gt;
the next open &lt;a href="https://github.com/Bryandero98/cliguard/issues" rel="noopener noreferrer"&gt;good first issue&lt;/a&gt;&lt;br&gt;
if you want to try implementing one.&lt;/p&gt;

&lt;p&gt;Repo (MIT): &lt;a href="https://github.com/Bryandero98/cliguard" rel="noopener noreferrer"&gt;https://github.com/Bryandero98/cliguard&lt;/a&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cli</category>
      <category>devops</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
