Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ export interface Backend {

Nothing else in the codebase needs to change — the picker, the live preview, and the theme format are all backend-agnostic.

### Updates

`ttm` periodically checks for a new version in the background (throttled to once per 24 hours) and prints a one-line notice to let you know when an update is available. The notice includes the right command for how you installed it — `npm i -g @bumaruf/ttm-cli` if you used npm, a download link if you used the binary, or `sudo apt upgrade ttm` if you used the `.deb`. It never installs anything on its own.

To disable the check, set `TTM_NO_UPDATE_CHECK=1` in your environment.

Note: new **themes** arrive automatically via `ttm update` without needing to update `ttm` itself — the update notifier only matters when there are changes to the tool's code.

## 🔥 Contribute

**Adding a theme needs no code at all**: copy `themes/core/nord.toml` into `themes/community/`, change the colors, open a PR. That's the whole contribution — once merged, CI republishes the catalogue and it shows up in everyone's picker with no `ttm` release.
Expand Down
49 changes: 49 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export async function runCli(
}

import { dirname, join } from "node:path";
import pkg from "../package.json";
import { createAlacrittyBackend } from "./backends/alacritty";
import { createGnomeBackend, realRun } from "./backends/gnome";
import { createIterm2Backend } from "./backends/iterm2";
Expand All @@ -170,8 +171,27 @@ import { fetchCatalogue } from "./catalogue/remote";
import { loadThemes } from "./core/theme";
import { BUILTIN_THEMES } from "./generated/builtin-themes";
import { realFs } from "./platform/fs";
import {
maybeScheduleCheck,
readNotice,
runCheck,
} from "./platform/update-notifier";
import { runTui } from "./tui/loop";

/** Bun.spawn's the child detached, ignoring stdio, and never throws. */
function spawnDetached(cmd: string[]): void {
try {
const child = Bun.spawn(cmd, {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
});
child.unref();
} catch {
// Scheduling a background check must never break the actual command.
}
}

/**
* Resolve the theme catalogue, in order:
* 1. TTM_THEMES env var, if set — lets someone test a theme they're writing.
Expand All @@ -198,6 +218,14 @@ export async function resolveThemes(
if (import.meta.main) {
const argv = process.argv.slice(2);

// Hidden subcommand run by the detached background check. Never printed,
// never advertised in --help, handled before anything else touches a
// backend or the catalogue.
if (argv[0] === "__notifier-check") {
await runCheck(fetch, realFs, process.env, Date.now());
process.exit(0);
}

// `--backend <id>` can appear anywhere; strip it from argv before parsing
// the rest of the command line.
let requested: string | undefined;
Expand Down Expand Up @@ -234,6 +262,25 @@ if (import.meta.main) {
}

const backend = selection.backend;

const notifierCtx = {
runningVersion: pkg.version,
mainPath: Bun.main,
execPath: process.execPath,
platform: process.platform,
};
let updateNotice: string | null = null;
if (process.stdout.isTTY) {
await maybeScheduleCheck(
realFs,
process.env,
spawnDetached,
notifierCtx,
Date.now(),
);
updateNotice = await readNotice(realFs, process.env, notifierCtx);
}

const builtin = await resolveThemes();

if (builtin.length === 0) {
Expand Down Expand Up @@ -280,6 +327,7 @@ if (import.meta.main) {
}
console.log(`applied ${applied.name}`);
}
if (updateNotice) console.error(updateNotice);
process.exit(0);
}

Expand All @@ -290,5 +338,6 @@ if (import.meta.main) {
(s) => console.log(s),
deps,
);
if (updateNotice) console.error(updateNotice);
process.exit(code);
}
36 changes: 36 additions & 0 deletions src/core/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Parse "x.y.z" (ignoring any "-prerelease" suffix). Returns null if not x.y.z. */
function parse(
v: string,
): { nums: [number, number, number]; pre: boolean } | null {
const trimmed = v.trim();
const dashIdx = trimmed.indexOf("-");
const core = dashIdx === -1 ? trimmed : trimmed.substring(0, dashIdx);
const pre = dashIdx !== -1;

const parts = core.split(".");
if (parts.length !== 3) return null;
const segmentPattern = /^(0|[1-9]\d*)$/;
if (!parts.every((p) => segmentPattern.test(p))) return null;
const nums = parts.map((p) => Number(p));
return { nums: nums as [number, number, number], pre };
}

/** True if `candidate` is a strictly newer x.y.z release than `current`. */
export function isNewer(candidate: string, current: string): boolean {
const c = parse(candidate);
const r = parse(current);
if (c === null || r === null) return false;

const [c0, c1, c2] = c.nums;
const [r0, r1, r2] = r.nums;

if (c0 > r0) return true;
if (c0 < r0) return false;
if (c1 > r1) return true;
if (c1 < r1) return false;
if (c2 > r2) return true;
if (c2 < r2) return false;

// Same x.y.z: a final (candidate not pre) beats a running prerelease.
return !c.pre && r.pre;
}
143 changes: 143 additions & 0 deletions src/platform/update-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Env } from "../core/env";
import { isNewer } from "../core/version";
import type { Fs } from "./fs";

export const NOTICE_PACKAGE = "@bumaruf/ttm-cli";

export type Channel = "npm" | "deb" | "binary";

/**
* How was this ttm installed? The compiled binary has a virtual `$bunfs` main;
* a real .ts main means it's being run by Bun (npm/bun install). A compiled
* binary under /usr/bin on linux is the .deb; anywhere else, a release download.
*/
export function detectChannel(
mainPath: string,
execPath: string,
platform: NodeJS.Platform,
): Channel {
const compiled = mainPath.includes("$bunfs");
if (!compiled) return "npm";
if (platform === "linux" && execPath.startsWith("/usr/bin/")) return "deb";
return "binary";
}

export function updateCommand(channel: Channel): string {
switch (channel) {
case "npm":
return `npm i -g ${NOTICE_PACKAGE}`;
case "deb":
return "sudo apt update && sudo apt upgrade ttm";
case "binary":
return "download the latest from github.com/bumaruf/ttm-cli/releases/latest";
}
}

const DIM = "\x1b[2m";
const RESET = "\x1b[0m";

function cacheDir(env: Env): string {
const base = env.XDG_CACHE_HOME ?? `${env.HOME ?? ""}/.cache`;
return `${base}/ttm`;
}

export function cacheFile(env: Env): string {
return `${cacheDir(env)}/update.json`;
}

function suppressed(env: Env): boolean {
return Boolean(env.TTM_NO_UPDATE_CHECK) || Boolean(env.CI);
}

export interface NoticeContext {
runningVersion: string;
mainPath: string;
execPath: string;
platform: NodeJS.Platform;
}

export async function readNotice(
fs: Fs,
env: Env,
ctx: NoticeContext,
): Promise<string | null> {
if (suppressed(env)) return null;

const path = cacheFile(env);
let latest: string;
try {
if (!(await fs.exists(path))) return null;
latest = JSON.parse(await fs.readFile(path)).latest;
} catch {
return null;
}

if (typeof latest !== "string" || !isNewer(latest, ctx.runningVersion)) {
return null;
}

const channel = detectChannel(ctx.mainPath, ctx.execPath, ctx.platform);
return `${DIM}update available: ${ctx.runningVersion} → ${latest} · run ${updateCommand(channel)}${RESET}`;
}

export type Fetch = (url: string) => Promise<Response>;
export type Spawn = (cmd: string[]) => void;

export const REGISTRY_URL =
"https://registry.npmjs.org/@bumaruf/ttm-cli/latest";

const TTL_MS = 24 * 60 * 60 * 1000;

/** The command a detached child runs to refresh the cache. */
export function checkCommand(ctx: NoticeContext): string[] {
const compiled = ctx.mainPath.includes("$bunfs");
return compiled
? [ctx.execPath, "__notifier-check"]
: [ctx.execPath, ctx.mainPath, "__notifier-check"];
}

/** Runs in the detached child. Fetches the registry, writes the cache. Silent. */
export async function runCheck(
fetchFn: Fetch,
fs: Fs,
env: Env,
now: number,
): Promise<void> {
try {
const response = await fetchFn(REGISTRY_URL);
if (!response.ok) return;
const body = (await response.json()) as { version: unknown };
const latest = body.version;
if (typeof latest !== "string") return;
await fs.writeFile(
cacheFile(env),
`${JSON.stringify({ checkedAt: now, latest })}\n`,
);
} catch {
// Silent by design: a background check must never surface an error.
}
}

async function cacheAgeOk(fs: Fs, env: Env, now: number): Promise<boolean> {
const path = cacheFile(env);
try {
if (!(await fs.exists(path))) return false;
const checkedAt = JSON.parse(await fs.readFile(path)).checkedAt;
return typeof checkedAt === "number" && now - checkedAt < TTL_MS;
} catch {
return false;
}
}

/** Spawns the detached check if the cache is missing or older than the TTL. */
export async function maybeScheduleCheck(
fs: Fs,
env: Env,
spawn: Spawn,
ctx: NoticeContext,
now: number,
): Promise<void> {
if (suppressed(env)) return;
if (await cacheAgeOk(fs, env, now)) return;
spawn(checkCommand(ctx));
}
7 changes: 7 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ test("apply of a remote theme installs it first, and aborts if that fails", asyn
expect(text()).toMatch(/disk full/);
});

test("the hidden notifier subcommand is not advertised in help", async () => {
const { backend } = fakeBackend();
const { out, text } = capture();
await runCli(["--help"], backend, ENTRIES, out);
expect(text()).not.toContain("__notifier-check");
});

test("apply of a remote theme installs it, then applies it", async () => {
const { backend, applied } = fakeBackend();
const { out } = capture();
Expand Down
36 changes: 36 additions & 0 deletions tests/core/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { isNewer } from "../../src/core/version";

test("a higher version is newer", () => {
expect(isNewer("0.5.0", "0.4.0")).toBe(true);
expect(isNewer("1.0.0", "0.9.9")).toBe(true);
expect(isNewer("0.4.1", "0.4.0")).toBe(true);
});

test("equal or lower is not newer", () => {
expect(isNewer("0.4.0", "0.4.0")).toBe(false);
expect(isNewer("0.3.0", "0.4.0")).toBe(false);
expect(isNewer("0.4.0", "0.4.1")).toBe(false);
});

test("a prerelease is older than the final of the same number", () => {
expect(isNewer("0.5.0-rc.1", "0.5.0")).toBe(false);
// ...and the final is newer than a running prerelease of the same number
expect(isNewer("0.5.0", "0.5.0-rc.1")).toBe(true);
});

test("garbage never reports an update", () => {
expect(isNewer("nonsense", "0.4.0")).toBe(false);
expect(isNewer("0.5.0", "")).toBe(false);
expect(isNewer("", "")).toBe(false);
expect(isNewer("1.2", "1.1.9")).toBe(false); // not x.y.z
});

test("malformed segments never report an update", () => {
expect(isNewer("1..3", "1.0.2")).toBe(false); // empty segment
expect(isNewer("1.2.-3", "1.1.9")).toBe(false); // negative sign in segment
expect(isNewer("1. 2.3", "1.1.9")).toBe(false); // embedded whitespace
expect(isNewer("01.2.3", "1.1.9")).toBe(false); // leading zero
expect(isNewer("v1.2.3", "1.1.9")).toBe(false); // non-digit prefix
expect(isNewer("1.2.3.4", "1.1.9")).toBe(false); // too many segments
});
Loading