Blog

Cliffy 1.3.0: lazy sub commands, @cliffy/upgrade, and env options

c4spar7 min read

We're happy to announce the release of Cliffy 1.3.0. Sub commands can be loaded on demand, the upgrade API moved into its own package with GitLab and URL providers, and an option can declare the environment variable it reads from.

A CLI command tree loading one selected subcommand module while two sibling modules remain dormant.
    deno add jsr:@cliffy/command@1.3.0
  

Command

Lazy loading sub commands

A CLI that registers fifty sub commands pays for all fifty on every run, even when the user asked for one. Pass a function returning a dynamic import instead of a command instance, and the module is only evaluated when that sub command is actually invoked:

import { Command } from "@cliffy/command";

export const cli = new Command()
  .name("example")
  .description("Example command with lazy loading.")
  .command("foo", () => import("./foo_command.ts"))
  .command("bar", () => import("./bar_command.ts"));

if (import.meta.main) {
  await cli.parse();
}

Running example foo loads foo and nothing else. --help is the exception: it loads every sub command first, because their descriptions live in the modules it would otherwise skip, so it costs marginally more than declaring them eagerly.

The function can also return a command instead of a module, which defers building it without moving it into another file:

import { Command } from "@cliffy/command";

await new Command()
  .name("example")
  .command("foo", () => new Command().description("Foo.").action(() => {}))
  .parse();

Both forms save the same thing: work that never happens. Cliffy calls the function only for the sub command you invoked, so the commands on every other branch are never built. How much that saves grows with the size of the tree and with how much each command declares (options, arguments, environment variables). A dynamic import skips all of that plus the module itself, which is what pays off when one command pulls in dependencies the rest never touch.

Many sub commands next to each other are cheap, because they load together. A lazy sub command nested inside another one is not: it can only load after its parent, so with dynamic imports a command three levels down imports one level at a time and the waits stack up. For a small tree, or commands that are cheap to build, declaring them eagerly is still the simpler choice.

The env option

Linking an option to an environment variable meant declaring both and keeping the two in sync. The option method now takes an env option that declares the variable with the option, reading into the same property with the precedence flag > env var > default:

import { Command } from "@cliffy/command";

await new Command()
  .option("--install-root <path:string>", "Install location.", {
    env: { prefix: "DENO_" },
  })
  .parse();

env: true derives the name from the option, so --install-root reads INSTALL_ROOT. A string sets it outright, { prefix } prepends to the derived name as above, and { type } reads the variable as a different type than the option itself.

A required option supplied only through the environment no longer throws as missing.

Negatable environment variables

NO_COLOR=true used to arrive as { noColor: true }, which left every CLI to invert it by hand. The negatable option on an environment variable strips the NO_ prefix and inverts the value, so it arrives as { color: false }, the same way --no-color behaves:

import { Command } from "@cliffy/command";

await new Command()
  .env("NO_COLOR=<value:boolean>", "Disable colors.", { negatable: true })
  .parse();

It is opt-in for backwards compatibility. In 2.0 this becomes the default for boolean NO_* variables and the option goes away.

The presence type

A new presence type treats a variable as a boolean based on whether it is set at all, rather than on its value. Any non-empty value resolves to true, and an unset or empty one adds nothing, which is the convention NO_COLOR follows:

import { Command } from "@cliffy/command";

await new Command()
  .env("NO_CACHE=<value:presence>", "Disable the cache.", { negatable: true })
  .parse();

The enabled option

enabled decides whether an option is registered at all, so you can put a flag behind a feature switch or a platform check without building two different commands:

import { Command } from "@cliffy/command";

await new Command()
  .option("--experimental", "Enable the experimental path.", {
    enabled: isFeatureEnabled("experimental"),
  })
  .parse();

The type follows. A literal false drops the key from the parsed options entirely, while a runtime boolean like the one above widens the value to boolean | undefined, since the option may never have been registered.

Empty values where a value is required

An empty string is no longer accepted where a value is required. It is reported the same way a missing one is:

$ greet --name "" file.txt
error: Missing value for option "--name".

$ greet --name x ""
error: Missing argument: file

Optional options and arguments are unchanged: an empty value is still skipped, so the default applies. Required ones were the inconsistency. An empty string passed the check that a value exists, so a command could run with nothing where it had declared that it needs something. To clear a value from the command line, declare a negatable option.

Color in the help output

The help output used to colorize regardless of where it was going. It now follows the global color state, so setColorEnabled(false) turns it off, and NO_COLOR switches it off whatever else is configured, even against an explicit colors: true.

On top of true and false, the colors help option accepts a new "auto" value. It adds a check that standard output is a terminal, so a piped or redirected help stays free of escape codes:

import { Command } from "@cliffy/command";

await new Command()
  .name("deploy")
  .help({ colors: "auto" })
  .parse();

"auto" is opt-in here and becomes the default in 2.0.

Upgrade

The @cliffy/upgrade package

The upgrade API moved out of @cliffy/command into its own @cliffy/upgrade package. The providers re-exported from @cliffy/command are deprecated, so import them from the new package instead:

// before
import { JsrProvider } from "@cliffy/command/upgrade/provider/jsr";

// after
import { JsrProvider } from "@cliffy/upgrade/provider/jsr";

Standalone binary self-upgrade

Until now upgrade reinstalled the CLI through a package manager, which a compiled binary has no way to do. It can now replace the running executable with a release asset instead, so a standalone build can update itself on a machine with no runtime installed.

Being standalone is detected, not declared. Binaries built by deno compile, by bun build --compile, or as Node single executable applications are all recognised, so the same upgrade command does the right thing whether it runs from a script or from a binary.

What you do have to declare is the asset to download, because that is what names the file for each build target:

import { Command } from "@cliffy/command";
import { UpgradeCommand } from "@cliffy/command/upgrade";
import { GithubProvider } from "@cliffy/upgrade/provider/github";

await new Command()
  .command(
    "upgrade",
    new UpgradeCommand({
      provider: new GithubProvider({
        repository: "my-user/my-cli",
        asset: ({ os, arch }) => `my-cli-${os}-${arch}`,
      }),
    }),
  )
  .parse();

The resolver receives the CLI name, the resolved version, and a normalised os and arch, so darwin and x86_64 rather than each runtime's own spelling. An os-arch to filename map works in place of the function.

Only the GitHub, GitLab and URL providers can do this, and only once an asset is configured. Without one the upgrade reports that the registry does not support upgrading a standalone executable. standalone forces the choice when detection is not what you want, and location installs somewhere other than over the running executable.

The GitLab provider

CLIs released from a GitLab project can now upgrade from it, the same way the GitHub provider works: tags and branches for a script install, a release asset for a standalone binary. host points the provider at a self-hosted instance, and a token for a private project is read from the token option or the GITLAB_TOKEN environment variable:

import { GitlabProvider } from "@cliffy/upgrade/provider/gitlab";

const provider = new GitlabProvider({
  repository: "my-group/my-cli",
  host: "https://gitlab.example.com",
});

Apart from the generated source archives, a GitLab release holds no files of its own, only links to them. The provider looks the asset up by name among those links, so a build has to be linked on the release under the name that asset resolves to. Where the file itself lives is up to you, the project's generic package registry included.

The URL provider

The URL provider upgrades from anywhere you can serve a file, with the URLs and the version list under your control rather than a registry's. asset points at a binary, and url at a script entrypoint on Deno:

import { UrlProvider } from "@cliffy/upgrade/provider/url";

const provider = new UrlProvider({
  asset: "https://example.com/my-cli",
  async versions() {
    const res = await fetch("https://example.com/my-cli/versions.json");
    return res.json();
  },
});

versions is what resolves the latest target, so without it an explicit version has to be requested, and it takes either a list or a function returning one. asset and url each take a string or a resolver, which is how you build a URL per build target.

Quality of life improvements

  • Short-only options are named after their first short flag.
  • Default values of arguments are shown in the help output.
  • Help text values are formatted identically on every runtime.
  • A standalone option no longer requires the command's arguments, so --help and --version work on a command that declares required ones.
  • Upgrade errors the user can act on, an unknown version, a missing release asset, a provider that cannot do the requested kind of upgrade, are reported as validation errors instead of surfacing as internal ones.
  • Every surplus argument is reported, not just the first.
  • Expected arguments are matched and typed when parsing stops early.
  • The GitHub provider returns plain branch names, instead of appending the "(Protected)" marker to the value it reports as a version.
  • getOs normalises win32 to windows.
  • Bun no longer hangs after reading from stdin, and a top-level await was removed from readSync.
  • A trailing ESC [ is no longer parsed as shift and left.

Fixed in 1.2.1

Shipped in the patch since 1.2.0: conflicting options only trigger when the conflicting option was explicitly provided, so a default value no longer counts as a conflict.

Acknowledgements

This is the first release announced here rather than only on GitHub. Introducing the Cliffy blog covers what else lives on the blog.

Two of the parser fixes in this release came from #901 and #902 by Ian Hickson, which taught the flags parser to match and type the expected arguments when parsing stops early, and to report every surplus argument rather than only the first. The keycode fix for a trailing ESC [ came from #908 by Rajan Pantha. Thank you both.

Thank you also to everyone who filed the bug reports and questions that shaped this release.

Contributions are welcome. The contributing guide covers how to get started.