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
109 changes: 109 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Command } from "commander";
import { spawn } from "node:child_process";
import { getCliVersion } from "~/lib/version.js";
import { printError, printSuccess } from "~/lib/output.js";
import { CliError } from "~/lib/errors.js";

const PACKAGE_NAME = "bluebubbles-cli";
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;

type LatestPackageInfo = {
version: string;
homepage?: string;
};

async function fetchLatestVersion(): Promise<LatestPackageInfo> {
const response = await fetch(REGISTRY_URL, { headers: { accept: "application/json" } });
if (!response.ok) {
throw new Error(`npm registry returned ${response.status} for ${REGISTRY_URL}`);
}
const body = (await response.json()) as LatestPackageInfo;
if (!body.version) {
throw new Error(`npm registry response missing 'version' for ${PACKAGE_NAME}`);
}
return body;
}

function compareSemver(a: string, b: string): number {
const parse = (s: string) => s.split(".").map((n) => Number.parseInt(n, 10) || 0);
const [aMaj, aMin, aPatch] = parse(a);
const [bMaj, bMin, bPatch] = parse(b);
if (aMaj !== bMaj) return (aMaj ?? 0) - (bMaj ?? 0);
if (aMin !== bMin) return (aMin ?? 0) - (bMin ?? 0);
return (aPatch ?? 0) - (bPatch ?? 0);
Comment on lines +28 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use real semver precedence for prerelease versions

The version comparator strips each dot-separated token with parseInt, which drops prerelease semantics (for example, 1.2.3-beta.1 compares equal to 1.2.3). That causes --check and update decisions to misreport prerelease installs as up to date when a stable release with the same core version exists.

Useful? React with 👍 / 👎.

}

function runInstall(packageManager: "npm" | "bun" | "pnpm" | "yarn"): Promise<number> {
const command =
packageManager === "bun"
? ["bun", "add", "-g", `${PACKAGE_NAME}@latest`]
: packageManager === "pnpm"
? ["pnpm", "add", "-g", `${PACKAGE_NAME}@latest`]
: packageManager === "yarn"
? ["yarn", "global", "add", `${PACKAGE_NAME}@latest`]
: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`];
return new Promise((resolve) => {
const child = spawn(command[0]!, command.slice(1), { stdio: "inherit" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use shell when spawning package managers on Windows

runInstall calls spawn() with default shell: false, but on Windows the npm/pnpm/yarn entrypoints are .cmd wrappers; Node's child_process docs for “Spawning .bat and .cmd files on Windows” state these cannot be launched directly without a shell. In that environment this path will emit an error and resolve to failure, so bluebubbles update will consistently fail for Windows users even when the package manager is installed.

Useful? React with 👍 / 👎.

child.on("exit", (code) => resolve(code ?? 0));
child.on("error", () => resolve(1));
});
}

export function registerUpdateCommands(program: Command): void {
program
.command("update")
.description(`Update ${PACKAGE_NAME} to the latest version on npm`)
.option("--check", "Only check for an available update; do not install")
.option(
"--package-manager <pm>",
"Package manager to use: npm | bun | pnpm | yarn (default: npm)",
"npm",
)
.action(async (options: { check?: boolean; packageManager?: string }) => {
const current = getCliVersion();
let latest: LatestPackageInfo;
try {
latest = await fetchLatestVersion();
} catch (error) {
printError(new CliError(error instanceof Error ? error.message : "Failed to query npm registry", "network"));
process.exitCode = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve typed exit codes for update command failures

This handler constructs typed CliErrors (network, validation) but then hard-codes process.exitCode = 1, which bypasses the exit-code mapping defined in lib/errors.ts. Scripts that distinguish retryable network failures from user validation errors will get the wrong status from bluebubbles update; propagate CliError.exitCode (or throw the error) instead of forcing 1.

Useful? React with 👍 / 👎.

return;
}

const cmp = compareSemver(current, latest.version);
if (cmp >= 0) {
printSuccess(
`${PACKAGE_NAME} is up to date (installed ${current}, latest ${latest.version}).`,
false,
);
return;
}

if (options.check) {
printSuccess(
`${PACKAGE_NAME} update available: ${current} → ${latest.version}. Run 'bluebubbles update' to install.`,
false,
);
return;
}

const pm = (options.packageManager ?? "npm").toLowerCase();
if (!["npm", "bun", "pnpm", "yarn"].includes(pm)) {
printError(new CliError(`Unknown --package-manager '${pm}'. Use npm | bun | pnpm | yarn.`, "validation"));
process.exitCode = 1;
return;
}

printSuccess(
`Updating ${PACKAGE_NAME} ${current} → ${latest.version} via ${pm}...`,
false,
);
const exitCode = await runInstall(pm as "npm" | "bun" | "pnpm" | "yarn");
if (exitCode !== 0) {
printError(new CliError(`Update failed (exit ${exitCode}). Try a different --package-manager.`, "general"));
process.exitCode = exitCode;
return;
}
printSuccess(`Updated ${PACKAGE_NAME} to ${latest.version}.`, false);
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { registerContactCommands } from "~/commands/contacts.js";
import { registerPingCommands } from "~/commands/ping.js";
import { registerDoctorCommands } from "~/commands/doctor.js";
import { registerWebhookCommands } from "~/commands/webhooks.js";
import { registerUpdateCommands } from "~/commands/update.js";
import { getCliVersion } from "~/lib/version.js";

function wantsJsonOutput(argv: string[]): boolean {
Expand Down Expand Up @@ -52,6 +53,7 @@ export function createProgram(): Command {
registerContactCommands(program);
registerICloudCommands(program);
registerWebhookCommands(program);
registerUpdateCommands(program);

return program;
}
Expand Down
Loading