@brainjar/cli 0.5.1 → 0.5.2

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.5.1",
3
+ "version": "0.5.2",
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/api-types.ts CHANGED
@@ -63,7 +63,7 @@ export interface ApiEffectiveState {
63
63
  rules: string[]
64
64
  }
65
65
 
66
- /** State override at a single scope, returned by GET /api/v1/state/override. */
66
+ /** State override at a single scope. */
67
67
  export interface ApiStateOverride {
68
68
  soul_slug?: string | null
69
69
  persona_slug?: string | null
@@ -72,6 +72,12 @@ export interface ApiStateOverride {
72
72
  rules_to_remove?: string[]
73
73
  }
74
74
 
75
+ /** Envelope returned by GET /api/v1/state/override. */
76
+ export interface ApiStateOverrideResponse {
77
+ override: ApiStateOverride | null
78
+ scope: { id: string; scope_type: string; reference_id: string; parent_scope_id?: string } | null
79
+ }
80
+
75
81
  /** Body for PUT /api/v1/state — partial update. */
76
82
  export interface ApiStateMutation {
77
83
  soul_slug?: string | null
package/src/client.ts CHANGED
@@ -19,7 +19,8 @@ export interface ClientOptions {
19
19
  export interface RequestOptions {
20
20
  timeout?: number
21
21
  headers?: Record<string, string>
22
- project?: string
22
+ /** Pass a project name to scope to that project, null to suppress auto-detection, or undefined for auto-detect. */
23
+ project?: string | null
23
24
  }
24
25
 
25
26
  export interface BrainjarClient {
@@ -40,7 +41,8 @@ const ERROR_MAP: Record<number, { code: ErrorCode; hint?: string }> = {
40
41
  503: { code: ErrorCode.SERVER_UNAVAILABLE, hint: 'Server is not ready. Try again in a moment.' },
41
42
  }
42
43
 
43
- async function detectProject(explicit?: string): Promise<string | null> {
44
+ async function detectProject(explicit?: string | null): Promise<string | null> {
45
+ if (explicit === null) return null // explicitly suppress auto-detection
44
46
  if (explicit) return explicit
45
47
  try {
46
48
  await access(getLocalDir())
@@ -72,7 +74,8 @@ export async function createClient(options?: ClientOptions): Promise<BrainjarCli
72
74
  ...(reqOpts?.headers ?? {}),
73
75
  }
74
76
 
75
- const project = await detectProject(reqOpts?.project ?? options?.project)
77
+ const explicitProject = reqOpts && 'project' in reqOpts ? reqOpts.project : options?.project
78
+ const project = await detectProject(explicitProject)
76
79
  if (project) headers['X-Brainjar-Project'] = project
77
80
  if (session) headers['X-Brainjar-Session'] = session
78
81
 
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
 
4
4
  const { IncurError } = Errors
5
5
  import { ErrorCode, createError } from '../errors.js'
6
- import { normalizeSlug, getEffectiveState, putState } from '../state.js'
6
+ import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
7
7
  import { sync } from '../sync.js'
8
8
  import { getApi } from '../client.js'
9
9
  import type { ApiPersona, ApiPersonaList, ApiRuleList } from '../api-types.js'
@@ -181,7 +181,7 @@ export const persona = Cli.create('persona', {
181
181
  }
182
182
 
183
183
  if (c.options.project) {
184
- const state = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
184
+ const state = await getStateOverride(api, {
185
185
  project: basename(process.cwd()),
186
186
  })
187
187
  if (state.persona_slug === undefined) return { active: false, scope: 'project', note: 'No project persona override (cascades from workspace)' }
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
 
4
4
  const { IncurError } = Errors
5
5
  import { ErrorCode, createError } from '../errors.js'
6
- import { normalizeSlug, getEffectiveState, putState } from '../state.js'
6
+ import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
7
7
  import { sync } from '../sync.js'
8
8
  import { getApi } from '../client.js'
9
9
  import type { ApiRule, ApiRuleList } from '../api-types.js'
@@ -112,7 +112,7 @@ export const rules = Cli.create('rules', {
112
112
  const availableSlugs = available.rules.map(r => r.slug)
113
113
 
114
114
  if (c.options.project) {
115
- const override = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
115
+ const override = await getStateOverride(api, {
116
116
  project: basename(process.cwd()),
117
117
  })
118
118
  return {
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
 
4
4
  const { IncurError } = Errors
5
5
  import { ErrorCode, createError } from '../errors.js'
6
- import { normalizeSlug, getEffectiveState, putState } from '../state.js'
6
+ import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
7
7
  import { sync } from '../sync.js'
8
8
  import { getApi } from '../client.js'
9
9
  import type { ApiSoul, ApiSoulList } from '../api-types.js'
@@ -145,7 +145,7 @@ export const soul = Cli.create('soul', {
145
145
  }
146
146
 
147
147
  if (c.options.project) {
148
- const state = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
148
+ const state = await getStateOverride(api, {
149
149
  project: basename(process.cwd()),
150
150
  })
151
151
  if (state.soul_slug === undefined) return { active: false, scope: 'project', note: 'No project soul override (cascades from workspace)' }
@@ -1,9 +1,8 @@
1
1
  import { Cli, z } from 'incur'
2
2
  import { basename } from 'node:path'
3
- import { getEffectiveState } from '../state.js'
3
+ import { getEffectiveState, getStateOverride } from '../state.js'
4
4
  import { sync } from '../sync.js'
5
5
  import { getApi } from '../client.js'
6
- import type { ApiStateOverride } from '../api-types.js'
7
6
 
8
7
  export const status = Cli.create('status', {
9
8
  description: 'Show active brain configuration',
@@ -35,11 +34,11 @@ export const status = Cli.create('status', {
35
34
 
36
35
  // --workspace: show only workspace-level override
37
36
  if (c.options.workspace) {
38
- const override = await api.get<ApiStateOverride>('/api/v1/state/override')
37
+ const override = await getStateOverride(api, { project: null })
39
38
  const result: Record<string, unknown> = {
40
39
  soul: override.soul_slug ?? null,
41
40
  persona: override.persona_slug ?? null,
42
- rules: override.rule_slugs ?? [],
41
+ rules: override.rules_to_add ?? [],
43
42
  }
44
43
  if (synced) result.synced = synced
45
44
  return result
@@ -47,7 +46,7 @@ export const status = Cli.create('status', {
47
46
 
48
47
  // --project: show only project-level overrides
49
48
  if (c.options.project) {
50
- const override = await api.get<ApiStateOverride>('/api/v1/state/override', {
49
+ const override = await getStateOverride(api, {
51
50
  project: basename(process.cwd()),
52
51
  })
53
52
  const result: Record<string, unknown> = {}
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 } from 'node:fs/promises'
3
+ import { readFile, writeFile, rm, access, open, chmod, mkdir, 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'
@@ -30,7 +30,7 @@ const { IncurError } = Errors
30
30
  * Minimum server version this CLI is compatible with.
31
31
  * Bump when the CLI depends on server features/API changes.
32
32
  */
33
- export const MIN_SERVER_VERSION = '0.2.2'
33
+ export const MIN_SERVER_VERSION = '0.2.4'
34
34
 
35
35
  export interface HealthStatus {
36
36
  healthy: boolean
@@ -268,6 +268,12 @@ export async function upgradeServer(): Promise<{ version: string; alreadyLatest:
268
268
  return { version, alreadyLatest: true }
269
269
  }
270
270
 
271
+ // Stop server before replacing binary to avoid ETXTBSY on Linux
272
+ const pid = await readPid(localContext(config).pid_file)
273
+ if (pid !== null && isAlive(pid)) {
274
+ await stop()
275
+ }
276
+
271
277
  const versionBase = `${DIST_BASE}/${version}`
272
278
  await downloadAndVerify(binPath, versionBase)
273
279
  await setInstalledServerVersion(version)
@@ -397,9 +403,25 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
397
403
  }
398
404
  }
399
405
 
406
+ /**
407
+ * Try to acquire an exclusive lock file. Returns a release function on success, null if already locked.
408
+ */
409
+ async function tryLock(lockFile: string): Promise<(() => Promise<void>) | null> {
410
+ try {
411
+ const fd = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY)
412
+ await fd.write(String(process.pid))
413
+ await fd.close()
414
+ return async () => { await rm(lockFile, { force: true }) }
415
+ } catch (e) {
416
+ if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null
417
+ throw e
418
+ }
419
+ }
420
+
400
421
  /**
401
422
  * Ensure the server is running and healthy.
402
423
  * Called by commands before making API calls.
424
+ * Uses a file lock to prevent parallel CLI invocations from spawning multiple server processes.
403
425
  */
404
426
  export async function ensureRunning(): Promise<void> {
405
427
  const config = await readConfig()
@@ -420,20 +442,31 @@ export async function ensureRunning(): Promise<void> {
420
442
  })
421
443
  }
422
444
 
423
- // Local mode: auto-start
424
- await cleanStalePid(local.pid_file)
445
+ // Local mode: auto-start with file lock to prevent races
446
+ const lockFile = `${local.pid_file}.lock`
447
+ const release = await tryLock(lockFile)
425
448
 
426
- try {
427
- await start()
428
- } catch (e) {
429
- if (e instanceof IncurError) throw e
430
- throw createError(ErrorCode.SERVER_START_FAILED, {
431
- message: 'Failed to start brainjar server.',
432
- hint: `Check ${local.log_file}`,
433
- })
449
+ if (release) {
450
+ // We hold the lock — we're responsible for starting
451
+ try {
452
+ await cleanStalePid(local.pid_file)
453
+
454
+ try {
455
+ await start()
456
+ } catch (e) {
457
+ if (e instanceof IncurError) throw e
458
+ throw createError(ErrorCode.SERVER_START_FAILED, {
459
+ message: 'Failed to start brainjar server.',
460
+ hint: `Check ${local.log_file}`,
461
+ })
462
+ }
463
+ } finally {
464
+ await release()
465
+ }
434
466
  }
435
467
 
436
468
  // Poll until healthy (200ms intervals, 10s timeout)
469
+ // Both the lock holder and waiters converge here
437
470
  const deadline = Date.now() + 10_000
438
471
  while (Date.now() < deadline) {
439
472
  await new Promise(r => setTimeout(r, 200))
package/src/state.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { BrainjarClient } from './client.js'
2
- import type { ApiEffectiveState, ApiStateMutation } from './api-types.js'
1
+ import type { BrainjarClient, RequestOptions } from './client.js'
2
+ import type { ApiEffectiveState, ApiStateMutation, ApiStateOverride, ApiStateOverrideResponse } from './api-types.js'
3
3
 
4
4
  const SLUG_RE = /^[a-zA-Z0-9_-]+$/
5
5
 
@@ -15,8 +15,14 @@ export function normalizeSlug(value: string, label: string): string {
15
15
  }
16
16
 
17
17
  /** Fetch the fully resolved effective state from the server. */
18
- export async function getEffectiveState(api: BrainjarClient): Promise<ApiEffectiveState> {
19
- return api.get<ApiEffectiveState>('/api/v1/state')
18
+ export async function getEffectiveState(api: BrainjarClient, options?: RequestOptions): Promise<ApiEffectiveState> {
19
+ return api.get<ApiEffectiveState>('/api/v1/state', options)
20
+ }
21
+
22
+ /** Fetch the raw override at a specific scope, unwrapping the server envelope. */
23
+ export async function getStateOverride(api: BrainjarClient, options?: RequestOptions): Promise<ApiStateOverride> {
24
+ const resp = await api.get<ApiStateOverrideResponse>('/api/v1/state/override', options)
25
+ return resp.override ?? {}
20
26
  }
21
27
 
22
28
  /** Mutate state on the server. Pass options.project to scope the mutation to a project. */
package/src/sync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
2
+ import { basename } from 'node:path'
2
3
  import { type Backend, getBackendConfig } from './paths.js'
3
4
  import { getEffectiveState } from './state.js'
4
5
  import { getApi, type BrainjarClient } from './client.js'
@@ -72,7 +73,7 @@ export async function sync(options?: SyncOptions) {
72
73
  const opts = options ?? {}
73
74
  const api = opts.api ?? await getApi()
74
75
 
75
- const state = await getEffectiveState(api)
76
+ const state = await getEffectiveState(api, opts.project ? { project: basename(process.cwd()) } : undefined)
76
77
  const backend: Backend = opts.backend ?? 'claude'
77
78
  const config = getBackendConfig(backend, { local: opts.project })
78
79
 
package/src/upgrade.ts CHANGED
@@ -88,14 +88,10 @@ export async function upgradeServerBinary(): Promise<ServerResult> {
88
88
  const { getInstalledServerVersion } = await import('./version-check.js')
89
89
  const installedVersion = (await getInstalledServerVersion()) ?? 'unknown'
90
90
 
91
- // Stop server if running before replacing binary
91
+ // upgradeServer() stops the server internally before replacing the binary
92
92
  const s = await daemonStatus()
93
93
  const wasRunning = s.running
94
94
 
95
- if (wasRunning) {
96
- await stop()
97
- }
98
-
99
95
  const result = await upgradeServer()
100
96
 
101
97
  if (result.alreadyLatest) {