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
28 changes: 25 additions & 3 deletions packages/opencode/script/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand All @@ -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 {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/src/cli/welcome.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 7 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions packages/opencode/test/cli/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
3 changes: 2 additions & 1 deletion packages/opencode/test/install/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) {
const result = spawnSync("node", ["postinstall.mjs"], {
cwd,
encoding: "utf-8",
timeout: 10_000,
env: { ...process.env, ...env },
})
return {
exitCode: result.status ?? -1,
Expand Down
70 changes: 69 additions & 1 deletion packages/opencode/test/install/postinstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading