`alt` CLI Tool

Timeframe:
≈3 months (Jul – Sep 2023)
Role:
Architect and sole implementer
Org Context:
≈30 frontend developers over 4 product units
Stack:
Node.js, TypeScript
terminal

A custom made CLI tool to manage full lifecycle of frontend-related units of code: npm-packages, modules and services, starting from code generation finishing with publish and operations; a tool with custom project-scoped pipelines.

1. Context

The company built a line of consumer products through four loosely-coupled units. Because the products didn’t need to share a stack, each team had drifted toward its own conventions over the years (their own server-side git hooks, their own security checks, their own unit test harness) while still relying on the same underlying infrastructure and internal services. That autonomy was healthy, but it had a cost.

By the time the org reached its current size, every team’s daily workflow had grown into a long manual checklist: create a branch following internal naming rules, write a commit message in the right format, kick off a dev build on Jenkins, run unit tests, push reports to the developer portal, publish artifacts to the registry, mark the branch or artifact as a release candidate. Engineers were doing all of it by hand, switching between four or five web UIs to get a single change shipped. Onboarding a new engineer took days of shoulder-surfing. Veterans forgot steps. Each missed step typically added one to two days to the feedback loop on quality assurance.

The scope was meaningful: roughly 10 services of ~100 modules each, plus ~100 reusable npm packages, spread across more than 100 repositories. Every engineer ran through some version of this checklist two to three times a day.

Goals for the project:

2. Constraints and non-goals

3. The decision / approach

A round of CustDev interviews surfaced a clear set of functional requirements: a plugin system with a scripting language, lifecycle commands for services / packages / modules (generate, build, test, publish, monitor), CLI-first on macOS and Linux (no Windows), and configuration at both user and project levels.

With requirements in hand, the next question was build vs. buy. None of the obvious candidates fit:

Building it ourselves on Node.js and TypeScript was the natural fit: the language was already native to every engineer who’d touch the tool, plugin authoring would feel like writing the code they wrote every day, and scripting maps cleanly onto pipeline-style workflows.

Once that was settled, I mapped each unit’s existing workflows onto a single combined diagram, looking for shared artifacts, entities, and steps. That map drove a four-iteration rollout plan:

  1. Application core — basic operations (code generation, building) ready for early adopters
  2. Data team build-out — fill in everything one specific team needed end-to-end. The data team was chosen as anchor adopter because they were the most pain-felt and the most enthusiastic, and the smallest blast radius if something broke.
  3. Essentials for the rest — extend plugins and pipelines to cover the other three units
  4. Polish — optional features and DX improvements driven by feedback

One architectural decision was made before any code was written: the core would run as a background daemon. The idea was that a long-running process could cache state across many local repositories and could expose itself through multiple front-ends down the line: a CLI today, a web GUI or system notifier tomorrow.

4. Implementation highlights

Pic 1. Diagram of three processes architecture
Pic 1. Diagram of three processes architecture

The application is structured as three processes (pic 1): the CLI, the Core Daemon, and the Notifier. The CLI is the entry point. It loads and validates user- and project-level config, parses the user’s command, spawns the Core daemon if it isn’t already running, and forwards the command to it. The Notifier is a small platform-specific process that surfaces native push notifications. The Core daemon is where the actual work happens.

The three processes talk over Unix domain sockets, exchanging HTTP-like messages written to a socket file. This keeps communication entirely at the OS level (no network interface) and leaves the door open for additional front-ends (a web GUI, an IDE plugin) without changing the daemon at all.

The Core itself follows a classic three-layer architecture: Public, Domain, and Data (pic 2), and persists its state (auth tokens, caches, temp files, in-flight pipeline state) to disk under the directories defined by the XDG Base Directory specification.

Pic 2. Core Daemon process architecture
Pic 2. Core Daemon process architecture

On the Public layer, an IPC Listener translates incoming messages into internal DTOs, and a Command Controller loads the right plugins and commands for the active context (user or project). The Command Controller extracts the task list for a given command and hands it off to the Domain layer.

On the Domain layer, the Task Manager runs the scripted pipelines. It owns the queue and the runner pool, and based on user config it can spin up several Runners in parallel. A pipeline can request access to a local tool (git, npm, node) or an external service (Jira, Jenkins, Backstage, Artifactory). Access is declared in the plugin manifest and granted either ahead of time in user config or interactively at runtime; if granted, the Runner invokes the corresponding RPC on the Data layer.

On the Data layer, a Service Provider and Local Tool Provider expose those RPCs. An IPC Sender on the same layer streams progress and notifications back out to the CLI or Notifier. Both the Command Controller and the Task Manager can publish to it.

A plugin is a directory with at minimum an alt-plugin.yaml manifest and an index.js or index.ts entry point. The manifest declares which command the plugin implements, what access it needs, and what context level it runs at. The entry point is a scripted pipeline: a mix of a small DSL exposed as JavaScript helpers, plus plain JavaScript anywhere custom logic is needed.

A real example. The manifest for alt branch:

id: "@alt/cmd-branch"
description: "This command helps to create a new branch that is compliant with Git server rules. It can take a Jira ticket number as an optional argument, and if none is provided, it will prompt the user to select from the open tickets assigned to them. This tool aims to simplify the branch creation process and improve the organization of your Git workflow."
scope: project
args:
  - name: "<JIRA_TICKET>"
    isRequired: false
    description: "Optional Jira ticket number that will be used as a part of the branch name."
examples:
  - command: "branch"
    description: "Creates new branch, shows interactive interface to choose Jira ticket."
  - command: "branch ABC-123"
    description: "Creates new branch with provided Jira ticket"
access:
  services:
    - jira
  tools:
    - git

Note how access is declared statically: the runtime enforces it via the Service Provider, so a plugin can never reach for a tool it didn’t ask for in the manifest.

The pipeline itself is a list of named steps. Each step is a pure function that returns an effect; this makes the whole thing dry-runnable and easy to reason about:

import { pipeline, step } from "@alt/cli";
import { args, listChoiceWithSuggest, prompt, fail } from "@alt/core/ui";
import { slugify, template, rand } from "@alt/core/helpers";
import { jira } from "@alt/core/services";
import { git } from "@alt/core/tools";

export default pipeline([
	step("Get Jira ticket", async ({ ctx }) => {
		const argTicket = args<string>(1);
		
		if (argTicket) {
			ctx.ticket = jira.getTicket(argTicket);
			if (!ctx.ticket) {
				return fail(`Ticket ${argTicket} does not exist`);
			}
			return;
		} else {
			const tickets = await jira.getTickets({ assignee: "me", sort: "date", sortDir: "desc", limit: 5 });
			ctx.ticket = null;
			return listChoiceWithSuggest(
				tickets.map(t => `#${t.key} ${t.summary}`),
				{ ctx, key: 'ticket' }
			);
		}
	}),
	step("Create branch name", async ({ ctx, config }) => {
		const slug = slugify(
			ctx.ticket.summary
				.replace(/\[.*?\]/g, "")
				.replace(/\s+/g, " ")
				.replace(/[\'\"\`\:\(\)\.]+/g, "")
				.trim(),
			{ lower: true }
		);
		ctx.branch = template(
			config.templates.branch,
			{
				"ticket-category": ctx.ticket.category,
				"jira-ticket-number": ctx.ticket.key,
				"slug": slug,
			},
			{
				maxLength: 64,
			}
		);
		return;
	}),
	step("Check if unique", async ({ ctx }) => {
		if (await git.getBranch(ctx.branch)) {
			ctx.answer = 'checkout';
			return prompt(
				{
					type: "expand",
					name: "branch",
					message: `There is a branch "${ctx.branch}" for the ticket ${ctx.ticket.key}.\n  Do you want to checkout to this branch or create a new one?`,
					choices: [
						{
							default: true,
							key: "c",
							name: "checkout",
							value: "checkout",
						},
						{
							key: "n",
							name: "new",
							value: "new",
						},
					],
				},
				{ ctx, key: 'answer'}
			);
		} else {
			ctx.skip = true;
			return git.checkout(ctx.branch, { flags: "-b" });
		}
	}),
	step("Create or checkout", ({ ctx }) => {
		if (ctx.answer === 'checkout') {
			return git.checkout(ctx.branch);
		} else {
			return git.checkout(ctx.branch + `-${rand({ type: "a-z", maxChars: 6 })}`, { flags: "-b" });
		}
	}, {
		skip: ctx.skip,
	}),
]);

The essential commands shipped:

5. Rollout

The design landed in front of the department at a regular all-hands for early feedback. The first month went into the application core. Once a Data team-ready slice was working, I introduced it to that team in a dedicated workshop. Adoption hit 100% within the week. The next iteration, covering the remaining three units, was ready a month later and went out at the next department meeting. Adoption again reached 100% within a week, well above the 80% target.

A few things made the rollout that smooth: the CustDev interviews up front meant the tool actually solved the problems engineers were complaining about; tight feedback loops at design and department meetings caught course corrections early; a dedicated Slack channel kept user support visible; documentation existed for both users and future maintainers; and the Data team’s enthusiasm meant the rest of the org saw real users vouching for the tool before they had to try it themselves.

After the two months of development and rollout, I handed the tool off to the Core team, full source, documentation, two two-hour workshops on architecture and maintenance, and a month of joint user support with me on third line. The tool is still maintained by the Core team and still used across all four units as of end of 2024.

6. Results

Adoption hit 100% within a week of each rollout, against an 80% target. NPS came in at 9 (n=30).

Day-to-day usage settled at:

The workflow improvements were concrete:

7. What I’d do differently

The custom pipeline feature didn’t earn its keep. Teams barely touched it, because everything they actually needed was already implemented out of the box. Internal processes evolve slowly, and when something did need to change, updating a built-in command’s source code turned out to be easier than authoring a new pipeline. The extension point still has strategic value if processes ever change radically, but in retrospect it was over-built for v1. I’d ship that capability only when there’s evidence of demand, not on speculation.

The daemon architecture was over-engineered for v1. It was designed for a future where multiple front-ends (a web GUI, system notifiers, IDE integrations) would talk to a long-running core. A year of usage showed that the rich CLI is enough for everyone. The daemon still pays off as an architectural option for later, but the complexity wasn’t necessary on day one. A simpler stateless CLI would have been faster to build and easier to maintain.

8. Further reading