Skip to content

Commit dbf8525

Browse files
anandgupta42claude
andauthored
feat: add postinstall welcome banner and changelog display after upgrade (#48)
Show a welcome banner with quick-start commands after npm install. Display relevant CHANGELOG.md entries after a successful upgrade so users can see what changed between their old and new versions. The changelog is bundled at build time via esbuild define and also copied into the published npm package as a fallback. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b64041 commit dbf8525

File tree

6 files changed

+204
-0
lines changed

6 files changed

+204
-0
lines changed

packages/altimate-code/script/build.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ if (!engineVersionMatch) {
2525
const engineVersion = engineVersionMatch[1]
2626
console.log(`Engine version: ${engineVersion}`)
2727

28+
// Read CHANGELOG.md for bundling
29+
const changelogPath = path.resolve(dir, "../../CHANGELOG.md")
30+
const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).text() : ""
31+
console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`)
32+
2833
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
2934
// Fetch and generate models.dev snapshot
3035
const modelsData = process.env.MODELS_DEV_API_JSON
@@ -213,6 +218,7 @@ for (const item of targets) {
213218
ALTIMATE_CLI_CHANNEL: `'${Script.channel}'`,
214219
ALTIMATE_ENGINE_VERSION: `'${engineVersion}'`,
215220
ALTIMATE_CLI_MIGRATIONS: JSON.stringify(migrations),
221+
ALTIMATE_CLI_CHANGELOG: JSON.stringify(changelog),
216222
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
217223
},
218224
})

packages/altimate-code/script/postinstall.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,48 @@ function prepareBinDirectory(binaryName) {
8585
return { binDir, targetPath }
8686
}
8787

88+
function printWelcome(version) {
89+
const v = `altimate-code v${version} installed`
90+
const lines = [
91+
"",
92+
" Get started:",
93+
" altimate Open the TUI",
94+
' altimate run "hello" Run a quick task',
95+
" altimate --help See all commands",
96+
"",
97+
" Docs: https://altimate-code.dev",
98+
"",
99+
]
100+
// Box width: pad all lines to the same length
101+
const contentWidth = Math.max(v.length, ...lines.map((l) => l.length)) + 2
102+
const pad = (s) => s + " ".repeat(contentWidth - s.length)
103+
const top = ` ╭${"─".repeat(contentWidth + 2)}╮`
104+
const bot = ` ╰${"─".repeat(contentWidth + 2)}╯`
105+
const empty = ` │ ${" ".repeat(contentWidth)} │`
106+
const row = (s) => ` │ ${pad(s)} │`
107+
108+
console.log(top)
109+
console.log(empty)
110+
console.log(row(` ${v}`))
111+
for (const line of lines) console.log(row(line))
112+
console.log(bot)
113+
}
114+
88115
async function main() {
116+
let version
117+
try {
118+
const pkgPath = path.join(__dirname, "package.json")
119+
if (fs.existsSync(pkgPath)) {
120+
version = JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version
121+
}
122+
} catch {}
123+
89124
try {
90125
if (os.platform() === "win32") {
91126
// On Windows, the .exe is already included in the package and bin field points to it
92127
// No postinstall setup needed
93128
console.log("Windows detected: binary setup not needed (using packaged .exe)")
129+
if (version) printWelcome(version)
94130
return
95131
}
96132

@@ -105,6 +141,7 @@ async function main() {
105141
fs.copyFileSync(binaryPath, target)
106142
}
107143
fs.chmodSync(target, 0o755)
144+
if (version) printWelcome(version)
108145
} catch (error) {
109146
console.error("Failed to setup altimate-code binary:", error.message)
110147
process.exit(1)

packages/altimate-code/script/publish.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ await $`mkdir -p ./dist/${pkg.name}`
1919
await $`cp -r ./bin ./dist/${pkg.name}/bin`
2020
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
2121
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
22+
await Bun.file(`./dist/${pkg.name}/CHANGELOG.md`).write(await Bun.file("../../CHANGELOG.md").text())
2223

2324
await Bun.file(`./dist/${pkg.name}/package.json`).write(
2425
JSON.stringify(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
declare const ALTIMATE_CLI_CHANGELOG: string | undefined
2+
3+
/** Parse a semver string into comparable numeric tuple. Returns null on failure. */
4+
function parseSemver(v: string): [number, number, number] | null {
5+
const clean = v.replace(/^v/, "").split("-")[0]
6+
const parts = clean.split(".")
7+
if (parts.length !== 3) return null
8+
const nums = parts.map(Number) as [number, number, number]
9+
if (nums.some(isNaN)) return null
10+
return nums
11+
}
12+
13+
/** Compare two semver tuples: -1 if a < b, 0 if equal, 1 if a > b */
14+
function compareSemver(a: [number, number, number], b: [number, number, number]): number {
15+
for (let i = 0; i < 3; i++) {
16+
if (a[i] < b[i]) return -1
17+
if (a[i] > b[i]) return 1
18+
}
19+
return 0
20+
}
21+
22+
/**
23+
* Parse changelog content and extract entries for versions between
24+
* `fromVersion` (exclusive) and `toVersion` (inclusive).
25+
*/
26+
export function extractChangelogFromContent(content: string, fromVersion: string, toVersion: string): string {
27+
try {
28+
if (!content) return ""
29+
30+
const from = parseSemver(fromVersion)
31+
const to = parseSemver(toVersion)
32+
if (!from || !to) return ""
33+
34+
// Split on ## [x.y.z] headings
35+
const sectionRegex = /^## \[([^\]]+)\]/gm
36+
const sections: { version: string; start: number }[] = []
37+
let match: RegExpExecArray | null
38+
while ((match = sectionRegex.exec(content)) !== null) {
39+
sections.push({ version: match[1], start: match.index })
40+
}
41+
42+
if (sections.length === 0) return ""
43+
44+
const lines: string[] = []
45+
for (let i = 0; i < sections.length; i++) {
46+
const ver = parseSemver(sections[i].version)
47+
if (!ver) continue
48+
// Include versions where: from < ver <= to
49+
if (compareSemver(ver, from) > 0 && compareSemver(ver, to) <= 0) {
50+
const end = i + 1 < sections.length ? sections[i + 1].start : content.length
51+
lines.push(content.slice(sections[i].start, end).trimEnd())
52+
}
53+
}
54+
55+
return lines.join("\n\n")
56+
} catch {
57+
return ""
58+
}
59+
}
60+
61+
/**
62+
* Extract changelog entries using the build-time bundled CHANGELOG.md.
63+
*/
64+
export function extractChangelog(fromVersion: string, toVersion: string): string {
65+
const content = typeof ALTIMATE_CLI_CHANGELOG === "string" ? ALTIMATE_CLI_CHANGELOG : ""
66+
return extractChangelogFromContent(content, fromVersion, toVersion)
67+
}

packages/altimate-code/src/cli/cmd/upgrade.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Argv } from "yargs"
22
import { UI } from "../ui"
33
import * as prompts from "@clack/prompts"
44
import { Installation } from "../../installation"
5+
import { extractChangelog } from "../changelog"
56

67
export const UpgradeCommand = {
78
command: "upgrade [target]",
@@ -68,6 +69,12 @@ export const UpgradeCommand = {
6869
return
6970
}
7071
spinner.stop("Upgrade complete")
72+
73+
const changelog = extractChangelog(Installation.VERSION, target)
74+
if (changelog) {
75+
prompts.log.info("What's new:\n\n" + changelog)
76+
}
77+
7178
prompts.outro("Done")
7279
},
7380
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test, expect } from "bun:test"
2+
import { extractChangelogFromContent } from "../../src/cli/changelog"
3+
4+
const sampleChangelog = `# Changelog
5+
6+
## [0.3.0] - 2026-04-01
7+
8+
### Added
9+
10+
- New feature A
11+
12+
## [0.2.2] - 2026-03-05
13+
14+
### Fixed
15+
16+
- Bug fix B
17+
18+
## [0.2.1] - 2026-03-05
19+
20+
### Added
21+
22+
- Feature C
23+
24+
## [0.2.0] - 2026-03-04
25+
26+
### Added
27+
28+
- Feature D
29+
30+
## [0.1.0] - 2025-06-01
31+
32+
### Added
33+
34+
- Initial release
35+
`
36+
37+
test("extracts changelog between two versions", () => {
38+
const result = extractChangelogFromContent(sampleChangelog, "0.2.0", "0.2.2")
39+
expect(result).toContain("## [0.2.2]")
40+
expect(result).toContain("Bug fix B")
41+
expect(result).toContain("## [0.2.1]")
42+
expect(result).toContain("Feature C")
43+
expect(result).not.toContain("## [0.2.0]")
44+
expect(result).not.toContain("## [0.3.0]")
45+
expect(result).not.toContain("## [0.1.0]")
46+
})
47+
48+
test("extracts single version", () => {
49+
const result = extractChangelogFromContent(sampleChangelog, "0.2.1", "0.2.2")
50+
expect(result).toContain("## [0.2.2]")
51+
expect(result).toContain("Bug fix B")
52+
expect(result).not.toContain("## [0.2.1]")
53+
})
54+
55+
test("returns empty string when no versions in range", () => {
56+
const result = extractChangelogFromContent(sampleChangelog, "0.3.0", "0.4.0")
57+
expect(result).toBe("")
58+
})
59+
60+
test("returns empty string for empty content", () => {
61+
expect(extractChangelogFromContent("", "0.1.0", "0.2.0")).toBe("")
62+
})
63+
64+
test("handles v-prefixed versions", () => {
65+
const result = extractChangelogFromContent(sampleChangelog, "v0.2.0", "v0.2.2")
66+
expect(result).toContain("## [0.2.2]")
67+
expect(result).toContain("## [0.2.1]")
68+
})
69+
70+
test("returns empty string for invalid version strings", () => {
71+
expect(extractChangelogFromContent(sampleChangelog, "not-a-version", "0.2.0")).toBe("")
72+
expect(extractChangelogFromContent(sampleChangelog, "0.1.0", "bad")).toBe("")
73+
})
74+
75+
test("works with the real CHANGELOG.md", async () => {
76+
const fs = await import("fs")
77+
const path = await import("path")
78+
const changelogPath = path.resolve(import.meta.dir, "../../../../CHANGELOG.md")
79+
if (!fs.existsSync(changelogPath)) return // skip if not available
80+
81+
const content = fs.readFileSync(changelogPath, "utf-8")
82+
const result = extractChangelogFromContent(content, "0.1.0", "0.2.0")
83+
expect(result).toContain("## [0.2.0]")
84+
expect(result).toContain("Context management")
85+
expect(result).not.toContain("## [0.1.0]")
86+
})

0 commit comments

Comments
 (0)