Skip to content

Commit 88b197f

Browse files
committed
check for outdated version and update
1 parent 6b74fa5 commit 88b197f

8 files changed

Lines changed: 523 additions & 14 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
"inquirer": "13.3.2",
6868
"omelette": "0.4.17",
6969
"ora": "9.3.0",
70+
"package-manager-detector": "1.6.0",
71+
"semver": "7.7.4",
72+
"update-notifier": "7.3.1",
7073
"zod": "4.3.6",
7174
"zod-opts": "1.0.0"
7275
},
@@ -79,6 +82,8 @@
7982
"@types/jest": "30.0.0",
8083
"@types/node": "25.5.0",
8184
"@types/omelette": "0.4.5",
85+
"@types/semver": "7.7.1",
86+
"@types/update-notifier": "6.0.8",
8287
"@typescript-eslint/eslint-plugin": "8.57.2",
8388
"@typescript-eslint/parser": "8.57.2",
8489
"eslint": "10.1.0",

pnpm-lock.yaml

Lines changed: 314 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/completion.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ const SPECIAL_CATEGORIES: Record<string, readonly string[]> = {
1717
completion: COMPLETION_SUBCOMMANDS,
1818
};
1919

20+
/** Standalone commands with no subcommands */
21+
const STANDALONE_COMMANDS = ["update"] as const;
22+
2023
const topLevelCompletions = [
2124
...getCategories(),
2225
...Object.keys(SPECIAL_CATEGORIES),
26+
...STANDALONE_COMMANDS,
2327
];
2428

2529
const globalFlagCompletions = FLAG_DEFS.flatMap((d) => d.aliases);

src/index.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import {
1717
showGlobalHelp,
1818
showVersion,
1919
} from "./router.js";
20-
import { COMMAND_NAME } from "./utils/command-info.js";
20+
import { checkForUpdate, handleUpdateCommand } from "./update.js";
21+
import { COMMAND_NAME, IS_NPX, PACKAGE_NAME } from "./utils/command-info.js";
2122

22-
completion.init();
23+
if (!IS_NPX) completion.init();
2324

2425
const CLI_PAGINATION_DEFAULTS: Record<string, number> = {
2526
page: 1,
@@ -29,11 +30,30 @@ const CLI_PAGINATION_DEFAULTS: Record<string, number> = {
2930
async function main(): Promise<void> {
3031
const parsed = parseArgs(process.argv.slice(2));
3132

33+
// Fire-and-forget update check (reads from cache, defers notification to exit).
34+
// Suppress for "update" (stale after upgrading) and "completion" (clutters
35+
// the shell-init output from omelette's process.exit()).
36+
if (parsed.category !== "update" && parsed.category !== "completion") {
37+
checkForUpdate();
38+
}
39+
3240
if (parsed.globalFlags.version) {
3341
showVersion();
3442
return;
3543
}
3644

45+
if (parsed.category === "update") {
46+
if (parsed.globalFlags.help) {
47+
console.log(
48+
// eslint-disable-line no-console
49+
`\nUsage: ${COMMAND_NAME} update\n\nUpgrade ${PACKAGE_NAME} to the latest version.\n`
50+
);
51+
return;
52+
}
53+
await handleUpdateCommand();
54+
return;
55+
}
56+
3757
if (!parsed.category || (parsed.globalFlags.help && !parsed.action)) {
3858
if (parsed.category) {
3959
showCategoryHelp(parsed.category);
@@ -44,6 +64,15 @@ async function main(): Promise<void> {
4464
}
4565

4666
if (parsed.category === "completion") {
67+
if (IS_NPX) {
68+
// eslint-disable-next-line no-console
69+
console.error(
70+
"Shell completion requires a global install.\n" +
71+
`Run: npm install -g ${PACKAGE_NAME}`
72+
);
73+
return;
74+
}
75+
4776
/* eslint-disable no-console */
4877
// omelette exposes getDefaultShellInitFile() but @types/omelette omits it
4978
let initFile: string | undefined;
@@ -57,7 +86,7 @@ async function main(): Promise<void> {
5786

5887
let successMsg: string | undefined;
5988

60-
// Both setupShellInitFile and cleanupShellInitFile call process.exit(),
89+
// Both setupShellInitFile and cleanupShellInitFile call process.exit()
6190
process.on("exit", () => {
6291
if (!successMsg) return;
6392
console.log(successMsg);

src/router.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@ export async function showGlobalHelp(keyOverride?: string): Promise<void> {
359359
` ${theme.accent(`${COMMAND_NAME} completion uninstall`.padEnd(45))} ${theme.muted("Remove shell completion")}`
360360
);
361361

362+
lines.push("", theme.bold("UPDATE"));
363+
lines.push(
364+
` ${theme.accent(`${COMMAND_NAME} update`.padEnd(45))} ${theme.muted("Check for and install the latest version")}`
365+
);
366+
362367
lines.push("");
363368

364369
console.log(lines.join("\n"));

src/update.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
4+
import boxen from "boxen";
5+
import chalk from "chalk";
6+
import semverGt from "semver/functions/gt.js";
7+
// @types/update-notifier@6 targets v6 but the API surface we use is unchanged
8+
// in v7. No v7-aligned types exist on DefinitelyTyped as of 2026-03.
9+
import updateNotifier from "update-notifier";
10+
11+
import { CliError } from "./errors.js";
12+
import { getSpinner } from "./utils/cli-env.js";
13+
import {
14+
BIN_NAME,
15+
COMMAND_NAME,
16+
IS_NPX,
17+
PACKAGE_NAME,
18+
PACKAGE_VERSION,
19+
} from "./utils/command-info.js";
20+
21+
const execFileAsync = promisify(execFile);
22+
23+
const ONE_DAY_MS = 86_400_000;
24+
25+
let updateCheckDone = false;
26+
27+
/**
28+
* Check for a newer version in the background and register an on-exit
29+
* notification to stderr. Fully fire-and-forget: errors are silently ignored
30+
* so normal CLI operation is never affected.
31+
*
32+
* The constructor creates a configstore and the background check process.
33+
* `check()` reads the cached result into `notifier.update` and, if the check
34+
* interval has elapsed, spawns a new background process for next time.
35+
*
36+
* We write to stderr (not stdout) so piped output stays clean, and we check
37+
* `process.stderr.isTTY` rather than stdout because notifications should
38+
* still appear when stdout is piped (e.g. `iterable users list | jq .`).
39+
*
40+
* CI, NO_UPDATE_NOTIFIER, and NODE_ENV=test suppression are handled
41+
* internally by update-notifier (notifier.config will be undefined).
42+
*/
43+
export function checkForUpdate(): void {
44+
if (updateCheckDone) return;
45+
updateCheckDone = true;
46+
47+
try {
48+
if (IS_NPX) return;
49+
if (!process.stderr.isTTY) return;
50+
51+
const notifier = updateNotifier({
52+
pkg: { name: PACKAGE_NAME, version: PACKAGE_VERSION },
53+
updateCheckInterval: ONE_DAY_MS,
54+
});
55+
56+
notifier.check();
57+
58+
if (!notifier.update) return;
59+
if (!semverGt(notifier.update.latest, PACKAGE_VERSION)) return;
60+
61+
const message =
62+
`Update available: ${chalk.dim(notifier.update.current)} ${chalk.reset("→")} ${chalk.green(notifier.update.latest)}\n` +
63+
`Run ${chalk.cyan(`${COMMAND_NAME} update`)} to update`;
64+
65+
const box = boxen(message, {
66+
padding: 1,
67+
margin: { top: 1, bottom: 0 },
68+
borderStyle: "round",
69+
borderColor: "yellow",
70+
textAlignment: "center",
71+
});
72+
73+
process.on("exit", () => {
74+
try {
75+
process.stderr.write(`${box}\n`);
76+
} catch {
77+
// Best-effort; swallow write errors at exit (e.g. EPIPE)
78+
}
79+
});
80+
} catch {
81+
// Never let the update check interfere with normal operation
82+
}
83+
}
84+
85+
/**
86+
* Self-upgrade: detect the package manager and run a global install of the
87+
* latest version.
88+
*/
89+
export async function handleUpdateCommand(): Promise<void> {
90+
if (IS_NPX) {
91+
// eslint-disable-next-line no-console
92+
console.error(
93+
chalk.yellow(
94+
"You're running via npx, which always fetches the latest version.\n" +
95+
`Run ${chalk.cyan(`npm install -g ${PACKAGE_NAME}`)} to install permanently.`
96+
)
97+
);
98+
return;
99+
}
100+
101+
const { getUserAgent } = await import("package-manager-detector/detect");
102+
const { resolveCommand } = await import("package-manager-detector/commands");
103+
104+
// npm_config_user_agent is only set when invoked through a package manager.
105+
// When the user runs the globally-installed binary directly, this falls back
106+
// to npm. If they installed via pnpm/yarn, the upgrade may go to npm's
107+
// global prefix instead — the spinner shows the exact command being run so
108+
// the user can verify.
109+
const agent = getUserAgent() ?? "npm";
110+
111+
const resolved = resolveCommand(agent, "global", [`${PACKAGE_NAME}@latest`]);
112+
if (!resolved) {
113+
throw new CliError(
114+
`Could not determine install command for package manager "${agent}".`
115+
);
116+
}
117+
118+
const spinner = await getSpinner();
119+
const cmdStr = `${resolved.command} ${resolved.args.join(" ")}`;
120+
spinner.start(`Upgrading ${PACKAGE_NAME} (${cmdStr})...`);
121+
122+
try {
123+
// shell: true is required on Windows where npm/pnpm/yarn are .cmd shims
124+
await execFileAsync(resolved.command, resolved.args, { shell: true });
125+
126+
let versionLine = "Upgrade complete";
127+
try {
128+
const { stdout } = await execFileAsync(BIN_NAME, ["--version"], {
129+
shell: true,
130+
});
131+
versionLine = `Upgraded to ${stdout.trim()}`;
132+
} catch {
133+
// Binary may not be on PATH yet; that's fine
134+
}
135+
spinner.succeed(versionLine);
136+
} catch (error: unknown) {
137+
spinner.fail("Upgrade failed");
138+
139+
if (
140+
error instanceof Error &&
141+
"code" in error &&
142+
(error as NodeJS.ErrnoException).code === "EACCES"
143+
) {
144+
throw new CliError(
145+
"Permission denied. Try running with sudo or fix your global npm prefix permissions."
146+
);
147+
}
148+
throw new CliError(error instanceof Error ? error.message : String(error));
149+
}
150+
}

src/utils/command-info.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,21 @@ function loadPackageJson(): PackageJson {
2323

2424
const pkg = loadPackageJson();
2525

26-
const binName = pkg.bin ? Object.keys(pkg.bin)[0] : undefined;
26+
/** The CLI binary name from the `bin` field in package.json */
27+
export const BIN_NAME: string = pkg.bin
28+
? (Object.keys(pkg.bin)[0] ?? pkg.name)
29+
: pkg.name;
2730

28-
const isNpx =
29-
(process.argv[1]?.includes("npx") ||
30-
process.env.npm_execpath?.includes("npx")) ??
31-
false;
31+
/** True when running via npx (ephemeral execution, no persistent install) */
32+
export const IS_NPX: boolean = Boolean(
33+
process.argv[1]?.includes("npx") || process.env.npm_execpath?.includes("npx")
34+
);
3235

3336
/** The command name as the user would invoke it */
34-
export const COMMAND_NAME: string = isNpx
35-
? `npx ${pkg.name}`
36-
: (binName ?? pkg.name);
37+
export const COMMAND_NAME: string = IS_NPX ? `npx ${pkg.name}` : BIN_NAME;
38+
39+
/** npm package name from package.json */
40+
export const PACKAGE_NAME: string = pkg.name;
3741

3842
/** Package version from package.json */
3943
export const PACKAGE_VERSION: string = pkg.version;

src/utils/ui.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function showSuccess(message: string): void {
4343
}
4444

4545
export function showError(message: string): void {
46-
console.log(chalk.hex(THEME.error)("✖ " + message));
46+
console.error(chalk.hex(THEME.error)("✖ " + message));
4747
}
4848

4949
export function showInfo(message: string): void {

0 commit comments

Comments
 (0)