feat(server): add CLI upgrade command (#374)

## 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é <pascalandr@gmail.com>
This commit is contained in:
Shantur Rathore
2026-04-26 17:14:27 +01:00
committed by GitHub
parent c526287b2f
commit 27f9c76a94
3 changed files with 122 additions and 1 deletions

View File

@@ -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")
})
})

View File

@@ -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<number> {
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)
})
})
}

View File

@@ -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" })