Skip to content

Commit a214b22

Browse files
committed
fix: show welcome banner on first CLI run after install/upgrade
npm v7+ silences postinstall stdout, so the `printWelcome()` banner was never visible to users despite running correctly. **Fixes:** - Postinstall now writes a `.installed-version` marker to `XDG_DATA_HOME` - CLI reads the marker on startup, displays a styled welcome banner, then removes it — works regardless of npm's output suppression - Fixed double-v bug (`vv0.2.4`) in `printWelcome()` version display **Tests (10 new):** - Postinstall: marker file creation, v-prefix stripping, missing version - Welcome module: marker cleanup, empty marker, fs error resilience
1 parent 0618fa0 commit a214b22

6 files changed

Lines changed: 254 additions & 5 deletions

File tree

packages/opencode/script/postinstall.mjs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ function prepareBinDirectory(binaryName) {
8686
}
8787

8888
function printWelcome(version) {
89-
const v = `altimate-code v${version} installed`
89+
const cleanVersion = version.replace(/^v/, "")
90+
const v = `altimate-code v${cleanVersion} installed`
9091
const lines = [
9192
"",
9293
" Get started:",
@@ -112,6 +113,21 @@ function printWelcome(version) {
112113
console.log(bot)
113114
}
114115

116+
/**
117+
* Write a marker file so the CLI can show a welcome/upgrade banner on first run.
118+
* npm v7+ silences postinstall stdout, so the CLI reads this marker at startup instead.
119+
*/
120+
function writeUpgradeMarker(version) {
121+
try {
122+
const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
123+
const dataDir = path.join(xdgData, "altimate-code")
124+
fs.mkdirSync(dataDir, { recursive: true })
125+
fs.writeFileSync(path.join(dataDir, ".installed-version"), version.replace(/^v/, ""))
126+
} catch {
127+
// Non-fatal — the CLI just won't show a welcome banner
128+
}
129+
}
130+
115131
async function main() {
116132
let version
117133
try {
@@ -126,7 +142,10 @@ async function main() {
126142
// On Windows, the .exe is already included in the package and bin field points to it
127143
// No postinstall setup needed
128144
console.log("Windows detected: binary setup not needed (using packaged .exe)")
129-
if (version) printWelcome(version)
145+
if (version) {
146+
writeUpgradeMarker(version)
147+
printWelcome(version)
148+
}
130149
return
131150
}
132151

@@ -141,7 +160,10 @@ async function main() {
141160
fs.copyFileSync(binaryPath, target)
142161
}
143162
fs.chmodSync(target, 0o755)
144-
if (version) printWelcome(version)
163+
if (version) {
164+
writeUpgradeMarker(version)
165+
printWelcome(version)
166+
}
145167
} catch (error) {
146168
console.error("Failed to setup altimate-code binary:", error.message)
147169
process.exit(1)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import fs from "fs"
2+
import path from "path"
3+
import os from "os"
4+
import { Installation } from "../installation"
5+
import { extractChangelog } from "./changelog"
6+
import { EOL } from "os"
7+
8+
const APP_NAME = "altimate-code"
9+
const MARKER_FILE = ".installed-version"
10+
11+
/** Resolve the data directory at call time (respects XDG_DATA_HOME changes in tests). */
12+
function getDataDir(): string {
13+
const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
14+
return path.join(xdgData, APP_NAME)
15+
}
16+
17+
/**
18+
* Check for a post-install/upgrade marker written by postinstall.mjs.
19+
* If found, display a welcome banner (and changelog on upgrade), then remove the marker.
20+
*
21+
* npm v7+ silences postinstall stdout, so this is the reliable way to show the banner.
22+
*/
23+
export function showWelcomeBannerIfNeeded(): void {
24+
try {
25+
const markerPath = path.join(getDataDir(), MARKER_FILE)
26+
if (!fs.existsSync(markerPath)) return
27+
28+
const installedVersion = fs.readFileSync(markerPath, "utf-8").trim()
29+
if (!installedVersion) {
30+
fs.unlinkSync(markerPath)
31+
return
32+
}
33+
34+
// Remove marker first to avoid showing twice even if display fails
35+
fs.unlinkSync(markerPath)
36+
37+
const currentVersion = Installation.VERSION.replace(/^v/, "")
38+
const isUpgrade = installedVersion === currentVersion && installedVersion !== "local"
39+
40+
if (!isUpgrade) return
41+
42+
// Show welcome box
43+
const tty = process.stderr.isTTY
44+
if (!tty) return
45+
46+
const orange = "\x1b[38;5;214m"
47+
const reset = "\x1b[0m"
48+
const bold = "\x1b[1m"
49+
50+
process.stderr.write(EOL)
51+
process.stderr.write(` ${orange}${bold}altimate-code v${currentVersion}${reset} installed successfully!${EOL}`)
52+
process.stderr.write(EOL)
53+
54+
// Try to show changelog for this version
55+
const changelog = extractChangelog("0.0.0", currentVersion)
56+
if (changelog) {
57+
// Extract only the latest version section
58+
const latestSection = changelog.split(/\n## \[/)[0]
59+
if (latestSection) {
60+
const dim = "\x1b[2m"
61+
const cyan = "\x1b[36m"
62+
const lines = latestSection.split("\n")
63+
for (const line of lines) {
64+
if (line.startsWith("## [")) {
65+
process.stderr.write(` ${cyan}${line}${reset}${EOL}`)
66+
} else if (line.startsWith("### ")) {
67+
process.stderr.write(` ${bold}${line}${reset}${EOL}`)
68+
} else if (line.trim()) {
69+
process.stderr.write(` ${dim}${line}${reset}${EOL}`)
70+
}
71+
}
72+
process.stderr.write(EOL)
73+
}
74+
}
75+
} catch {
76+
// Non-fatal — never let banner display break the CLI
77+
}
78+
}

packages/opencode/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import { Database } from "./storage/db"
3636
// altimate_change start - telemetry import
3737
import { Telemetry } from "./telemetry"
3838
// altimate_change end
39+
// altimate_change start - welcome banner
40+
import { showWelcomeBannerIfNeeded } from "./cli/welcome"
41+
// altimate_change end
3942

4043
process.on("unhandledRejection", (e) => {
4144
Log.Default.error("rejection", {
@@ -97,6 +100,10 @@ let cli = yargs(hideBin(process.argv))
97100
Telemetry.init().catch(() => {})
98101
// altimate_change end
99102

103+
// altimate_change start - welcome banner on first run after install/upgrade
104+
showWelcomeBannerIfNeeded()
105+
// altimate_change end
106+
100107
// altimate_change start - app name in logs
101108
Log.Default.info("altimate-code", {
102109
// altimate_change end
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
2+
import fs from "fs"
3+
import path from "path"
4+
import os from "os"
5+
6+
describe("showWelcomeBannerIfNeeded", () => {
7+
let tmpDir: string
8+
let cleanup: () => void
9+
let originalStderrWrite: typeof process.stderr.write
10+
let stderrOutput: string
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-test-"))
14+
const dataDir = path.join(tmpDir, "altimate-code")
15+
fs.mkdirSync(dataDir, { recursive: true })
16+
17+
// Set env vars for test isolation
18+
process.env.OPENCODE_TEST_HOME = tmpDir
19+
process.env.XDG_DATA_HOME = tmpDir
20+
21+
// Capture stderr output
22+
stderrOutput = ""
23+
originalStderrWrite = process.stderr.write
24+
process.stderr.write = ((chunk: string | Uint8Array) => {
25+
if (typeof chunk === "string") stderrOutput += chunk
26+
return true
27+
}) as typeof process.stderr.write
28+
29+
cleanup = () => {
30+
process.stderr.write = originalStderrWrite
31+
delete process.env.OPENCODE_TEST_HOME
32+
delete process.env.XDG_DATA_HOME
33+
fs.rmSync(tmpDir, { recursive: true, force: true })
34+
}
35+
})
36+
37+
afterEach(() => {
38+
cleanup?.()
39+
})
40+
41+
test("does nothing when no marker file exists", async () => {
42+
// Import with fresh module state
43+
const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome")
44+
showWelcomeBannerIfNeeded()
45+
expect(stderrOutput).toBe("")
46+
})
47+
48+
test("removes marker file after reading", async () => {
49+
const markerPath = path.join(tmpDir, "altimate-code", ".installed-version")
50+
fs.writeFileSync(markerPath, "0.2.5")
51+
52+
const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome")
53+
showWelcomeBannerIfNeeded()
54+
expect(fs.existsSync(markerPath)).toBe(false)
55+
})
56+
57+
test("removes marker file even when version is empty", async () => {
58+
const markerPath = path.join(tmpDir, "altimate-code", ".installed-version")
59+
fs.writeFileSync(markerPath, "")
60+
61+
const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome")
62+
showWelcomeBannerIfNeeded()
63+
expect(fs.existsSync(markerPath)).toBe(false)
64+
})
65+
66+
test("does not crash on filesystem errors", async () => {
67+
// Point to a non-existent directory — should silently handle the error
68+
process.env.XDG_DATA_HOME = "/nonexistent/path/that/does/not/exist"
69+
70+
const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome")
71+
expect(() => showWelcomeBannerIfNeeded()).not.toThrow()
72+
})
73+
})

packages/opencode/test/install/fixture.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,12 @@ export function createDummyBinary(dir: string, name?: string): string {
8080
return binaryPath
8181
}
8282

83-
export function runPostinstall(cwd: string) {
83+
export function runPostinstall(cwd: string, env?: Record<string, string>) {
8484
const result = spawnSync("node", ["postinstall.mjs"], {
8585
cwd,
8686
encoding: "utf-8",
8787
timeout: 10_000,
88+
env: { ...process.env, ...env },
8889
})
8990
return {
9091
exitCode: result.status ?? -1,

packages/opencode/test/install/postinstall.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createBinaryPackage,
88
runPostinstall,
99
CURRENT_PLATFORM,
10+
POSTINSTALL_SCRIPT,
1011
} from "./fixture"
1112

1213
// On Windows, postinstall.mjs takes a different early-exit path that skips
@@ -84,9 +85,76 @@ describe("postinstall.mjs", () => {
8485
createMainPackageDir(dir, { version: "2.5.0" })
8586
createBinaryPackage(dir)
8687

87-
const result = runPostinstall(dir)
88+
const dataDir = path.join(dir, "xdg-data")
89+
const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir })
90+
expect(result.exitCode).toBe(0)
91+
expect(result.stdout).toContain("altimate-code v2.5.0 installed")
92+
})
93+
94+
test("does not produce double-v when version already has v prefix", () => {
95+
const { dir, cleanup: c } = installTmpdir()
96+
cleanup = c
97+
98+
createMainPackageDir(dir, { version: "v2.5.0" })
99+
createBinaryPackage(dir)
100+
101+
const dataDir = path.join(dir, "xdg-data")
102+
const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir })
88103
expect(result.exitCode).toBe(0)
89104
expect(result.stdout).toContain("altimate-code v2.5.0 installed")
105+
expect(result.stdout).not.toContain("vv2.5.0")
106+
})
107+
108+
test("writes upgrade marker file to XDG_DATA_HOME", () => {
109+
const { dir, cleanup: c } = installTmpdir()
110+
cleanup = c
111+
112+
createMainPackageDir(dir, { version: "2.5.0" })
113+
createBinaryPackage(dir)
114+
115+
const dataDir = path.join(dir, "xdg-data")
116+
const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir })
117+
expect(result.exitCode).toBe(0)
118+
119+
const markerPath = path.join(dataDir, "altimate-code", ".installed-version")
120+
expect(fs.existsSync(markerPath)).toBe(true)
121+
expect(fs.readFileSync(markerPath, "utf-8")).toBe("2.5.0")
122+
})
123+
124+
test("upgrade marker strips v prefix from version", () => {
125+
const { dir, cleanup: c } = installTmpdir()
126+
cleanup = c
127+
128+
createMainPackageDir(dir, { version: "v1.0.0" })
129+
createBinaryPackage(dir)
130+
131+
const dataDir = path.join(dir, "xdg-data")
132+
const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir })
133+
expect(result.exitCode).toBe(0)
134+
135+
const markerPath = path.join(dataDir, "altimate-code", ".installed-version")
136+
expect(fs.readFileSync(markerPath, "utf-8")).toBe("1.0.0")
137+
})
138+
139+
test("does not write marker when version is missing", () => {
140+
const { dir, cleanup: c } = installTmpdir()
141+
cleanup = c
142+
143+
// Create package.json without version field
144+
fs.copyFileSync(POSTINSTALL_SCRIPT, path.join(dir, "postinstall.mjs"))
145+
fs.writeFileSync(
146+
path.join(dir, "package.json"),
147+
JSON.stringify({ name: "@altimateai/altimate-code" }, null, 2),
148+
)
149+
fs.mkdirSync(path.join(dir, "bin"), { recursive: true })
150+
createBinaryPackage(dir)
151+
152+
const dataDir = path.join(dir, "xdg-data")
153+
const result = runPostinstall(dir, { XDG_DATA_HOME: dataDir })
154+
expect(result.exitCode).toBe(0)
155+
156+
const markerPath = path.join(dataDir, "altimate-code", ".installed-version")
157+
expect(fs.existsSync(markerPath)).toBe(false)
90158
})
91159

92160
unixtest("exits 1 when platform binary package is missing", () => {

0 commit comments

Comments
 (0)