claude-brain 0.22.4 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Auto-updater for claude-brain.
3
+ * Checks npm for new versions periodically, auto-updates with ghost process cleanup and fresh restart.
4
+ */
5
+
6
+ import { execSync, spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
8
+ import { join, dirname, resolve } from 'node:path'
9
+ import { homedir, platform } from 'node:os'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const __filename = fileURLToPath(import.meta.url)
13
+ const __dirname = dirname(__filename)
14
+ const PACKAGE_ROOT = resolve(__dirname, '..', '..')
15
+
16
+ interface UpdateCheckResult {
17
+ currentVersion: string
18
+ latestVersion: string | null
19
+ updateAvailable: boolean
20
+ lastChecked: string
21
+ }
22
+
23
+ interface AutoUpdateConfig {
24
+ enabled: boolean
25
+ checkIntervalHours: number
26
+ autoRestart: boolean
27
+ }
28
+
29
+ const DEFAULT_CONFIG: AutoUpdateConfig = {
30
+ enabled: true,
31
+ checkIntervalHours: 24,
32
+ autoRestart: true,
33
+ }
34
+
35
+ const isWindows = platform() === 'win32'
36
+
37
+ export class AutoUpdater {
38
+ private dataDir: string
39
+ private checkFile: string
40
+ private config: AutoUpdateConfig
41
+ private timer: ReturnType<typeof setInterval> | null = null
42
+ private logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void }
43
+
44
+ constructor(config?: Partial<AutoUpdateConfig>, logger?: any) {
45
+ this.config = { ...DEFAULT_CONFIG, ...config }
46
+ this.dataDir = join(homedir(), '.claude-brain', 'data')
47
+ this.checkFile = join(this.dataDir, 'update-check.json')
48
+ this.logger = logger ?? {
49
+ info: (...args: any[]) => console.error('[auto-updater] INFO:', ...args),
50
+ warn: (...args: any[]) => console.error('[auto-updater] WARN:', ...args),
51
+ error: (...args: any[]) => console.error('[auto-updater] ERROR:', ...args),
52
+ }
53
+ }
54
+
55
+ /** Check npm registry for latest version */
56
+ async check(): Promise<UpdateCheckResult> {
57
+ const currentVersion = this.getCurrentVersion()
58
+
59
+ // Return cached result if checked recently
60
+ if (!this.shouldCheck()) {
61
+ try {
62
+ const cached = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
63
+ return { ...cached, currentVersion }
64
+ } catch {
65
+ // Corrupted cache, proceed with fresh check
66
+ }
67
+ }
68
+
69
+ let latestVersion: string | null = null
70
+ try {
71
+ const result = execSync('npm view claude-brain version', {
72
+ encoding: 'utf-8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ timeout: 15_000,
75
+ })
76
+ latestVersion = result.trim()
77
+ } catch {
78
+ this.logger.warn('Failed to check npm registry for latest version')
79
+ }
80
+
81
+ const updateAvailable = latestVersion !== null && latestVersion !== currentVersion
82
+ const checkResult: UpdateCheckResult = {
83
+ currentVersion,
84
+ latestVersion,
85
+ updateAvailable,
86
+ lastChecked: new Date().toISOString(),
87
+ }
88
+
89
+ // Persist check result
90
+ try {
91
+ mkdirSync(this.dataDir, { recursive: true })
92
+ writeFileSync(this.checkFile, JSON.stringify(checkResult, null, 2), 'utf-8')
93
+ } catch (err) {
94
+ this.logger.warn('Failed to write update check file:', err)
95
+ }
96
+
97
+ if (updateAvailable) {
98
+ this.logger.info(`Update available: v${currentVersion} -> v${latestVersion}`)
99
+ }
100
+
101
+ return checkResult
102
+ }
103
+
104
+ /** Perform the update: kill ghosts -> update package -> restart */
105
+ async performUpdate(targetVersion: string): Promise<boolean> {
106
+ try {
107
+ this.logger.info(`Updating claude-brain to v${targetVersion}...`)
108
+
109
+ // Kill ghost processes
110
+ this.killGhostProcesses()
111
+
112
+ // Wait for ports to release
113
+ await this.sleep(2000)
114
+
115
+ // Try bun first, fallback to npm
116
+ try {
117
+ execSync('bun install -g claude-brain@latest', {
118
+ encoding: 'utf-8',
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ timeout: 120_000,
121
+ })
122
+ this.logger.info(`Updated to v${targetVersion} via bun`)
123
+ } catch {
124
+ try {
125
+ execSync('npm install -g claude-brain@latest', {
126
+ encoding: 'utf-8',
127
+ stdio: ['pipe', 'pipe', 'pipe'],
128
+ timeout: 120_000,
129
+ })
130
+ this.logger.info(`Updated to v${targetVersion} via npm`)
131
+ } catch (err) {
132
+ this.logger.error('Install failed with both bun and npm:', err)
133
+ return false
134
+ }
135
+ }
136
+
137
+ if (this.config.autoRestart) {
138
+ this.restartServer()
139
+ }
140
+
141
+ return true
142
+ } catch (err) {
143
+ this.logger.error('Update failed:', err)
144
+ return false
145
+ }
146
+ }
147
+
148
+ /** Schedule periodic background checks */
149
+ schedulePeriodicCheck(): void {
150
+ if (!this.config.enabled) return
151
+ if (this.timer) return
152
+
153
+ const intervalMs = this.config.checkIntervalHours * 3600 * 1000
154
+
155
+ this.timer = setInterval(async () => {
156
+ try {
157
+ const result = await this.check()
158
+ if (result.updateAvailable && result.latestVersion && this.config.enabled) {
159
+ await this.performUpdate(result.latestVersion)
160
+ }
161
+ } catch (err) {
162
+ this.logger.error('Periodic update check failed:', err)
163
+ }
164
+ }, intervalMs)
165
+
166
+ // Don't keep the process alive just for the update timer
167
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
168
+ this.timer.unref()
169
+ }
170
+
171
+ this.logger.info(`Scheduled update checks every ${this.config.checkIntervalHours}h`)
172
+ }
173
+
174
+ /** Stop periodic checks */
175
+ stopPeriodicCheck(): void {
176
+ if (this.timer) {
177
+ clearInterval(this.timer)
178
+ this.timer = null
179
+ }
180
+ }
181
+
182
+ /** Check if enough time has passed since last check */
183
+ private shouldCheck(): boolean {
184
+ if (!existsSync(this.checkFile)) return true
185
+
186
+ try {
187
+ const data = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
188
+ if (!data.lastChecked) return true
189
+
190
+ const lastChecked = new Date(data.lastChecked).getTime()
191
+ const intervalMs = this.config.checkIntervalHours * 3600 * 1000
192
+ return Date.now() - lastChecked >= intervalMs
193
+ } catch {
194
+ return true
195
+ }
196
+ }
197
+
198
+ /** Kill all ghost claude-brain processes */
199
+ private killGhostProcesses(): void {
200
+ const myPid = process.pid
201
+
202
+ // Kill by pattern
203
+ try {
204
+ if (isWindows) {
205
+ const result = execSync(
206
+ `wmic process where "commandline like '%claude-brain%' and processid != ${myPid}" get processid /format:list`,
207
+ { encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }
208
+ )
209
+ const pids = result.match(/ProcessId=(\d+)/g)?.map(m => m.split('=')[1]) || []
210
+ for (const pid of pids) {
211
+ try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
212
+ }
213
+ } else {
214
+ const result = execSync(`pgrep -f "claude-brain"`, {
215
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
216
+ })
217
+ const pids = result.trim().split('\n').filter(p => p && Number(p) !== myPid)
218
+ for (const pid of pids) {
219
+ try { process.kill(Number(pid), 'SIGKILL') } catch {}
220
+ }
221
+ }
222
+ } catch {
223
+ // No matching processes — that's fine
224
+ }
225
+
226
+ // Kill by port 3000
227
+ try {
228
+ if (isWindows) {
229
+ const result = execSync(`netstat -ano | findstr :3000 | findstr LISTENING`, {
230
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
231
+ })
232
+ const pids = new Set(
233
+ result.split('\n')
234
+ .map(line => line.trim().split(/\s+/).pop())
235
+ .filter(p => p && Number(p) !== myPid)
236
+ )
237
+ for (const pid of pids) {
238
+ try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
239
+ }
240
+ } else {
241
+ const raw = execSync(`lsof -ti :3000`, {
242
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
243
+ }).trim()
244
+ if (raw) {
245
+ const pids = raw.split('\n').filter(p => p && Number(p) !== myPid)
246
+ for (const pid of pids) {
247
+ try { process.kill(Number(pid), 'SIGKILL') } catch {}
248
+ }
249
+ }
250
+ }
251
+ } catch {
252
+ // No process on port — that's fine
253
+ }
254
+
255
+ // Clean up stale PID files
256
+ const pidPath = join(this.dataDir, 'server.pid')
257
+ if (existsSync(pidPath)) {
258
+ try {
259
+ const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10)
260
+ if (!isNaN(pid) && pid !== myPid) {
261
+ try { process.kill(pid, 0) } catch {
262
+ // Process not running, remove stale PID file
263
+ try { unlinkSync(pidPath) } catch {}
264
+ }
265
+ }
266
+ } catch {}
267
+ }
268
+ }
269
+
270
+ /** Spawn a fresh server instance and exit current */
271
+ private restartServer(): void {
272
+ this.logger.info('Restarting server with updated version...')
273
+
274
+ const child = spawn('claude-brain', ['serve'], {
275
+ detached: true,
276
+ stdio: 'ignore',
277
+ })
278
+
279
+ child.unref()
280
+
281
+ this.logger.info(`Spawned new server process (PID: ${child.pid}). Exiting current process.`)
282
+ process.exit(0)
283
+ }
284
+
285
+ /** Get current installed version from package.json */
286
+ private getCurrentVersion(): string {
287
+ try {
288
+ // Use the version from the package that's actually running
289
+ const pkgPath = join(PACKAGE_ROOT, 'package.json')
290
+ if (existsSync(pkgPath)) {
291
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
292
+ return pkg.version || 'unknown'
293
+ }
294
+ } catch {}
295
+ return 'unknown'
296
+ }
297
+
298
+ private sleep(ms: number): Promise<void> {
299
+ return new Promise(resolve => setTimeout(resolve, ms))
300
+ }
301
+ }
@@ -1065,8 +1065,17 @@ export class HttpApiServer {
1065
1065
  port,
1066
1066
  url: `http://localhost:${port}`,
1067
1067
  }, 'HTTP API server started successfully')
1068
- } catch (error) {
1069
- this.logger.error({ error }, 'Failed to start HTTP API server')
1068
+ } catch (error: any) {
1069
+ const code = error?.code || error?.message || ''
1070
+ if (String(code).includes('EADDRINUSE') || String(error?.message).includes('EADDRINUSE')) {
1071
+ this.logger.error(
1072
+ { port },
1073
+ `Port ${port} is already in use. Another claude-brain instance may be running. ` +
1074
+ `Kill existing instances with: pkill -f "claude-brain serve"`
1075
+ )
1076
+ } else {
1077
+ this.logger.error({ error }, 'Failed to start HTTP API server')
1078
+ }
1070
1079
  throw error
1071
1080
  }
1072
1081
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * PID file manager for the Claude Brain server.
3
+ * Ensures only one server instance runs at a time.
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { getHomePaths } from '@/config/home'
9
+
10
+ const PID_FILENAME = 'server.pid'
11
+
12
+ export class ServerPidManager {
13
+ private pidFilePath: string
14
+
15
+ constructor() {
16
+ const paths = getHomePaths()
17
+ this.pidFilePath = join(paths.data, PID_FILENAME)
18
+ }
19
+
20
+ /** Check if another server instance is already running. Returns the PID if alive, null otherwise. */
21
+ getRunningPid(): number | null {
22
+ if (!existsSync(this.pidFilePath)) return null
23
+
24
+ try {
25
+ const pid = parseInt(readFileSync(this.pidFilePath, 'utf-8').trim(), 10)
26
+ if (isNaN(pid)) {
27
+ this.cleanup()
28
+ return null
29
+ }
30
+ // Signal 0 tests if process exists without killing it
31
+ process.kill(pid, 0)
32
+ return pid
33
+ } catch {
34
+ // Process not running, clean up stale PID file
35
+ this.cleanup()
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** Write the current process PID to the PID file. */
41
+ writePidFile(): void {
42
+ writeFileSync(this.pidFilePath, String(process.pid), 'utf-8')
43
+ }
44
+
45
+ /** Remove the PID file. Safe to call multiple times. */
46
+ cleanup(): void {
47
+ try {
48
+ unlinkSync(this.pidFilePath)
49
+ } catch {
50
+ // Already removed or doesn't exist
51
+ }
52
+ }
53
+
54
+ /** Register cleanup handlers on SIGINT, SIGTERM, and process exit. */
55
+ registerCleanupHandlers(): void {
56
+ const doCleanup = () => {
57
+ this.cleanup()
58
+ }
59
+
60
+ process.on('exit', doCleanup)
61
+ process.on('SIGINT', doCleanup)
62
+ process.on('SIGTERM', doCleanup)
63
+ }
64
+ }