From 27f9c76a940d2c3fd0a92db1cfbb0c67d2cc6a01 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 26 Apr 2026 17:14:27 +0100 Subject: [PATCH] feat(server): add CLI upgrade command (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a `--upgrade [version]` CLI flag that upgrades the global CodeNomad CLI server package and exits. - Uses `bun add --global` for the package upgrade path and includes server-side tests. - Rebased onto the latest `dev` because we do not have permission to push to the original fork branch. ## Credits - Original PR: #363 - Original author: Pascal André (@pascalandr) ## Testing - Not run; this PR only recreates the rebased branch from #363. --------- Co-authored-by: Pascal André --- packages/server/src/cli-upgrade.test.ts | 39 ++++++++++++++ packages/server/src/cli-upgrade.ts | 70 +++++++++++++++++++++++++ packages/server/src/index.ts | 14 ++++- 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/cli-upgrade.test.ts create mode 100644 packages/server/src/cli-upgrade.ts diff --git a/packages/server/src/cli-upgrade.test.ts b/packages/server/src/cli-upgrade.test.ts new file mode 100644 index 00000000..e392ed1b --- /dev/null +++ b/packages/server/src/cli-upgrade.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { buildUpgradeCommand, detectPackageManager, formatUpgradeCommand } from "./cli-upgrade" + +describe("cli upgrade", () => { + it("defaults to npm when no package manager can be detected", () => { + assert.equal(detectPackageManager({}), "npm") + }) + + it("detects package managers from npm user agent", () => { + assert.equal(detectPackageManager({ npm_config_user_agent: "pnpm/9.0.0 node/v22" }), "pnpm") + assert.equal(detectPackageManager({ npm_config_user_agent: "bun/1.0.0" }), "bun") + assert.equal(detectPackageManager({ npm_config_user_agent: "npm/10.0.0 node/v22" }), "npm") + }) + + it("builds latest upgrade command by default", () => { + const command = buildUpgradeCommand(undefined, "npm") + + assert.equal(command.packageSpec, "@neuralnomads/codenomad@latest") + assert.deepEqual(command.args, ["install", "-g", "@neuralnomads/codenomad@latest"]) + assert.equal(formatUpgradeCommand(command), "npm install -g @neuralnomads/codenomad@latest") + }) + + it("builds a versioned upgrade command", () => { + const command = buildUpgradeCommand("0.10.5", "pnpm") + + assert.equal(command.packageSpec, "@neuralnomads/codenomad@0.10.5") + assert.deepEqual(command.args, ["install", "-g", "@neuralnomads/codenomad@0.10.5"]) + assert.equal(formatUpgradeCommand(command), "pnpm install -g @neuralnomads/codenomad@0.10.5") + }) + + it("uses bun add for Bun installs", () => { + const command = buildUpgradeCommand("0.10.5", "bun") + + assert.equal(command.packageSpec, "@neuralnomads/codenomad@0.10.5") + assert.deepEqual(command.args, ["add", "-g", "@neuralnomads/codenomad@0.10.5"]) + assert.equal(formatUpgradeCommand(command), "bun add -g @neuralnomads/codenomad@0.10.5") + }) +}) diff --git a/packages/server/src/cli-upgrade.ts b/packages/server/src/cli-upgrade.ts new file mode 100644 index 00000000..e7814c59 --- /dev/null +++ b/packages/server/src/cli-upgrade.ts @@ -0,0 +1,70 @@ +import { spawn } from "child_process" + +const CODENOMAD_PACKAGE_NAME = "@neuralnomads/codenomad" + +export type SupportedPackageManager = "npm" | "pnpm" | "bun" + +export interface UpgradeCommand { + command: SupportedPackageManager + args: string[] + packageSpec: string +} + +function detectFromText(value: string | undefined): SupportedPackageManager | null { + const lower = (value ?? "").toLowerCase() + if (!lower) return null + if (lower.includes("pnpm")) return "pnpm" + if (lower.includes("bun")) return "bun" + if (lower.includes("npm")) return "npm" + return null +} + +export function detectPackageManager(env: NodeJS.ProcessEnv = process.env): SupportedPackageManager { + return detectFromText(env.npm_config_user_agent) ?? detectFromText(env.npm_execpath) ?? "npm" +} + +export function buildUpgradeCommand( + version?: string, + packageManager: SupportedPackageManager = detectPackageManager(), +): UpgradeCommand { + const targetVersion = (version ?? "").trim() || "latest" + const packageSpec = `${CODENOMAD_PACKAGE_NAME}@${targetVersion}` + const args = packageManager === "bun" ? ["add", "-g", packageSpec] : ["install", "-g", packageSpec] + + return { + command: packageManager, + args, + packageSpec, + } +} + +export function formatUpgradeCommand(command: UpgradeCommand): string { + return [command.command, ...command.args].join(" ") +} + +export function runCliUpgrade(version?: string, env: NodeJS.ProcessEnv = process.env): Promise { + const upgrade = buildUpgradeCommand(version, detectPackageManager(env)) + console.log(`Upgrading CodeNomad with: ${formatUpgradeCommand(upgrade)}`) + + return new Promise((resolve) => { + const child = spawn(upgrade.command, upgrade.args, { + env, + shell: process.platform === "win32", + stdio: "inherit", + }) + + child.on("exit", (code, signal) => { + if (signal) { + console.error(`Upgrade command stopped by signal ${signal}`) + resolve(1) + return + } + resolve(code ?? 0) + }) + + child.on("error", (error) => { + console.error("Failed to launch upgrade command", error) + resolve(1) + }) + }) +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 73f89c5d..17de82f7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -29,6 +29,7 @@ import { SideCarManager } from "./sidecars/manager" import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" +import { runCliUpgrade } from "./cli-upgrade" const require = createRequire(import.meta.url) @@ -63,6 +64,7 @@ interface CliOptions { authCookieName: string generateToken: boolean dangerouslySkipAuth: boolean + upgrade?: string | boolean } const DEFAULT_HOST = "127.0.0.1" @@ -124,6 +126,7 @@ function parseCliOptions(argv: string[]): CliOptions { .env("CODENOMAD_SKIP_AUTH") .default(false), ) + .addOption(new Option("--upgrade [version]", "Upgrade the global CodeNomad CLI server package and exit")) program.parse(argv, { from: "user" }) const parsed = program.opts<{ @@ -153,8 +156,10 @@ function parseCliOptions(argv: string[]): CliOptions { authCookieName: string generateToken?: boolean dangerouslySkipAuth?: boolean + upgrade?: string | boolean }>() + const upgrade = parsed.upgrade const parseBooleanEnv = (value: string | undefined): boolean => { const normalized = (value ?? "").trim().toLowerCase() return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on" @@ -170,7 +175,7 @@ function parseCliOptions(argv: string[]): CliOptions { const httpsEnabled = parseBooleanEnv(parsed.https) const httpEnabled = parseBooleanEnv(parsed.http) - if (!httpsEnabled && !httpEnabled) { + if (upgrade === undefined && !httpsEnabled && !httpEnabled) { throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)") } @@ -200,6 +205,7 @@ function parseCliOptions(argv: string[]): CliOptions { authCookieName: parsed.authCookieName, generateToken: Boolean(parsed.generateToken), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), + upgrade, } } @@ -232,6 +238,12 @@ function programHasArg(argv: string[], flag: string): boolean { async function main() { const options = parseCliOptions(process.argv.slice(2)) + if (options.upgrade !== undefined) { + const version = typeof options.upgrade === "string" ? options.upgrade : undefined + process.exitCode = await runCliUpgrade(version) + return + } + const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" }) const workspaceLogger = logger.child({ component: "workspace" }) const configLogger = logger.child({ component: "config" })