diff --git a/docs/docs/assets/images/upgrade-indicator-shown.png b/docs/docs/assets/images/upgrade-indicator-shown.png new file mode 100644 index 0000000000..4414587341 Binary files /dev/null and b/docs/docs/assets/images/upgrade-indicator-shown.png differ diff --git a/docs/docs/assets/images/upgrade-no-indicator.png b/docs/docs/assets/images/upgrade-no-indicator.png new file mode 100644 index 0000000000..52f06edc4a Binary files /dev/null and b/docs/docs/assets/images/upgrade-no-indicator.png differ diff --git a/docs/docs/configure/config.md b/docs/docs/configure/config.md index 8c2ed93f7e..6300fbf939 100644 --- a/docs/docs/configure/config.md +++ b/docs/docs/configure/config.md @@ -44,7 +44,7 @@ Configuration is loaded from multiple sources, with later sources overriding ear | `default_agent` | `string` | Default agent to use on startup | | `logLevel` | `string` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` | | `share` | `string` | Session sharing: `"manual"`, `"auto"`, `"disabled"` | -| `autoupdate` | `boolean \| "notify"` | Auto-update behavior | +| `autoupdate` | `boolean \| "notify"` | Auto-update behavior: `true` (default) auto-upgrades, `"notify"` shows an indicator without upgrading, `false` disables auto-upgrade but still shows the update indicator | | `provider` | `object` | Provider configurations (see [Providers](providers.md)) | | `mcp` | `object` | MCP server configurations (see [MCP Servers](mcp-servers.md)) | | `formatter` | `object \| false` | Formatter settings (see [Formatters](formatters.md)) | diff --git a/docs/docs/reference/troubleshooting.md b/docs/docs/reference/troubleshooting.md index 4429bbf7a4..28e5e51e54 100644 --- a/docs/docs/reference/troubleshooting.md +++ b/docs/docs/reference/troubleshooting.md @@ -104,7 +104,7 @@ Disable auto-update if it causes problems: export ALTIMATE_CLI_DISABLE_AUTOUPDATE=true ``` -Or set to notification only: +Or set to notification only in your config: ```json { @@ -112,6 +112,15 @@ Or set to notification only: } ``` +Both options still show an upgrade indicator in the footer when a new version is available. To upgrade manually, run: + +```bash +altimate upgrade +``` + +!!! note + When an update is available, you'll see `↑ update available · altimate upgrade` in the bottom-right corner of the TUI. + ### Context Too Large If conversations hit context limits: diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index b32b43b243..75446f46f7 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -68,7 +68,7 @@ Configuration can be controlled via environment variables: | Variable | Description | | -------------------------------------- | ------------------------------------ | -| `ALTIMATE_CLI_DISABLE_AUTOUPDATE` | Disable automatic updates | +| `ALTIMATE_CLI_DISABLE_AUTOUPDATE` | Disable automatic updates (still shows upgrade indicator) | | `ALTIMATE_CLI_DISABLE_LSP_DOWNLOAD` | Don't auto-download LSP servers | | `ALTIMATE_CLI_DISABLE_AUTOCOMPACT` | Disable automatic context compaction | | `ALTIMATE_CLI_DISABLE_DEFAULT_PLUGINS` | Skip loading default plugins | diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2d46ae39fa..75a77b3d36 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -2,24 +2,55 @@ import { Bus } from "@/bus" import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { Installation } from "@/installation" +// altimate_change start — robust upgrade notification +import semver from "semver" +import { Log } from "@/util/log" + +const log = Log.create({ service: "upgrade" }) export async function upgrade() { const config = await Config.global() const method = await Installation.method() - const latest = await Installation.latest(method).catch(() => {}) + const latest = await Installation.latest(method).catch((err) => { + log.warn("failed to fetch latest version", { error: String(err), method }) + return undefined + }) if (!latest) return if (Installation.VERSION === latest) return + // Prevent downgrade: if current version is already >= latest, skip + if ( + Installation.VERSION !== "local" && + semver.valid(Installation.VERSION) && + semver.valid(latest) && + semver.gte(Installation.VERSION, latest) + ) { + return + } + + const notify = () => Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) + + // Always notify when update is available, regardless of autoupdate setting if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) { + await notify() return } if (config.autoupdate === "notify") { - await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) + await notify() + return + } + + // Can't auto-upgrade for unknown or unsupported methods — notify instead + if (method === "unknown" || method === "yarn") { + await notify() return } - if (method === "unknown") return await Installation.upgrade(method, latest) .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) - .catch(() => {}) + .catch(async (err) => { + log.warn("auto-upgrade failed, notifying instead", { error: String(err), method, target: latest }) + await notify() + }) } +// altimate_change end diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index a94b2cc63e..b0aae920da 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -28,7 +28,9 @@ export namespace Flag { export declare const OPENCODE_TUI_CONFIG: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] - export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") + // altimate_change start — support ALTIMATE_CLI_DISABLE_AUTOUPDATE env var (documented name) + export const OPENCODE_DISABLE_AUTOUPDATE = altTruthy("ALTIMATE_CLI_DISABLE_AUTOUPDATE", "OPENCODE_DISABLE_AUTOUPDATE") + // altimate_change end export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") // altimate_change start - global opt-out for Altimate Memory export const ALTIMATE_DISABLE_MEMORY = altTruthy("ALTIMATE_DISABLE_MEMORY", "OPENCODE_DISABLE_MEMORY") diff --git a/packages/opencode/test/altimate/training-import.test.ts b/packages/opencode/test/altimate/training-import.test.ts index bbae2c5df2..bd42d7bc08 100644 --- a/packages/opencode/test/altimate/training-import.test.ts +++ b/packages/opencode/test/altimate/training-import.test.ts @@ -56,6 +56,8 @@ function setupMocks(opts: { context: opts.currentCount ?? 0, rule: opts.currentCount ?? 0, pattern: opts.currentCount ?? 0, + context: opts.currentCount ?? 0, + rule: opts.currentCount ?? 0, })) saveSpy = spyOn(TrainingStore, "save").mockImplementation(async () => { if (opts.saveShouldFail) throw new Error("store write failed") diff --git a/packages/opencode/test/cli/upgrade-decision.test.ts b/packages/opencode/test/cli/upgrade-decision.test.ts new file mode 100644 index 0000000000..ef30314eab --- /dev/null +++ b/packages/opencode/test/cli/upgrade-decision.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, test } from "bun:test" +import semver from "semver" +import { Installation } from "../../src/installation" + +/** + * Tests for the upgrade() decision logic in cli/upgrade.ts. + * + * Since upgrade() depends on Config, Bus, and Installation with side effects, + * we test the decision logic directly — the same conditions that upgrade() checks. + * This validates the fix for: silent skip on unknown method, autoupdate=false, + * failed auto-upgrade, and downgrade prevention. + */ + +// ─── Decision Logic Extracted from upgrade() ───────────────────────────────── +// These mirror the exact checks in cli/upgrade.ts so we can test every path. + +type Decision = "skip" | "notify" | "auto-upgrade" + +function upgradeDecision(input: { + latest: string | undefined + currentVersion: string + autoupdate: boolean | "notify" | undefined + disableAutoupdate: boolean + method: string +}): Decision { + const { latest, currentVersion, autoupdate, disableAutoupdate, method } = input + + if (!latest) return "skip" + if (currentVersion === latest) return "skip" + + // Prevent downgrade + if ( + currentVersion !== "local" && + semver.valid(currentVersion) && + semver.valid(latest) && + semver.gte(currentVersion, latest) + ) { + return "skip" + } + + if (autoupdate === false || disableAutoupdate) return "notify" + if (autoupdate === "notify") return "notify" + if (method === "unknown" || method === "yarn") return "notify" + + return "auto-upgrade" +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("upgrade decision logic", () => { + describe("skip: no latest version available", () => { + test("latest is undefined (network failure)", () => { + expect(upgradeDecision({ + latest: undefined, + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + + test("latest is empty string", () => { + expect(upgradeDecision({ + latest: "", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + }) + + describe("skip: already up to date", () => { + test("same version string", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.7", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + }) + + describe("skip: downgrade prevention", () => { + test("current version is newer than latest (canary/preview user)", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.6.0", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + + test("current is prerelease of a newer version", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.6.0-beta.1", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + + test("semver.gte catches equal versions even if string !== (edge case)", () => { + // This shouldn't happen in practice (both normalize), but tests the safety net + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.7", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + + test("local version bypasses downgrade check", () => { + // Dev mode: VERSION="local" should NOT be caught by semver guard + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "local", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + + test("invalid semver current version bypasses downgrade check", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "dev-build-123", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + }) + + describe("notify: autoupdate disabled", () => { + test("autoupdate is false", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: false, + disableAutoupdate: false, + method: "npm", + })).toBe("notify") + }) + + test("OPENCODE_DISABLE_AUTOUPDATE flag is true", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: true, + method: "npm", + })).toBe("notify") + }) + + test("both autoupdate=false and flag=true", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: false, + disableAutoupdate: true, + method: "npm", + })).toBe("notify") + }) + + test("autoupdate is 'notify'", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: "notify", + disableAutoupdate: false, + method: "npm", + })).toBe("notify") + }) + }) + + describe("notify: unknown or unsupported install method", () => { + test("method is 'unknown'", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "unknown", + })).toBe("notify") + }) + + test("method is 'yarn' (detected but not supported for auto-upgrade)", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "yarn", + })).toBe("notify") + }) + + test("unknown method with autoupdate=false still notifies", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: false, + disableAutoupdate: false, + method: "unknown", + })).toBe("notify") + }) + }) + + describe("auto-upgrade: supported methods with autoupdate enabled", () => { + const supportedMethods = ["npm", "bun", "pnpm", "brew", "curl", "choco", "scoop"] + + for (const method of supportedMethods) { + test(`auto-upgrade for method: ${method}`, () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method, + })).toBe("auto-upgrade") + }) + } + + test("autoupdate=true explicitly", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: true, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + }) + + describe("the reported bug: user on 0.5.2, latest is 0.5.7", () => { + test("npm install, default config → should auto-upgrade", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + + test("unknown method, default config → should notify (was silently skipped before fix)", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "unknown", + })).toBe("notify") + }) + + test("autoupdate=false → should notify (was silently skipped before fix)", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: false, + disableAutoupdate: false, + method: "npm", + })).toBe("notify") + }) + + test("DISABLE_AUTOUPDATE flag → should notify (was silently skipped before fix)", () => { + expect(upgradeDecision({ + latest: "0.5.7", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: true, + method: "npm", + })).toBe("notify") + }) + }) + + describe("version format edge cases", () => { + test("patch version bump", () => { + expect(upgradeDecision({ + latest: "0.5.3", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + + test("major version bump", () => { + expect(upgradeDecision({ + latest: "1.0.0", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + + test("prerelease latest version vs stable current", () => { + // 1.0.0-beta.1 is greater than 0.5.2 + expect(upgradeDecision({ + latest: "1.0.0-beta.1", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("auto-upgrade") + }) + + test("same major.minor, prerelease latest < current release", () => { + // 0.5.2-beta.1 is LESS than 0.5.2 per semver + expect(upgradeDecision({ + latest: "0.5.2-beta.1", + currentVersion: "0.5.2", + autoupdate: undefined, + disableAutoupdate: false, + method: "npm", + })).toBe("skip") + }) + }) +}) + +// ─── Installation.VERSION sanity ───────────────────────────────────────────── + +describe("Installation.VERSION format", () => { + test("is a non-empty string", () => { + expect(typeof Installation.VERSION).toBe("string") + expect(Installation.VERSION.length).toBeGreaterThan(0) + }) + + test("does not have v prefix", () => { + expect(Installation.VERSION.startsWith("v")).toBe(false) + }) + + test("is either 'local' or valid semver", () => { + if (Installation.VERSION !== "local") { + expect(semver.valid(Installation.VERSION)).not.toBeNull() + } + }) +})