diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index b7ce807c74..a5fb3d84e9 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -86,7 +86,8 @@ function prepareBinDirectory(binaryName) { } function printWelcome(version) { - const v = `altimate-code v${version} installed` + const cleanVersion = version.replace(/^v/, "") + const v = `altimate-code v${cleanVersion} installed` const lines = [ "", " Get started:", @@ -112,6 +113,21 @@ function printWelcome(version) { console.log(bot) } +/** + * Write a marker file so the CLI can show a welcome/upgrade banner on first run. + * npm v7+ silences postinstall stdout, so the CLI reads this marker at startup instead. + */ +function writeUpgradeMarker(version) { + try { + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share") + const dataDir = path.join(xdgData, "altimate-code") + fs.mkdirSync(dataDir, { recursive: true }) + fs.writeFileSync(path.join(dataDir, ".installed-version"), version.replace(/^v/, "")) + } catch { + // Non-fatal — the CLI just won't show a welcome banner + } +} + async function main() { let version try { @@ -126,7 +142,10 @@ async function main() { // On Windows, the .exe is already included in the package and bin field points to it // No postinstall setup needed console.log("Windows detected: binary setup not needed (using packaged .exe)") - if (version) printWelcome(version) + if (version) { + writeUpgradeMarker(version) + printWelcome(version) + } return } @@ -141,7 +160,10 @@ async function main() { fs.copyFileSync(binaryPath, target) } fs.chmodSync(target, 0o755) - if (version) printWelcome(version) + if (version) { + writeUpgradeMarker(version) + printWelcome(version) + } } catch (error) { console.error("Failed to setup altimate-code binary:", error.message) process.exit(1) diff --git a/packages/opencode/src/cli/welcome.ts b/packages/opencode/src/cli/welcome.ts new file mode 100644 index 0000000000..5fd404a010 --- /dev/null +++ b/packages/opencode/src/cli/welcome.ts @@ -0,0 +1,78 @@ +import fs from "fs" +import path from "path" +import os from "os" +import { Installation } from "../installation" +import { extractChangelog } from "./changelog" +import { EOL } from "os" + +const APP_NAME = "altimate-code" +const MARKER_FILE = ".installed-version" + +/** Resolve the data directory at call time (respects XDG_DATA_HOME changes in tests). */ +function getDataDir(): string { + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share") + return path.join(xdgData, APP_NAME) +} + +/** + * Check for a post-install/upgrade marker written by postinstall.mjs. + * If found, display a welcome banner (and changelog on upgrade), then remove the marker. + * + * npm v7+ silences postinstall stdout, so this is the reliable way to show the banner. + */ +export function showWelcomeBannerIfNeeded(): void { + try { + const markerPath = path.join(getDataDir(), MARKER_FILE) + if (!fs.existsSync(markerPath)) return + + const installedVersion = fs.readFileSync(markerPath, "utf-8").trim() + if (!installedVersion) { + fs.unlinkSync(markerPath) + return + } + + // Remove marker first to avoid showing twice even if display fails + fs.unlinkSync(markerPath) + + const currentVersion = Installation.VERSION.replace(/^v/, "") + const isUpgrade = installedVersion === currentVersion && installedVersion !== "local" + + if (!isUpgrade) return + + // Show welcome box + const tty = process.stderr.isTTY + if (!tty) return + + const orange = "\x1b[38;5;214m" + const reset = "\x1b[0m" + const bold = "\x1b[1m" + + process.stderr.write(EOL) + process.stderr.write(` ${orange}${bold}altimate-code v${currentVersion}${reset} installed successfully!${EOL}`) + process.stderr.write(EOL) + + // Try to show changelog for this version + const changelog = extractChangelog("0.0.0", currentVersion) + if (changelog) { + // Extract only the latest version section + const latestSection = changelog.split(/\n## \[/)[0] + if (latestSection) { + const dim = "\x1b[2m" + const cyan = "\x1b[36m" + const lines = latestSection.split("\n") + for (const line of lines) { + if (line.startsWith("## [")) { + process.stderr.write(` ${cyan}${line}${reset}${EOL}`) + } else if (line.startsWith("### ")) { + process.stderr.write(` ${bold}${line}${reset}${EOL}`) + } else if (line.trim()) { + process.stderr.write(` ${dim}${line}${reset}${EOL}`) + } + } + process.stderr.write(EOL) + } + } + } catch { + // Non-fatal — never let banner display break the CLI + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index bc43881596..0338e475a7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -36,6 +36,9 @@ import { Database } from "./storage/db" // altimate_change start - telemetry import import { Telemetry } from "./telemetry" // altimate_change end +// altimate_change start - welcome banner +import { showWelcomeBannerIfNeeded } from "./cli/welcome" +// altimate_change end process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -97,6 +100,10 @@ let cli = yargs(hideBin(process.argv)) Telemetry.init().catch(() => {}) // altimate_change end + // altimate_change start - welcome banner on first run after install/upgrade + showWelcomeBannerIfNeeded() + // altimate_change end + // altimate_change start - app name in logs Log.Default.info("altimate-code", { // altimate_change end diff --git a/packages/opencode/test/cli/welcome.test.ts b/packages/opencode/test/cli/welcome.test.ts new file mode 100644 index 0000000000..0c87cf0bba --- /dev/null +++ b/packages/opencode/test/cli/welcome.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import fs from "fs" +import path from "path" +import os from "os" + +describe("showWelcomeBannerIfNeeded", () => { + let tmpDir: string + let cleanup: () => void + let originalStderrWrite: typeof process.stderr.write + let stderrOutput: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-test-")) + const dataDir = path.join(tmpDir, "altimate-code") + fs.mkdirSync(dataDir, { recursive: true }) + + // Set env vars for test isolation + process.env.OPENCODE_TEST_HOME = tmpDir + process.env.XDG_DATA_HOME = tmpDir + + // Capture stderr output + stderrOutput = "" + originalStderrWrite = process.stderr.write + process.stderr.write = ((chunk: string | Uint8Array) => { + if (typeof chunk === "string") stderrOutput += chunk + return true + }) as typeof process.stderr.write + + cleanup = () => { + process.stderr.write = originalStderrWrite + delete process.env.OPENCODE_TEST_HOME + delete process.env.XDG_DATA_HOME + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + afterEach(() => { + cleanup?.() + }) + + test("does nothing when no marker file exists", async () => { + // Import with fresh module state + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + showWelcomeBannerIfNeeded() + expect(stderrOutput).toBe("") + }) + + test("removes marker file after reading", async () => { + const markerPath = path.join(tmpDir, "altimate-code", ".installed-version") + fs.writeFileSync(markerPath, "0.2.5") + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + showWelcomeBannerIfNeeded() + expect(fs.existsSync(markerPath)).toBe(false) + }) + + test("removes marker file even when version is empty", async () => { + const markerPath = path.join(tmpDir, "altimate-code", ".installed-version") + fs.writeFileSync(markerPath, "") + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + showWelcomeBannerIfNeeded() + expect(fs.existsSync(markerPath)).toBe(false) + }) + + test("does not crash on filesystem errors", async () => { + // Point to a non-existent directory — should silently handle the error + process.env.XDG_DATA_HOME = "/nonexistent/path/that/does/not/exist" + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + expect(() => showWelcomeBannerIfNeeded()).not.toThrow() + }) +}) diff --git a/packages/opencode/test/install/fixture.ts b/packages/opencode/test/install/fixture.ts index 0beb57ecc7..f74e5536e1 100644 --- a/packages/opencode/test/install/fixture.ts +++ b/packages/opencode/test/install/fixture.ts @@ -80,11 +80,12 @@ export function createDummyBinary(dir: string, name?: string): string { return binaryPath } -export function runPostinstall(cwd: string) { +export function runPostinstall(cwd: string, env?: Record) { const result = spawnSync("node", ["postinstall.mjs"], { cwd, encoding: "utf-8", timeout: 10_000, + env: { ...process.env, ...env }, }) return { exitCode: result.status ?? -1, diff --git a/packages/opencode/test/install/postinstall.test.ts b/packages/opencode/test/install/postinstall.test.ts index b15a290303..31e79fffdf 100644 --- a/packages/opencode/test/install/postinstall.test.ts +++ b/packages/opencode/test/install/postinstall.test.ts @@ -7,6 +7,7 @@ import { createBinaryPackage, runPostinstall, CURRENT_PLATFORM, + POSTINSTALL_SCRIPT, } from "./fixture" // On Windows, postinstall.mjs takes a different early-exit path that skips @@ -84,9 +85,76 @@ describe("postinstall.mjs", () => { createMainPackageDir(dir, { version: "2.5.0" }) createBinaryPackage(dir) - const result = runPostinstall(dir) + const dataDir = path.join(dir, "xdg-data") + const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("altimate-code v2.5.0 installed") + }) + + test("does not produce double-v when version already has v prefix", () => { + const { dir, cleanup: c } = installTmpdir() + cleanup = c + + createMainPackageDir(dir, { version: "v2.5.0" }) + createBinaryPackage(dir) + + const dataDir = path.join(dir, "xdg-data") + const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir }) expect(result.exitCode).toBe(0) expect(result.stdout).toContain("altimate-code v2.5.0 installed") + expect(result.stdout).not.toContain("vv2.5.0") + }) + + test("writes upgrade marker file to XDG_DATA_HOME", () => { + const { dir, cleanup: c } = installTmpdir() + cleanup = c + + createMainPackageDir(dir, { version: "2.5.0" }) + createBinaryPackage(dir) + + const dataDir = path.join(dir, "xdg-data") + const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir }) + expect(result.exitCode).toBe(0) + + const markerPath = path.join(dataDir, "altimate-code", ".installed-version") + expect(fs.existsSync(markerPath)).toBe(true) + expect(fs.readFileSync(markerPath, "utf-8")).toBe("2.5.0") + }) + + test("upgrade marker strips v prefix from version", () => { + const { dir, cleanup: c } = installTmpdir() + cleanup = c + + createMainPackageDir(dir, { version: "v1.0.0" }) + createBinaryPackage(dir) + + const dataDir = path.join(dir, "xdg-data") + const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir }) + expect(result.exitCode).toBe(0) + + const markerPath = path.join(dataDir, "altimate-code", ".installed-version") + expect(fs.readFileSync(markerPath, "utf-8")).toBe("1.0.0") + }) + + test("does not write marker when version is missing", () => { + const { dir, cleanup: c } = installTmpdir() + cleanup = c + + // Create package.json without version field + fs.copyFileSync(POSTINSTALL_SCRIPT, path.join(dir, "postinstall.mjs")) + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name: "@altimateai/altimate-code" }, null, 2), + ) + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }) + createBinaryPackage(dir) + + const dataDir = path.join(dir, "xdg-data") + const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir }) + expect(result.exitCode).toBe(0) + + const markerPath = path.join(dataDir, "altimate-code", ".installed-version") + expect(fs.existsSync(markerPath)).toBe(false) }) unixtest("exits 1 when platform binary package is missing", () => {