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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kars-runtime/cli",
"version": "0.1.23",
"version": "0.1.24",
"description": "Enterprise-grade runtime for running OpenClaw AI assistants safely on Azure",
"license": "MIT",
"repository": {
Expand Down
5 changes: 5 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { inspectCommand } from "./commands/inspect.js";
import { auditCommand } from "./commands/audit.js";
import { headlampCommand } from "./commands/headlamp.js";
import { sreCommand } from "./commands/sre.js";
import { updateCommand } from "./commands/update.js";

export function createCli(): Command {
const program = new Command();
Expand Down Expand Up @@ -100,6 +101,9 @@ export function createCli(): Command {
// Attestation
program.addCommand(attestCommand());

// Self-management
program.addCommand(updateCommand());

program.addHelpText("after", `
Command groups:
Lifecycle up, dev, add, push, destroy
Expand All @@ -110,6 +114,7 @@ Command groups:
Interop convert, a2a, a2a-agent, migrate
Governance toolpolicy, inferencepolicy, mcp, memory
Attestation attest
Self update

Quick start:
kars up # Provision Azure + deploy controller + first sandbox
Expand Down
59 changes: 59 additions & 0 deletions cli/src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// commands/update.ts — `kars update`: explicitly check npm for a newer
// `@kars-runtime/cli`, show the changelog, and (interactively) install it.
//
// This is the on-demand counterpart to the passive end-of-run notice wired into
// every invocation (lib/update-check.ts). It always hits the network (bypassing
// the 24h cache) and always offers to install when a newer version exists.

import { Command } from "commander";
import chalk from "chalk";
import { cliVersion } from "../lib/version.js";
import {
CLI_PACKAGE,
checkForCliUpdate,
renderUpdateNotice,
} from "../lib/update-check.js";

export function updateCommand(): Command {
const cmd = new Command("update");

cmd
.description(`Check for and install a newer ${CLI_PACKAGE}`)
.option("--check", "Only check; never prompt or install")
.option("--yes", "Install the latest version without prompting")
.action(async (options: { check?: boolean; yes?: boolean }) => {
const info = await checkForCliUpdate({ force: true, withChangelog: true });

if (!info) {
console.error(
chalk.green(`\n ✓ ${CLI_PACKAGE} is up to date `) +
chalk.dim(`(v${cliVersion()}).\n`),
);
return;
}

if (options.check) {
// Report-only: print the notice without the install prompt.
await renderUpdateNotice(info, { offerInstall: false });
// Non-zero so scripts/CI can detect "an update is available".
process.exitCode = 1;
return;
}

if (options.yes) {
const { runGlobalInstall } = await import("../lib/update-check.js");
await renderUpdateNotice(info, { offerInstall: false });
await runGlobalInstall();
return;
}

await renderUpdateNotice(info, {
offerInstall: !!process.stdout.isTTY && !!process.stdin.isTTY,
});
});

return cmd;
}
97 changes: 97 additions & 0 deletions cli/src/commands/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
detectCurrentVersion,
rolloutRestartAll,
verifyHealth,
parseHelmFieldConflicts,
suggestConflictRemediation,
} from "./upgrade.js";

describe("isFoundryProjectHost", () => {
Expand Down Expand Up @@ -127,6 +129,29 @@ describe("buildHelmUpgradeArgs", () => {
expect(args.slice(0, 4)).toEqual(["upgrade", "--install", "kars", "/chart"]);
});

it("does NOT take ownership of foreign-managed fields by default (no --force-conflicts)", () => {
const args = buildHelmUpgradeArgs(ctx, "/chart", "v0.1.20");
expect(args).not.toContain("--force-conflicts");
});

it("adds --force-conflicts only when explicitly opted in", () => {
const args = buildHelmUpgradeArgs(ctx, "/chart", "v0.1.20", { forceConflicts: true });
expect(args).toContain("--force-conflicts");
// still a real, atomic upgrade
expect(args).toContain("--atomic");
});

it("builds a non-mutating server dry-run for the conflict pre-flight", () => {
const args = buildHelmUpgradeArgs(ctx, "/chart", "v0.1.20", { dryRunServer: true });
expect(args).toContain("--dry-run=server");
// dry-run must NOT carry --atomic/--wait (meaningless) nor force ownership
expect(args).not.toContain("--atomic");
expect(args).not.toContain("--wait");
expect(args).not.toContain("--force-conflicts");
// ...but still carries the real image values so the dry-run is representative
expect(args.join(" ")).toContain("controller.image.tag=v0.1.20");
});

it("only sets the Foundry endpoint when one is configured", () => {
const withFoundry = buildHelmUpgradeArgs({ ...ctx, foundryEndpoint: "https://x.services.ai.azure.com" }, "/chart", "v0.1.20").join(" ");
expect(withFoundry).toContain("inferenceRouter.azure.openai.endpoint=https://x.services.ai.azure.com");
Expand All @@ -135,6 +160,78 @@ describe("buildHelmUpgradeArgs", () => {
});
});

describe("parseHelmFieldConflicts — failsafe field-manager conflict detection", () => {
// The real Helm v4 error that wedged the v0.1.18 → v0.1.23 upgrade on launch
// day, as printed to stderr (`Error: UPGRADE FAILED:` — quotes are bare here).
const realError =
"Error: UPGRADE FAILED: an error occurred while rolling back the release. " +
"original upgrade error: conflict occurred while applying object " +
"kars-system/kars-controller apps/v1, Kind=Deployment: Apply failed with 1 " +
'conflict: conflict with "kubectl-set" using apps/v1: ' +
'.spec.template.spec.containers[name="controller"].env[name="HERMES_RUNTIME_IMAGE"].value';

it("extracts the object, kind, manager, and field from a real conflict", () => {
const conflicts = parseHelmFieldConflicts(realError);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]).toMatchObject({
object: "kars-system/kars-controller",
kind: "Deployment",
manager: "kubectl-set",
});
expect(conflicts[0].field).toContain('env[name="HERMES_RUNTIME_IMAGE"].value');
});

it("also parses Go-%q escaped quotes from the structured WARN log line", () => {
const warn =
'level=WARN msg="upgrade failed" name=kars error="conflict occurred while ' +
'applying object kars-system/kars-controller apps/v1, Kind=Deployment: Apply ' +
'failed with 1 conflict: conflict with \\"kubectl-set\\" using apps/v1: ' +
'.spec.template.spec.containers[name=\\"controller\\"].env[name=\\"HERMES_RUNTIME_IMAGE\\"].value"';
const conflicts = parseHelmFieldConflicts(warn);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].manager).toBe("kubectl-set");
});

it("attributes multiple conflicting fields to the right object and de-dupes", () => {
const multi =
"conflict occurred while applying object ns/dep apps/v1, Kind=Deployment: " +
'Apply failed with 2 conflicts: conflict with "mgr-a" using apps/v1: .spec.foo, ' +
'conflict with "mgr-b" using apps/v1: .spec.bar; ' +
// a repeat of the same conflict must not double-count
'conflict with "mgr-a" using apps/v1: .spec.foo';
const conflicts = parseHelmFieldConflicts(multi);
expect(conflicts).toHaveLength(2);
expect(conflicts.map((c) => c.manager).sort()).toEqual(["mgr-a", "mgr-b"]);
expect(conflicts.every((c) => c.object === "ns/dep")).toBe(true);
});

it("returns nothing for output that has no conflict (a clean dry-run)", () => {
expect(parseHelmFieldConflicts("")).toEqual([]);
expect(parseHelmFieldConflicts("Release \"kars\" has been upgraded. Happy Helming!")).toEqual([]);
});
});

describe("suggestConflictRemediation — actionable copy-paste fix", () => {
it("turns an env-var conflict into the exact `kubectl set env … NAME-` command", () => {
const fix = suggestConflictRemediation({
object: "kars-system/kars-controller",
kind: "Deployment",
manager: "kubectl-set",
field: '.spec.template.spec.containers[name="controller"].env[name="HERMES_RUNTIME_IMAGE"].value',
});
expect(fix).toBe("kubectl set env deployment/kars-controller -n kars-system HERMES_RUNTIME_IMAGE-");
});

it("returns null for field shapes it can't safely auto-remediate", () => {
expect(suggestConflictRemediation({
object: "ns/cm",
kind: "ConfigMap",
manager: "kubectl-edit",
field: ".data.someKey",
})).toBeNull();
});
});

// ── health / safety / recovery coverage ────────────────────────────

type Execa = typeof import("execa").execa;
Expand Down
Loading
Loading