Writing a portable codebase
Every new codebase is started by a single individual working on one computer. That does not last long, though. New environments where the code must run and be built pop up quickly. Sometimes, it is a new, shiny laptop used on the run. Often, it is a ci/cd pipeline where you want to mimic the local commands. And if the code is valuable, there will be new contributors with their operating systems and habits. Very quickly, things get out of control as each machine where the code runs has its quirks, and a far-from-trivial amount of work needs to be done to make the codebase portable.
There are two ways of approaching this problem. You can try to control the environment, for example, by making all machines look the same, or you can try to make the codebase portable so that it works on different machines. Large organizations take the first path by specifying hardware and operating systems and tooling for their employees and deployment environments. In contrast, loosely centralized or fully distributed organizations, such as open source, have no option and go through the latter.
Both are roads full of obstacles and pains, with significant trade-offs. There is a third path somewhere in between, making the controlled environment portable. Our industry has attempted this many times with limited success. For running software in production, we have certainly seen great advancements, from VMs to containers, but the results in the development environments have been paltry, at least. Tools like Vagrant, Devcontainers, and Tilt have tried to backport production isolation layers to developers' screens for a while. Although they are not without merit, widespread adoption is yet to be seen. The problem is that too many workflows, idioms, and tools exist.
Reproducibility in a dynamic environment is also very hard to achieve. Production code is primarily read-only, and you can pack your release in a nice sha256 signed blob, like a docker image, and be confident you have the same bits running every time. However, by definition, development environments are constantly changing. The span and the frequency of variation across dependencies, operating systems, memory amount, disk speeds, and CPU architecture are way beyond what is seen in production, and on top of that, code is being live edited and experimented with. You get surprising, long-lasting side effects from things like compilation or integration tests, and suddenly, your code feels like a delicate flower that can only blossom in the fragile, poorly understood garden that is your local computer.
The exercise we will do in this write-up is to build a pragmatic portable codebase. We will leverage the local environment and tools for the more developer-oriented stages and move towards stronger isolation layers as we dive into the operations world. This is a balancing act since the software development landscape is ever-changing. We will introduce concepts, which tend to last longer, back them up with tools, which inevitably rot, and seam them together with conventions that shall help bridge the lifetime gap among those.
We require that everything can be bootstrapped and successfully operated in Windows, Mac, and major Linux distributions. Also, it should support Apple and Intel silicon and be easily lifted to container-centric environments for things like CI/CD and cloud deployments. We will restrict ourselves to a multi-repo design for simplicity but hint at extending the design to larger monorepos with internal dependencies. The ideas are polyglot in essence, and can be applied to most common programming languages, and we will showcase a handful of them. Finally, the techniques are gradual, which means you can start a new codebase with them or incrementally move an existing codebase towards portability.
From the social perspective, the codebase should have a smooth onboarding experience. It should suit companies hiring engineers fast through a central process with locked-in standardized laptops or a hobby project that takes contributions from random individuals on the internet writing code on their personal computers. It should also be a comfortable place for LLMs, the programmers of the new world. For all these to work, we rely on existing, battle-proven, and future-safe tooling rather than trying to write an all-encompassing system as many have tried before.
The developer toolbelt
Three leading IDEs are widely used today. Visual Studio Code leads the pack as a highly extensible solution, IntelliJ and the JetBrains family offer the most integrated solution, and NeoVim found a niche. There are many other interesting options and a bunch of new exciting players popping up, like Zed or Helix. From our perspective, we will focus on Visual Studio Code, not for its merits, which are many, but because its popularity makes it widely supported by other tools.
Hence, we will start by leveraging Visual Studio Code configuration to describe how code should be built and how unit tests can be run. This can be expressed in the .vscode/tasks.json
file, and plenty of recipes can be found around the internet on how to set this up for any language or framework. It also has cross-platform support, offering different invocations per operating system. Whether you use Visual Studio Code or not, this standard configuration can be leveraged to integrate with other tools. For example, the vscode-task-runner allows you to run vtr build
on the shell and build your code the way Visual Studio Code would have. Or, if you are into NeoVim, you can use Overseer for a more tightly integrated experience. Ultimately, this is the most popular configuration format for building and testing code; you only need to commit it to your repository to let many tools benefit.
The next item in our toolbelt is a command runner. For this, we will use casey/just. Besides being an excellent portable tool, it has a few critical properties. First, it is composable through its import statement. This is critical for monorepos, and we favor tools that have a composition mechanism. Second, it has repository-scoped invocation semantics, which means that it can fall back to the git root for a default configuration. With this, one can run just build
in any directory, and a root .justfile
can delegate to vtr build
which will run the instructions on that directory .vscode/tasks.json
. This is an excellent first win; it does not matter if it is your operating system, the editor you use, or your programming language. With minimal intrusion, any code can now be built with a single uniform command in the terminal.
No matter how powerful the tools you have, every codebase relies on some ad-hoc scripts. Bash is a popular option but does not scale well regarding composition and readability. It is not that widely available either. Mac ships with an old version, Windows has Cygwin and git-bash distros, and the high overhead WSL one, and it is too easy to shoot yourself in the foot. Posix shell fares only slightly better. Powershell is portable but a vast and hard-to-install beast. We will go with nushell
, which hits many high notes, is relatively small, easy to write scripts, and has excellent startup time.
A uniform set of command line utilities is also desirable. Because we want those to be small, fast, and portable, not by coincidence, they tend to be all written in Rust or Golang. There are many modern unix tools lists, but here are some that you may find helpful: rg, fd, fzf, bat, zoxide, jq, yq, lsd, duf, hyperfine, and dive. They all run just fine in Windows and may suffice for your muscle memory to work across machines.
Bootstrapping your environment
We will need a package manager to install our command runner, scripting shell, and any other utility. Unfortunately, no good package manager exists that works across all three operating systems yet. For Windows, scoop
is the best option. For everything else, we will go with pkgx.sh
, the new shiny toy from the homebrew creator. Reproducibility is a crucial factor, and both these tools have reasonable support for version pinning. But Pkgx has a killer feature no other package manager offers: lazy installation support. This helps with isolation and works wonders with docker, enabling tiny images. In the world of fast and cheap downloads, expensive CPUs, and uploads, this is a match made in heaven for docker build cache mounts.
There is one last challenge. How do we install the package manager itself in a portable fashion? We need to rely on tools that are shipped by default. On Linux and Mac, a POSIX shell is always available. For Windows, we can rely on PowerShell. The trick here is that if you write two scripts, one without an extension and another with theps1
extension, you can use the same call syntax on all operating systems. For example, if we create the two scripts below on the root of our repository and name them boostrap
and bootstrap.ps1
, we can run ./bootstrap
from the command line, and Windows will magically run ./bootstrap.ps1
whereas in Linux or Mac, boostrap
will be picked.
#!/bin/sh
command -v pkgx >/dev/null || eval "$(curl -Ssf https://pkgx.sh)"
command -v nu >/dev/null || pkgx install nushell.sh@0.96.1
command -v just >/dev/null || pkgx install just.systems@1.29.1
# Check if scoop command is available
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri "https://get.scoop.sh" | Invoke-Expression
}
# Check if nu command is available
if (-not (Get-Command nu -ErrorAction SilentlyContinue)) {
scoop install nu@0.96.1
}
# Check if just command is available
if (-not (Get-Command just -ErrorAction SilentlyContinue)) {
scoop install just@1.34.0
}
Just say the word
Let us wrap this up by showing the rest of the needed scripts and configuration files. In the end, we will end up with the following steps that we could run in GitHub actions as a CI/CD, in either Mac, Ubuntu, or Windows machine, for any framework. Moreover, the same commands would work inside a docker container, as the onboarding instructions for the first day of a new developer in a company, in the DEVELOPING.md of an open source project with new contributors joining with their own laptops, or even as the prebuild steps of a GitHub devcontainer. Each of these environments may need some surrounding machinery to enable the cache to work correctly, but we will skip that in this text.
jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
runs-on: ${{ matrix.os }}
steps:
- run: ./bootstrap
- run: just setup
- run: just build
- run: just test
We have seen the ./bootstrap
script, which is, in fact, two simple scripts. To add the remaining commands, we will use a .justfile
in the root of the repository to pick up the command and delegate it to a portable nushell
script. That script will be responsible for locally installing all the dependencies required to work with the codebase. See below the .justfile
. The Medium.com code box lacks native syntax highlight for it, but just
is still popular enough that it has syntax plugins for most editors and is mature enough to not only have cross-platform support but also good composition support.
set shell := ["nu", "-c"]
@_default:
just --list --unsorted
[no-cd]
build:
just say build
[no-cd]
test:
just say test
[private]
[no-cd]
say target *args:
nu {{justfile_directory()}}/say.nu {{target}} {{args}}
The say.nu
is a straightforward script that will delegate to the package managers for the setup
command and will delegate to vscode-task-runner
the build
and test
commands. Let us start with the latter two.
#!/usr/bin/env nu
def test [...args] { vtr test ...$args }
def build [...args] { vtr build ...$args }
def --wrapped vtr [...args: string] {
pipx vscode-task-runner ...$args
}
def pipx [pkg, ...args] {
if ((sys host | get name) == 'Windows') {
vrun pipx run -q $pkg ...$args
} else {
vrun pkgx +pypa.github.io/pipx pipx run -q $pkg ...$args
}
}
def --wrapped vrun [cmd, ...args] {
print $"($cmd) ($args | str join ' ')"
^$cmd ...$args
}
As you can see, we try not to affect the local machine as much as possible. The usage of vscode-task-runner
, in particular, is tricky since it is a Python-based dependency, which is probably worth rewriting in Golang to avoid the cost of installing the runtime. Nonetheless, on Mac and Linux the usage of Pkgx.sh alleviates this cost since we can install pipx
lazily, which in turn offers a cacheable one-off mechanism for running vtr
. On Windows, until native Pkgx.sh support is launched, we need to rely on a global pipx
installation.
The setup
command will install all the local dependencies for the development. Let us assume this is pnpm/nuxt project, but the same instructions are easily applicable to most languages/frameworks. Let us extend say.nu
to implement it. Ah, if you are wondering why the namesay.nu
, it is just a contrived acronym for Scaling Your Artifacts with Your Team that happens to sound nice when coupled with just
. Let us add a main
function as well to route between setup
, build
and test.
def setup [...args] {
if ((sys host | get name) != 'Windows') {
open .pkgx.yaml | get -i dependencies | filter { is-not-empty } | split row " " | par-each { |it| vrun pkgx install $it }
} else {
open .pkgx.yaml | get -i get env.SAY_SCOOP_INSTALL | filter { is-not-empty } | split row " " | par-each { |it| vrun pkgx install $it }
}
# fallback to .pkgx.nu for non-standard installation needs
if ('.pkgx.nu' | path exists) { nu '.pkgx.nu' }
}
def --wrapped main [
--directory (-d) = ".", # directory where to run the command
subcommand?: string, ...args] {
cd $directory
match $subcommand {
"setup" => { setup ...$args },
"build" => { build ...$args },
"test" => { test ...$args },
_ => {
$"subcommand ($subcommand) not found"
}
}
}
The .pkgx.yml
is a standard configuration from Pkgx.sh, and if you are on Mac or Linux, you can use its shell integration to have a venv style locally isolated local setup by just calling dev
instead of calling just setup
. For Windows, we abuse a bit its env variable support to encode the packages we need to feed to scoop
.
dependencies: nodejs.org@20.13.1 pnpm.io@9.1.2
env:
SAY_SCOOP_INSTALL: nodejs-lts@20.13.1 pnpm@9.1.2
The final step is to add tasks.json
, which usually can be auto-generated from within Visual Studio Code, or you can ask an LLM to write it for you. Here is a working example. Notice we chose typecheck
to represent the build action, but each codebase can find what would be more appropriate.
{
"version": "2.0.0",
"tasks": [
{
"type": "pnpm",
"script": "nuxi typecheck",
"group": "build"
},
{
"type": "pnpm",
"script": "nuxi test",
"group": "test"
}
]
}
By setting up two standard configuration files, namely vscode/tasks.json
and .pkgx.yaml
, each one connected to powerful tools, we unlocked several workflows. Some are very popular, but others are niche ones, which can only be supported by popular tools that can afford them. Furthermore, by bringing less than a hundred lines of glue scripts, we exposed a uniform interface to interact with diverse codebases in distinct environments.
Arguably, this could be done with a proper programming language, like typescript. The modern nodejs
doppelgangers, namely bun
and deno
, are very impressive pieces of software with composition support, great tooling, extensive libraries, negligible cold start times, and an ok-ish runtime size. Using a single programming language across frontend, backend, and DevOps is a sweet proposal as it smooths the boundaries and context switch costs. However, the boundaries often exist for a valid underlying reason, not a technology limitation. Specialized tools are not only about increasing productivity on the happy paths; they also introduce friction as you inadvertently try to break the boundaries. Striking a balance is not easy, but it is worth pursuing.
In the real world, people will write a Makefile
to cover all the developer experience journey and fail quickly as the codebase scales. Then, they will try to replace it with an all-encompassing tool, be it bazel
or a homegrown solution, and when you do this, you gain a full-time job and lose the rest of the ecosystem. We avoided both traps by sticking to idiomatic solutions of well-maintained software solutions. Finding the glue is difficult because you need to have broad and deep knowledge of the software-building process, but once it ticks, things work. There is no need to be perfect either; local escape hatches are everywhere whenever things fall short, and the concepts will survive a handful of tooling iterations as the landscape evolves.
Hire your first AI programmer
As we see English taking over as the primary programming language and regular code becoming just a state storage, it is easy to wonder if setting up a new codebase makes sense. No one can say for sure what will happen in the future. Still, as of now, predictability may be the most lacking property of LLMs, and a solid codebase with strong conventions and testing mechanisms may be the perfect playground for our new creative peers. Let us jump fearlessly in the new world by adding one more command to our portable codebase: just chat
.
Although many tools offer AI agents that can write and compile code, Aider seems to have a good set of trade-offs. You can use it with ollama, which is not great and not fast, or you can create a free Groq account to see fast and ok-ish responses from Llama70B, or you can use a commercial Openai key for a decent experience, or you can go straight to Sonnet to get the best. The trade-offs change every month, so keep an eye on their leaderboard. Let us extend say.nu
to offer the chat command.
def chat [...args] {
if ($env.GROQ_API_KEY | is-empty) and ($args | is-empty) {
log warning "GROQ_API_KEY is empty and no arguments provided"
exit 1
} else if ($args | is-empty) {
aider --model groq/llama-3.1-70b-versatile
} else {
aider ...$args
}
}
def --wrapped aider [...args: string] {
pipx aider-chat ...$args
}
Now you can type just say chat
to get Aider to work in your codebase with the groq/llama70b combo. Or if you have an Anthropic key, you can do just say chat --sonnet
. Lastly, let us wrap this up by registering the command in the .justfile
so we can drop the say
and just chat
!
[no-cd]
chat:
just say chat
Next chapters
In the next installment in this series, we will dive into the DevOps world by introducing docker as an isolation layer and setting up several caching layers to build an efficient CI pipeline. From there, we will continue into the ops/sre discipline, taming Kubernetes and performing end-to-end tests to deploy your code in production safely. Finally, we will extend the whole thing into a humble monorepo, supporting several languages and dozens of services.