@brainjar/cli 0.6.3 → 0.6.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainjar/cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.ts CHANGED
@@ -153,6 +153,7 @@ export async function createClient(options?: ClientOptions): Promise<BrainjarCli
153
153
  throw new IncurError({ code, message, hint })
154
154
  }
155
155
 
156
+ if (response.status === 204) return undefined as T
156
157
  return response.json() as Promise<T>
157
158
  }
158
159
 
@@ -307,8 +307,9 @@ export const persona = Cli.create('persona', {
307
307
  const name = normalizeSlug(c.args.name, 'persona name')
308
308
  const api = await getApi()
309
309
 
310
+ let result: { affected_brains?: string[] } | undefined
310
311
  try {
311
- await api.delete(`/api/v1/personas/${name}`)
312
+ result = await api.delete<{ affected_brains?: string[] }>(`/api/v1/personas/${name}`)
312
313
  } catch (e) {
313
314
  if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
314
315
  throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
@@ -320,7 +321,9 @@ export const persona = Cli.create('persona', {
320
321
  const state = await getEffectiveState(api)
321
322
  if (state.persona === name) await sync({ api })
322
323
 
323
- return { deleted: name }
324
+ const out: Record<string, unknown> = { deleted: name }
325
+ if (result?.affected_brains?.length) out.affected_brains = result.affected_brains
326
+ return out
324
327
  },
325
328
  })
326
329
  .command('drop', {
@@ -247,8 +247,9 @@ export const rules = Cli.create('rules', {
247
247
  const name = normalizeSlug(c.args.name, 'rule name')
248
248
  const api = await getApi()
249
249
 
250
+ let result: { affected_brains?: string[] } | undefined
250
251
  try {
251
- await api.delete(`/api/v1/rules/${name}`)
252
+ result = await api.delete<{ affected_brains?: string[] }>(`/api/v1/rules/${name}`)
252
253
  } catch (e) {
253
254
  if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
254
255
  throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
@@ -260,7 +261,9 @@ export const rules = Cli.create('rules', {
260
261
  const state = await getEffectiveState(api)
261
262
  if (state.rules.includes(name)) await sync({ api })
262
263
 
263
- return { deleted: name }
264
+ const out: Record<string, unknown> = { deleted: name }
265
+ if (result?.affected_brains?.length) out.affected_brains = result.affected_brains
266
+ return out
264
267
  },
265
268
  })
266
269
  .command('drop', {
@@ -264,8 +264,9 @@ export const soul = Cli.create('soul', {
264
264
  const name = normalizeSlug(c.args.name, 'soul name')
265
265
  const api = await getApi()
266
266
 
267
+ let result: { affected_brains?: string[] } | undefined
267
268
  try {
268
- await api.delete(`/api/v1/souls/${name}`)
269
+ result = await api.delete<{ affected_brains?: string[] }>(`/api/v1/souls/${name}`)
269
270
  } catch (e) {
270
271
  if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
271
272
  throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
@@ -277,7 +278,9 @@ export const soul = Cli.create('soul', {
277
278
  const state = await getEffectiveState(api)
278
279
  if (state.soul === name) await sync({ api })
279
280
 
280
- return { deleted: name }
281
+ const out: Record<string, unknown> = { deleted: name }
282
+ if (result?.affected_brains?.length) out.affected_brains = result.affected_brains
283
+ return out
281
284
  },
282
285
  })
283
286
  .command('drop', {
package/src/daemon.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFileSync } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
- import { readFile, writeFile, rm, access, open, chmod, mkdir, constants } from 'node:fs/promises'
3
+ import { readFile, writeFile, rm, access, open, chmod, mkdir, rename, copyFile, constants } from 'node:fs/promises'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { Errors } from 'incur'
@@ -31,7 +31,7 @@ const { IncurError } = Errors
31
31
  * Minimum server version this CLI is compatible with.
32
32
  * Bump when the CLI depends on server features/API changes.
33
33
  */
34
- export const MIN_SERVER_VERSION = '0.2.4'
34
+ export const MIN_SERVER_VERSION = '0.2.7'
35
35
 
36
36
  export interface HealthStatus {
37
37
  healthy: boolean
@@ -229,9 +229,17 @@ export async function downloadAndVerify(binPath: string, versionBase: string): P
229
229
  })
230
230
  }
231
231
 
232
- const binContent = await readFile(extractedBin)
233
- await writeFile(binPath, binContent)
234
- await chmod(binPath, 0o755)
232
+ // Avoid ETXTBSY: unlink the old binary first (running process
233
+ // keeps its inode), then move the new one into place.
234
+ await chmod(extractedBin, 0o755)
235
+ await rm(binPath, { force: true })
236
+ try {
237
+ await rename(extractedBin, binPath)
238
+ } catch {
239
+ // Cross-device rename (tmpdir on different fs) — fall back to copy
240
+ await copyFile(extractedBin, binPath)
241
+ await chmod(binPath, 0o755)
242
+ }
235
243
  } finally {
236
244
  await rm(tmpDir, { recursive: true, force: true })
237
245
  }
@@ -275,15 +283,17 @@ export async function upgradeServer(): Promise<{ version: string; alreadyLatest:
275
283
  return { version, alreadyLatest: true }
276
284
  }
277
285
 
278
- // Stop server before replacing binary to avoid ETXTBSY on Linux
279
- const pid = await readPid(localContext(config).pid_file)
280
- if (pid !== null && isAlive(pid)) {
281
- await stop()
282
- }
283
-
286
+ // Binary replacement uses rm + rename/copy which avoids ETXTBSY.
287
+ // The running server keeps its old inode; next restart picks up the new binary.
284
288
  const versionBase = `${DIST_BASE}/${version}`
285
289
  await downloadAndVerify(binPath, versionBase)
286
290
  await setInstalledServerVersion(version)
291
+
292
+ // Restart the server on the new binary
293
+ await stop()
294
+ await new Promise(r => setTimeout(r, 1000))
295
+ await start()
296
+
287
297
  return { version, alreadyLatest: false }
288
298
  }
289
299
 
@@ -351,7 +361,14 @@ export async function stop(): Promise<{ stopped: boolean }> {
351
361
  return { stopped: false }
352
362
  }
353
363
 
354
- process.kill(pid, 'SIGTERM')
364
+ // Kill entire process group (negative pid) so child processes
365
+ // like embedded postgres are also terminated.
366
+ try {
367
+ process.kill(-pid, 'SIGTERM')
368
+ } catch {
369
+ // Process group doesn't exist — try single process
370
+ try { process.kill(pid, 'SIGTERM') } catch {}
371
+ }
355
372
 
356
373
  // Poll for exit, up to 5s
357
374
  const deadline = Date.now() + 5000
@@ -363,10 +380,15 @@ export async function stop(): Promise<{ stopped: boolean }> {
363
380
  }
364
381
  }
365
382
 
366
- // Force kill
383
+ // Force kill entire process group
367
384
  try {
368
- process.kill(pid, 'SIGKILL')
369
- } catch {}
385
+ process.kill(-pid, 'SIGKILL')
386
+ } catch {
387
+ try { process.kill(pid, 'SIGKILL') } catch {}
388
+ }
389
+
390
+ // Wait briefly for SIGKILL to take effect
391
+ await new Promise(r => setTimeout(r, 500))
370
392
  await rm(pid_file, { force: true })
371
393
  return { stopped: true }
372
394
  }