Skip to content

Commit e881456

Browse files
committed
feat(scripts): add watch-releases to check for new terminal versions
Fetches latest releases from GitHub/Codeberg for tracked terminals and compares with locally probed versions. Supports --json and --update flags. Handles Ghostty's tag-only releases (no GitHub Releases) via tags API.
1 parent 5f28228 commit e881456

1 file changed

Lines changed: 357 additions & 0 deletions

File tree

scripts/watch-releases.ts

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Watch for new releases of tracked terminals.
4+
*
5+
* Reads content/probes-apps/ and content/probes-libs/ to find the latest
6+
* probed version of each terminal, then fetches the latest release from
7+
* GitHub/Codeberg to see if newer versions are available.
8+
*
9+
* Usage:
10+
* bun scripts/watch-releases.ts # Human-readable report
11+
* bun scripts/watch-releases.ts --json # JSON output
12+
* bun scripts/watch-releases.ts --update # Update terminals.json with new versions
13+
*
14+
* Set GITHUB_TOKEN env var for higher rate limits (60 req/hr → 5000 req/hr).
15+
*/
16+
17+
import { readFileSync, readdirSync, writeFileSync } from "node:fs"
18+
import { join, dirname } from "node:path"
19+
import { fileURLToPath } from "node:url"
20+
21+
const __dirname = dirname(fileURLToPath(import.meta.url))
22+
const rootDir = join(__dirname, "..")
23+
const contentDir = join(rootDir, "content")
24+
const terminalsPath = join(contentDir, "terminals.json")
25+
const probesAppsDir = join(contentDir, "probes-apps")
26+
const probesLibsDir = join(contentDir, "probes-libs")
27+
28+
// ---------------------------------------------------------------------------
29+
// Types
30+
// ---------------------------------------------------------------------------
31+
32+
interface ReleaseSource {
33+
terminal: string
34+
label: string
35+
apiUrl: string
36+
type: "github" | "github-tags" | "codeberg"
37+
}
38+
39+
interface ReleaseResult {
40+
terminal: string
41+
label: string
42+
currentVersion: string | null
43+
latestVersion: string | null
44+
latestDate: string | null
45+
isNewer: boolean
46+
error: string | null
47+
}
48+
49+
// ---------------------------------------------------------------------------
50+
// Release sources — terminals with known GitHub/Codeberg repos
51+
// ---------------------------------------------------------------------------
52+
53+
const RELEASE_SOURCES: ReleaseSource[] = [
54+
{
55+
terminal: "kitty",
56+
label: "Kitty",
57+
apiUrl: "https://api.github.com/repos/kovidgoyal/kitty/releases/latest",
58+
type: "github",
59+
},
60+
{
61+
terminal: "ghostty",
62+
label: "Ghostty",
63+
apiUrl: "https://api.github.com/repos/ghostty-org/ghostty/tags?per_page=1",
64+
type: "github-tags",
65+
},
66+
{
67+
terminal: "wezterm",
68+
label: "WezTerm",
69+
apiUrl: "https://api.github.com/repos/wez/wezterm/releases/latest",
70+
type: "github",
71+
},
72+
{
73+
terminal: "foot",
74+
label: "foot",
75+
apiUrl: "https://codeberg.org/api/v1/repos/dnkl/foot/releases?limit=1",
76+
type: "codeberg",
77+
},
78+
{
79+
terminal: "alacritty",
80+
label: "Alacritty",
81+
apiUrl: "https://api.github.com/repos/alacritty/alacritty/releases/latest",
82+
type: "github",
83+
},
84+
{
85+
terminal: "com.microsoft.terminal",
86+
label: "Windows Terminal",
87+
apiUrl: "https://api.github.com/repos/microsoft/terminal/releases/latest",
88+
type: "github",
89+
},
90+
{
91+
terminal: "mintty",
92+
label: "mintty",
93+
apiUrl: "https://api.github.com/repos/mintty/mintty/releases/latest",
94+
type: "github",
95+
},
96+
{
97+
terminal: "contour",
98+
label: "Contour",
99+
apiUrl: "https://api.github.com/repos/contour-terminal/contour/releases/latest",
100+
type: "github",
101+
},
102+
]
103+
104+
// ---------------------------------------------------------------------------
105+
// Helpers
106+
// ---------------------------------------------------------------------------
107+
108+
/** Strip leading "v" from version tags (e.g. "v1.3.1" → "1.3.1"). */
109+
function normalizeVersion(tag: string): string {
110+
return tag.replace(/^v/, "")
111+
}
112+
113+
/**
114+
* Compare two semver-ish version strings.
115+
* Returns: -1 if a < b, 0 if equal, 1 if a > b.
116+
* Handles formats like "1.3.1", "0.46.2", "1.22.10.0".
117+
*/
118+
function compareVersions(a: string, b: string): number {
119+
const pa = a.split(/[.-]/).map((s) => (/^\d+$/.test(s) ? Number(s) : s))
120+
const pb = b.split(/[.-]/).map((s) => (/^\d+$/.test(s) ? Number(s) : s))
121+
const len = Math.max(pa.length, pb.length)
122+
for (let i = 0; i < len; i++) {
123+
const va = pa[i] ?? 0
124+
const vb = pb[i] ?? 0
125+
if (typeof va === "number" && typeof vb === "number") {
126+
if (va < vb) return -1
127+
if (va > vb) return 1
128+
} else {
129+
const sa = String(va)
130+
const sb = String(vb)
131+
if (sa < sb) return -1
132+
if (sa > sb) return 1
133+
}
134+
}
135+
return 0
136+
}
137+
138+
/**
139+
* Find the latest probed version for a terminal by scanning probe result files.
140+
* Checks both probes-apps/ and probes-libs/ directories.
141+
*/
142+
function findCurrentVersion(terminalId: string): string | null {
143+
const versions: string[] = []
144+
145+
for (const dir of [probesAppsDir, probesLibsDir]) {
146+
let files: string[]
147+
try {
148+
files = readdirSync(dir)
149+
} catch {
150+
continue
151+
}
152+
for (const file of files) {
153+
if (!file.endsWith(".json")) continue
154+
// File format: terminal-version-platform.json or terminal-version.json
155+
if (!file.startsWith(terminalId + "-")) continue
156+
try {
157+
const data = JSON.parse(readFileSync(join(dir, file), "utf-8"))
158+
if (data.terminalVersion) {
159+
versions.push(data.terminalVersion)
160+
}
161+
} catch {
162+
// Skip unparseable files
163+
}
164+
}
165+
}
166+
167+
if (versions.length === 0) return null
168+
169+
// Return the highest version
170+
versions.sort(compareVersions)
171+
return versions[versions.length - 1]!
172+
}
173+
174+
/**
175+
* Fetch the latest release from a GitHub or Codeberg API endpoint.
176+
*/
177+
async function fetchLatestRelease(
178+
source: ReleaseSource,
179+
): Promise<{ version: string; date: string }> {
180+
const headers: Record<string, string> = {
181+
Accept: "application/json",
182+
"User-Agent": "terminfo.dev/watch-releases",
183+
}
184+
185+
const token = process.env.GITHUB_TOKEN
186+
if (token && (source.type === "github" || source.type === "github-tags")) {
187+
headers.Authorization = `Bearer ${token}`
188+
}
189+
190+
const res = await fetch(source.apiUrl, { headers })
191+
192+
if (res.status === 403 || res.status === 429) {
193+
const reset = res.headers.get("x-ratelimit-reset")
194+
const resetIn = reset ? Math.ceil((Number(reset) * 1000 - Date.now()) / 60000) : "?"
195+
throw new Error(`Rate limited (resets in ~${resetIn} min). Set GITHUB_TOKEN for higher limits.`)
196+
}
197+
198+
if (!res.ok) {
199+
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
200+
}
201+
202+
const data = await res.json()
203+
204+
if (source.type === "github-tags") {
205+
// Tags API returns an array of {name, ...} — no date info
206+
const tags = Array.isArray(data) ? data : [data]
207+
if (tags.length === 0) throw new Error("No tags found")
208+
return {
209+
version: normalizeVersion(tags[0].name),
210+
date: "", // Tags don't carry date info
211+
}
212+
}
213+
214+
if (source.type === "codeberg") {
215+
// Codeberg returns an array
216+
const release = Array.isArray(data) ? data[0] : data
217+
if (!release) throw new Error("No releases found")
218+
return {
219+
version: normalizeVersion(release.tag_name),
220+
date: release.published_at ?? release.created_at,
221+
}
222+
}
223+
224+
// GitHub returns a single object for /releases/latest
225+
return {
226+
version: normalizeVersion(data.tag_name),
227+
date: data.published_at ?? data.created_at,
228+
}
229+
}
230+
231+
// ---------------------------------------------------------------------------
232+
// Main
233+
// ---------------------------------------------------------------------------
234+
235+
async function main() {
236+
const args = process.argv.slice(2)
237+
const jsonOutput = args.includes("--json")
238+
const updateMode = args.includes("--update")
239+
240+
const results: ReleaseResult[] = []
241+
242+
// Fetch all releases in parallel
243+
const promises = RELEASE_SOURCES.map(async (source): Promise<ReleaseResult> => {
244+
const currentVersion = findCurrentVersion(source.terminal)
245+
246+
try {
247+
const { version: latestVersion, date } = await fetchLatestRelease(source)
248+
const isNewer =
249+
currentVersion !== null ? compareVersions(currentVersion, latestVersion) < 0 : false
250+
251+
return {
252+
terminal: source.terminal,
253+
label: source.label,
254+
currentVersion,
255+
latestVersion,
256+
latestDate: date,
257+
isNewer,
258+
error: null,
259+
}
260+
} catch (err) {
261+
return {
262+
terminal: source.terminal,
263+
label: source.label,
264+
currentVersion,
265+
latestVersion: null,
266+
latestDate: null,
267+
isNewer: false,
268+
error: err instanceof Error ? err.message : String(err),
269+
}
270+
}
271+
})
272+
273+
results.push(...(await Promise.all(promises)))
274+
275+
// --json output
276+
if (jsonOutput) {
277+
console.log(JSON.stringify(results, null, 2))
278+
return
279+
}
280+
281+
// Human-readable output
282+
const today = new Date().toISOString().slice(0, 10)
283+
console.log()
284+
console.log(` Release Watch — ${today}`)
285+
console.log()
286+
287+
const labelWidth = Math.max(...results.map((r) => r.label.length))
288+
const curWidth = Math.max(
289+
...results.map((r) => (r.currentVersion ?? "unknown").length),
290+
"current:".length,
291+
)
292+
const latWidth = Math.max(
293+
...results.map((r) => (r.latestVersion ?? "error").length),
294+
"latest:".length,
295+
)
296+
297+
let hasNew = false
298+
for (const r of results) {
299+
const label = r.label.padEnd(labelWidth)
300+
const cur = (r.currentVersion ?? "unknown").padEnd(curWidth)
301+
const lat = (r.latestVersion ?? "error").padEnd(latWidth)
302+
303+
let status: string
304+
if (r.error) {
305+
status = `⚠ ${r.error}`
306+
} else if (r.currentVersion === null) {
307+
status = ` (not tracked locally)`
308+
} else if (r.isNewer) {
309+
status = `← NEW`
310+
hasNew = true
311+
} else {
312+
status = `✓ up to date`
313+
}
314+
315+
console.log(` ${label} current: ${cur} latest: ${lat} ${status}`)
316+
}
317+
318+
console.log()
319+
320+
// --update: write new versions into terminals.json
321+
if (updateMode) {
322+
const newReleases = results.filter((r) => r.isNewer && r.latestVersion)
323+
if (newReleases.length === 0) {
324+
console.log(" Nothing to update — all tracked terminals are current.")
325+
console.log()
326+
return
327+
}
328+
329+
const raw = readFileSync(terminalsPath, "utf-8")
330+
const terminals = JSON.parse(raw)
331+
332+
for (const r of newReleases) {
333+
if (terminals[r.terminal]) {
334+
terminals[r.terminal].latestRelease = {
335+
version: r.latestVersion,
336+
date: r.latestDate?.slice(0, 10) ?? null,
337+
checkedAt: new Date().toISOString(),
338+
}
339+
}
340+
}
341+
342+
writeFileSync(terminalsPath, JSON.stringify(terminals, null, 2) + "\n")
343+
console.log(` Updated terminals.json with ${newReleases.length} new version(s):`)
344+
for (const r of newReleases) {
345+
console.log(` ${r.label}: ${r.currentVersion}${r.latestVersion}`)
346+
}
347+
console.log()
348+
} else if (hasNew) {
349+
console.log(" Run with --update to write new versions to terminals.json")
350+
console.log()
351+
}
352+
}
353+
354+
main().catch((err) => {
355+
console.error("Fatal:", err)
356+
process.exit(1)
357+
})

0 commit comments

Comments
 (0)