A terminal window glowing softly in a dim room
9 min read

Building a CLI Tool That Accidentally Taught Me About Patience

What started as a weekend project to automate my deployment workflow turned into a three-month journey through yak shaving, scope creep, and an unexpected lesson about knowing when something is done.

It started, as these things always do, with a minor annoyance. Every time I deployed a new service to one of our VPS instances, I had to run the same dozen commands in roughly the same order. SSH in, pull the latest code, rebuild the container, check the logs, update the reverse proxy config, reload Nginx, test the endpoint. Rinse. Repeat. For every single service, on every single box.

The rational response would have been to write a quick shell script. Maybe 30 lines. Hardcode the paths, move on with my life. But I'm a developer, which means I have a constitutionally inability to solve a small problem in a small way.

The First Weekend: "This Will Be Quick"

I opened a new directory, ran npm init, and told myself this would be a simple Node.js CLI. A single binary. Maybe three commands: deploy, status, and rollback. I'd ship it by Sunday night.

The first thing I built was the config parser. Every project would have a small .deploy.yml file describing the service name, the target host, and the build command. Simple enough:

      
yaml
# .deploy.yml service: api-gateway host: prod-01.example.com build: docker compose up -d --build health: /api/health timeout: 30

Then I needed to parse it. I reached for js-yaml, wrote a loader, added validation with zod, and before I knew it, I had a 200-line config module that could handle inheritance, environment overrides, and multi-service stacks. It was Saturday afternoon. I hadn't written a single line of deployment logic.

The gap between "I'll just write a quick script" and "I've built a framework" is about four hours of uninterrupted coding time.

Scope Creep Wears a Friendly Face

By the end of the first week, I had a working prototype. It could parse configs, SSH into a remote host, pull code, and run a build command. But it was ugly. The output was a wall of text with no structure. So I added chalk for colors. Then ora for spinners. Then a custom log formatter that aligned timestamps and padded service names so everything lined up neatly in the terminal.

The deployment logic itself was about 40 lines. The pretty-printing was 180.

Terminal output with colored deployment status
The terminal output after the first round of polish. Every spinner was hand-tuned.

Then a friend saw a screenshot and asked, "Can it do blue-green deployments?" And I thought: well, why not? That's just running the new container alongside the old one, switching the proxy, and tearing down the original. Three steps. Maybe a day of work.

It was not a day of work.

The SSH Problem

Blue-green deployments meant I needed reliable state tracking. Which container is "blue" and which is "green"? What happens if a deployment fails halfway through? How do I roll back cleanly?

I ended up building a small state machine that tracked each deployment through five phases:

  1. Prepare — validate config, check host connectivity
  2. Build — pull code, build the new container
  3. Stage — start the new container on a shadow port
  4. Switch — update the reverse proxy to point to the new container
  5. Cleanup — remove the old container and dangling images

Each phase could succeed, fail, or timeout. Failures in the first two phases would abort cleanly. Failures in the switch phase would attempt an automatic rollback. The whole thing was event-driven, with each phase emitting lifecycle hooks that the pretty-printer consumed.

      
javascript
const PHASES = ['prepare', 'build', 'stage', 'switch', 'cleanup']; class Deployer extends EventEmitter { constructor(config) { super(); this.config = config; this.phase = 0; this.state = 'idle'; } async run() { for (const phase of PHASES) { this.emit('phase:start', phase); try { await this[phase](); this.emit('phase:done', phase); } catch (err) { this.emit('phase:fail', phase, err); if (this.canRollback(phase)) { await this.rollback(); } throw err; } } } }

I was genuinely proud of this code. It was clean, it was testable, and it handled edge cases I'd never even encountered in production. The problem was that I'd now spent three weeks on a tool that was supposed to take a weekend. And I still hadn't deployed a single thing with it.

Lesson learned

There's a particular kind of procrastination that feels exactly like productivity. You're writing code, solving real problems, making visible progress — and yet the thing you sat down to do remains undone. I've come to think of it as productive avoidance, and it's the most dangerous kind.

The Moment It Clicked

I was sitting on the porch one evening, watching my daughter ride her bike in circles in the driveway. She'd just learned to ride without training wheels and was still wobbly, still occasionally putting a foot down. But she was riding. Imperfectly, joyfully, completely unconcerned with optimization.

And I realized: my deployment script had been "riding" three weeks ago. It was wobbly. It had no spinners or state machines or blue-green anything. But it worked. It did the thing I needed it to do.

View from a porch at sunset
Sometimes the best debugging happens away from the keyboard.

I shipped the simple version that night. Three commands. No state machine. The output was just console.log with a timestamp. It deployed all four of our services in under two minutes. My team used it the next morning without a single question.

What I Kept, What I Threw Away

The over-engineered version isn't dead. It lives in a branch called someday, and I occasionally pull ideas from it. The config parser was genuinely useful, so that stayed. The SSH connection pooling turned out to matter once we hit eight services. But the state machine, the event system, the blue-green logic — all of that sits in a branch, waiting for the day I actually need it.

Which might be never. And that's fine.


The Takeaway

I used to think patience meant waiting. Sitting still while the build runs, while the tests pass, while the deploy finishes. But working on this project taught me that patience is something more active than that. It's the willingness to stop before you're done. To ship the thing that works instead of the thing that's perfect. To let "good enough" be the goal for now, with "better" as a promise for later.

The best tools I've built are the ones I had the patience to leave unfinished.

Shipping is a skill. So is stopping. The best engineers I know are the ones who've learned to do both at the right time.

— A thing I wrote in my notebook at 11pm, feeling philosophical

If you're building something right now — a side project, a tool, a rewrite of something that works but bothers you — I'd encourage you to ask yourself: is this the weekend version, or the three-month version? Both are valid. Just make sure you know which one you're building.

And if your daughter asks you to come watch her ride her bike, close the laptop. The code will wait. She won't.

← Back to all posts
Fred Lackey

Fred Lackey

Full-stack developer, infrastructure tinkerer, and dad. Writing about code, tools, and the messy overlap between work and life. Say hello.

More from the blog