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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ notes what is missing, and stderr prefixes every per-member line with
`[k/n] <title>`. If any member did not complete, veta still prints the prompt
path first, then exits non-zero.

## Update notifications

When a newer release is on npm, veta prints a small box on stderr after the
command finishes — current version, new version, a changelog link, and the
update command for the package manager that installed it. The registry is
asked at most once every 24 hours; the answer is cached in
`~/.veta/update-check.json` (under `VETA_DATA_DIR` if set). The check only
runs in an interactive terminal and never affects the exit code.

Opt out with `NO_UPDATE_NOTIFIER=1` (or `VETA_NO_UPDATE_CHECK=1`); it is also
skipped automatically when `CI` is set.

## Requirements

| Need | Why |
Expand Down
8 changes: 8 additions & 0 deletions src/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ describe("list command", () => {
expect(result.status).toBe(0);
expect(result.stdout).toContain("my-video");
});

it("does not check for updates when stderr is not a TTY", () => {
const result = runCli(["list"], { VETA_DATA_DIR: dataDir });

expect(result.status).toBe(0);
expect(result.stderr).not.toContain("Update available");
expect(existsSync(path.join(dataDir, "update-check.json"))).toBe(false);
});
});

describe("yargs completion short-circuit (D17)", () => {
Expand Down
103 changes: 94 additions & 9 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ import { extractPlaylist } from "./extract-playlist.ts";
import { list } from "./list.ts";
import { purge } from "./purge.ts";
import { createProgressRenderer } from "./render/progress-renderer.ts";
import { readCurrentVersion } from "./update/current-version.ts";
import {
currentBinPath,
detectInstaller,
updateCommandFor,
} from "./update/package-manager.ts";
import { changelogUrlFor, renderUpdateNotice } from "./update/render-notice.ts";
import {
fetchLatestFromNpm,
fileUpdateCache,
shouldCheckForUpdates,
startUpdateCheck,
} from "./update/update-check.ts";

/**
* Where packages live: `~/.veta` unless `VETA_DATA_DIR` overrides it.
Expand Down Expand Up @@ -70,7 +83,8 @@ async function runExtract(url: string, preferredLang?: string, force?: boolean):
// NO_COLOR convention (https://no-color.org): presence alone, any value,
// turns color off.
const renderer = createProgressRenderer(process.stderr, {
useColor: process.stderr.isTTY === true && process.env["NO_COLOR"] === undefined,
useColor:
process.stderr.isTTY === true && process.env["NO_COLOR"] === undefined,
});

try {
Expand Down Expand Up @@ -132,7 +146,9 @@ async function offerPromptCopy(promptPath: string | null): Promise<void> {
process.stderr.write("Copied to clipboard.\n");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
process.stderr.write(`Warning: ${reason} The prompt is still at ${promptPath}.\n`);
process.stderr.write(
`Warning: ${reason} The prompt is still at ${promptPath}.\n`,
);
}
}

Expand Down Expand Up @@ -193,7 +209,10 @@ function buildProgram(argv: readonly string[]) {
if (error !== undefined) {
throw error;
}
throw new VetaError("INPUT_UNRECOGNIZED", msg || "Unrecognized command or option.");
throw new VetaError(
"INPUT_UNRECOGNIZED",
msg || "Unrecognized command or option.",
);
});
}

Expand All @@ -219,20 +238,86 @@ export async function run(argv: readonly string[]): Promise<number> {
}
}

type UpdateCheck = { result(): Promise<string | null> };

/**
* Start the "newer version?" lookup so it overlaps the command. Anything that
* goes wrong here is swallowed: the notice is a courtesy, never a failure.
*/
function beginUpdateCheck(): UpdateCheck | null {
if (
!shouldCheckForUpdates(
process.env,
process.stderr.isTTY === true,
process.argv,
)
) {
return null;
}
// Reading package.json is one file read; the registry request leaves right
// after it and runs alongside the command itself.
const pending = readCurrentVersion()
.then((currentVersion) =>
startUpdateCheck({
currentVersion,
fetchLatest: fetchLatestFromNpm,
cache: fileUpdateCache(
path.join(dataDirFromEnv(), "update-check.json"),
),
now: Date.now,
}).result(),
)
.catch(() => null);

return { result: () => pending };
}

/** Print the update box on stderr if a newer version was found. Never throws. */
async function printUpdateNotice(check: UpdateCheck | null): Promise<void> {
if (check === null) {
return;
}
try {
const latest = await check.result();
if (latest === null) {
return;
}
const current = await readCurrentVersion();
const installer = detectInstaller(await currentBinPath(), process.env);
process.stderr.write(
renderUpdateNotice({
current,
latest,
changelogUrl: changelogUrlFor(latest),
updateCommand: updateCommandFor(installer),
useColor:
process.stderr.isTTY === true &&
process.env["NO_COLOR"] === undefined,
}),
);
} catch {
// A failed notice must never change the outcome of the command.
}
}

/** Composition root for normal CLI execution (not completion short-circuit). */
export function main(): void {
const check = beginUpdateCheck();

run(process.argv).then(
(code) => {
async (code) => {
await printUpdateNotice(check);
process.exit(code);
},
(error: unknown) => {
async (error: unknown) => {
if (isVetaError(error)) {
process.stderr.write(`${error.message}\n`);
process.exit(exitCodeFor(error));
return;
} else {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
}

process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
await printUpdateNotice(check);
process.exit(exitCodeFor(error));
},
);
Expand Down
13 changes: 13 additions & 0 deletions src/cli/update/current-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { readCurrentVersion } from "./current-version.ts";

describe("readCurrentVersion()", () => {
it("matches the version in package.json", async () => {
const pkg = JSON.parse(
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
) as { version: string };

await expect(readCurrentVersion()).resolves.toBe(pkg.version);
});
});
21 changes: 21 additions & 0 deletions src/cli/update/current-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { readFile } from "node:fs/promises";
import { isRecord } from "../../domain/json.ts";

/**
* The running veta's version, from the package manifest.
*
* `src/cli/update/` and `dist/cli/update/` sit at the same depth, so one
* relative URL serves both the source tree and the published build.
*/
const PACKAGE_JSON = new URL("../../../package.json", import.meta.url);

export async function readCurrentVersion(): Promise<string> {
try {
const manifest: unknown = JSON.parse(await readFile(PACKAGE_JSON, "utf8"));
return isRecord(manifest) && typeof manifest["version"] === "string"
? manifest["version"]
: "0.0.0";
} catch {
return "0.0.0";
}
}
66 changes: 66 additions & 0 deletions src/cli/update/package-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { detectInstaller, updateCommandFor } from "./package-manager.ts";

describe("detectInstaller()", () => {
it("defaults to npm for an ordinary global bin path", () => {
expect(detectInstaller("/usr/local/lib/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe("npm");
});

it("detects npx from an _npx segment in the path", () => {
expect(detectInstaller("/Users/me/.npm/_npx/abc123/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe(
"npx",
);
});

it("detects npx from the npm user agent", () => {
expect(
detectInstaller("/some/where/bin/cli.js", { npm_config_user_agent: "npm/10.0.0 npx/10.0.0 node/v24" }),
).toBe("npx");
});

it("prefers npx over pnpm when both hints are present", () => {
expect(detectInstaller("/Users/me/.pnpm/_npx/abc/bin/cli.js")).toBe("npx");
});

it("detects pnpm from /pnpm/, /.pnpm/ and /Library/pnpm/ segments", () => {
expect(detectInstaller("/Users/me/Library/pnpm/global/5/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe("pnpm");
expect(detectInstaller("/home/me/.local/share/pnpm/global/5/.pnpm/@cmglezpdev+veta/bin/cli.js")).toBe("pnpm");
expect(detectInstaller("/opt/pnpm/store/bin/cli.js")).toBe("pnpm");
});

it("detects pnpm from the user agent", () => {
expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "pnpm/11.0.0 npm/? node/v24" })).toBe("pnpm");
});

it("detects yarn from path or user agent", () => {
expect(detectInstaller("/Users/me/.yarn/bin/veta")).toBe("yarn");
expect(detectInstaller("/usr/local/share/yarn/global/node_modules/x/bin/cli.js")).toBe("yarn");
expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "yarn/1.22.0 npm/? node/v24" })).toBe("yarn");
});

it("detects bun from path or user agent", () => {
expect(detectInstaller("/Users/me/.bun/install/global/node_modules/x/bin/cli.js")).toBe("bun");
expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "bun/1.1.0" })).toBe("bun");
});

it("detects homebrew from Cellar, homebrew and linuxbrew segments", () => {
expect(detectInstaller("/opt/homebrew/Cellar/veta/0.10.0/libexec/bin/cli.js")).toBe("brew");
expect(detectInstaller("/opt/homebrew/lib/node_modules/x/bin/cli.js")).toBe("brew");
expect(detectInstaller("/home/linuxbrew/.linuxbrew/lib/node_modules/x/bin/cli.js")).toBe("brew");
});

it("matches case-insensitively and accepts backslashes", () => {
expect(detectInstaller("C:\\Users\\me\\AppData\\Local\\PNPM\\global\\5\\bin\\cli.js")).toBe("pnpm");
});
});

describe("updateCommandFor()", () => {
it("maps every installer to its update command", () => {
expect(updateCommandFor("npm")).toBe("npm install -g @cmglezpdev/veta@latest");
expect(updateCommandFor("pnpm")).toBe("pnpm add -g @cmglezpdev/veta@latest");
expect(updateCommandFor("yarn")).toBe("yarn global add @cmglezpdev/veta@latest");
expect(updateCommandFor("bun")).toBe("bun add -g @cmglezpdev/veta@latest");
expect(updateCommandFor("npx")).toBe("npx @cmglezpdev/veta@latest");
expect(updateCommandFor("brew")).toBe("brew upgrade veta");
});
});
70 changes: 70 additions & 0 deletions src/cli/update/package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { realpath } from "node:fs/promises";

/**
* Which tool put veta on this machine, guessed from where the binary lives.
*
* The update notice tells the user what to run, and telling a pnpm user to
* `npm install -g` would leave them with two copies and the wrong one on
* PATH. The guess is a heuristic over the resolved bin path plus npm's user
* agent; it is allowed to be wrong, so npm stays the default.
*/
export type Installer = "npm" | "pnpm" | "yarn" | "bun" | "npx" | "brew";

const PACKAGE = "@cmglezpdev/veta@latest";

const UPDATE_COMMANDS: Record<Installer, string> = {
npm: `npm install -g ${PACKAGE}`,
pnpm: `pnpm add -g ${PACKAGE}`,
yarn: `yarn global add ${PACKAGE}`,
bun: `bun add -g ${PACKAGE}`,
npx: `npx ${PACKAGE}`,
brew: "brew upgrade veta",
};

export function updateCommandFor(installer: Installer): string {
return UPDATE_COMMANDS[installer];
}

/**
* Detect the installer from the (realpath-resolved) bin path and environment.
*
* Checks run in priority order because paths overlap: an `npx` cache can sit
* inside pnpm's store, and homebrew's `lib/node_modules` contains no
* package-manager name at all.
*/
export function detectInstaller(binPath: string, env: NodeJS.ProcessEnv = {}): Installer {
const p = binPath.replaceAll("\\", "/").toLowerCase();
const agent = (env["npm_config_user_agent"] ?? "").toLowerCase();

if (p.includes("/_npx/") || agent.includes("npx")) {
return "npx";
}
if (
p.includes("/pnpm/") ||
p.includes("/.pnpm/") ||
p.includes("/library/pnpm/") ||
agent.startsWith("pnpm")
) {
return "pnpm";
}
if (p.includes("/.yarn/") || p.includes("/yarn/") || agent.startsWith("yarn")) {
return "yarn";
}
if (p.includes("/.bun/") || agent.startsWith("bun")) {
return "bun";
}
if (p.includes("/cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/")) {
return "brew";
}
return "npm";
}

/** The running binary's real location — symlinks resolved so `/pnpm/` and friends show through. */
export async function currentBinPath(): Promise<string> {
const raw = process.argv[1] ?? "";
try {
return await realpath(raw);
} catch {
return raw;
}
}
Loading