Skip to content

Commit 5313c3e

Browse files
committed
feat(updates): implement custom update checker with caching
Add new update checker module that replaces update-notifier dependency. The implementation includes: - Background update checks with 24-hour cache - NPM registry version comparison - Environment variable controls - Synchronous status check for TUI
1 parent d452fc8 commit 5313c3e

5 files changed

Lines changed: 143 additions & 22 deletions

File tree

‎src/cli/tui/shared/context/update-notifier.tsx‎

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
/** @jsxImportSource @opentui/solid */
22
import { createSignal, onMount } from "solid-js"
3-
import updateNotifier from "update-notifier"
43
import { createSimpleContext } from "./helper"
5-
import { createRequire } from "node:module"
6-
import { resolvePackageJson } from "../../../../shared/runtime/root.js"
4+
import { status } from "../../../../shared/updates/index.js"
75

86
export const { use: useUpdateNotifier, provider: UpdateNotifierProvider } = createSimpleContext({
97
name: "UpdateNotifier",
@@ -13,25 +11,10 @@ export const { use: useUpdateNotifier, provider: UpdateNotifierProvider } = crea
1311
const [latestVersion, setLatestVersion] = createSignal("")
1412

1513
onMount(() => {
16-
// Check for updates (respects NO_UPDATE_NOTIFIER and CI environments)
17-
if (!process.env.NO_UPDATE_NOTIFIER && !process.env.CODEMACHINE_NO_UPDATE_CHECK) {
18-
try {
19-
const require = createRequire(import.meta.url)
20-
const packageJsonPath = resolvePackageJson(import.meta.url, "update notifier context")
21-
const pkg = require(packageJsonPath) as { version: string; name: string }
22-
23-
const notifier = updateNotifier({
24-
pkg,
25-
updateCheckInterval: 1000 * 60 * 60 * 24, // Check once per day
26-
})
27-
28-
if (notifier.update) {
29-
setUpdateAvailable(true)
30-
setLatestVersion(notifier.update.latest)
31-
}
32-
} catch {
33-
// Silently fail if update check errors (offline, network issues, etc.)
34-
}
14+
const cache = status()
15+
if (cache && cache.available && cache.latest) {
16+
setUpdateAvailable(true)
17+
setLatestVersion(cache.latest)
3518
}
3619
setReady(true)
3720
})

‎src/runtime/cli-setup.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ const DEFAULT_SPEC_PATH = '.codemachine/inputs/specifications.md';
7676
* Note: .codemachine folder initialization is handled by workflow run, not here
7777
*/
7878
async function initializeInBackground(cwd: string): Promise<void> {
79+
// Check for updates (writes to ~/.codemachine/resources/updates.json)
80+
appDebug('[Init] Checking for updates');
81+
const { check } = await import('../shared/updates/index.js');
82+
check().catch(err => appDebug('[Init] Update check error: %s', err));
83+
7984
const cmRoot = path.join(cwd, '.codemachine');
8085

8186
// Only bootstrap if .codemachine doesn't exist

‎src/shared/updates/checker.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { homedir } from "os"
2+
import { join } from "path"
3+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs"
4+
import { createRequire } from "node:module"
5+
import { resolvePackageJson } from "../runtime/root.js"
6+
import { appDebug } from "../logging/logger.js"
7+
import type { UpdateCache } from "./types.js"
8+
9+
function debug(message: string, ...args: unknown[]) {
10+
appDebug(`[UpdateChecker] ${message}`, ...args)
11+
}
12+
13+
const CACHE_DIR = join(homedir(), ".codemachine", "resources")
14+
const CACHE_PATH = join(CACHE_DIR, "updates.json")
15+
const CHECK_INTERVAL = 1000 * 60 * 60 * 24 // 24 hours
16+
17+
function getPackageInfo(): { name: string; version: string } {
18+
const require = createRequire(import.meta.url)
19+
const packageJsonPath = resolvePackageJson(import.meta.url, "update checker")
20+
return require(packageJsonPath) as { name: string; version: string }
21+
}
22+
23+
function readCache(): UpdateCache | null {
24+
try {
25+
if (!existsSync(CACHE_PATH)) {
26+
debug("Cache file does not exist")
27+
return null
28+
}
29+
const data = readFileSync(CACHE_PATH, "utf-8")
30+
const cache = JSON.parse(data) as UpdateCache
31+
debug("Read cache:", cache)
32+
return cache
33+
} catch (error) {
34+
debug("Failed to read cache:", error)
35+
return null
36+
}
37+
}
38+
39+
function writeCache(cache: UpdateCache): void {
40+
try {
41+
mkdirSync(CACHE_DIR, { recursive: true })
42+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2))
43+
debug("Wrote cache:", cache)
44+
} catch (error) {
45+
debug("Failed to write cache:", error)
46+
}
47+
}
48+
49+
async function fetchLatestVersion(packageName: string): Promise<string | null> {
50+
try {
51+
debug("Fetching latest version for:", packageName)
52+
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
53+
headers: { Accept: "application/json" },
54+
signal: AbortSignal.timeout(10000),
55+
})
56+
if (!response.ok) {
57+
debug("Registry response not ok:", response.status)
58+
return null
59+
}
60+
const data = (await response.json()) as { version: string }
61+
debug("Latest version:", data.version)
62+
return data.version
63+
} catch (error) {
64+
debug("Failed to fetch latest version:", error)
65+
return null
66+
}
67+
}
68+
69+
function compareVersions(current: string, latest: string): boolean {
70+
const currentParts = current.split(".").map(Number)
71+
const latestParts = latest.split(".").map(Number)
72+
73+
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
74+
const c = currentParts[i] || 0
75+
const l = latestParts[i] || 0
76+
if (l > c) return true
77+
if (l < c) return false
78+
}
79+
return false
80+
}
81+
82+
/**
83+
* Check for updates. Spawns background check if cache is stale.
84+
* Safe to call on every CLI startup - returns immediately if cache is fresh.
85+
*/
86+
export async function check(): Promise<void> {
87+
debug("check() called")
88+
89+
if (process.env.NO_UPDATE_NOTIFIER || process.env.CODEMACHINE_NO_UPDATE_CHECK) {
90+
debug("Update check disabled by environment variable")
91+
return
92+
}
93+
94+
const cache = readCache()
95+
const now = Date.now()
96+
97+
if (cache && now - cache.checked < CHECK_INTERVAL) {
98+
debug("Cache is fresh, skipping check")
99+
return
100+
}
101+
102+
debug("Cache is stale or missing, performing check")
103+
104+
const pkg = getPackageInfo()
105+
const latest = await fetchLatestVersion(pkg.name)
106+
107+
const newCache: UpdateCache = {
108+
current: pkg.version,
109+
latest,
110+
available: latest ? compareVersions(pkg.version, latest) : false,
111+
checked: now,
112+
}
113+
114+
writeCache(newCache)
115+
debug("Check complete:", newCache)
116+
}
117+
118+
/**
119+
* Get cached update status. Returns null if no cache exists.
120+
* This is synchronous and safe to call from TUI render.
121+
*/
122+
export function status(): UpdateCache | null {
123+
debug("status() called")
124+
return readCache()
125+
}

‎src/shared/updates/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { check, status } from "./checker.js"
2+
export type { UpdateCache } from "./types.js"

‎src/shared/updates/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface UpdateCache {
2+
current: string
3+
latest: string | null
4+
available: boolean
5+
checked: number
6+
}

0 commit comments

Comments
 (0)