@brainjar/cli 0.5.0 → 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 +1 -1
- package/src/api-types.ts +7 -1
- package/src/client.ts +6 -3
- package/src/commands/persona.ts +2 -2
- package/src/commands/rules.ts +2 -2
- package/src/commands/soul.ts +2 -2
- package/src/commands/status.ts +4 -5
- package/src/daemon.ts +46 -13
- package/src/state.ts +10 -4
- package/src/sync.ts +2 -1
- package/src/upgrade.ts +1 -5
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
package/src/commands/persona.ts
CHANGED
|
@@ -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
|
|
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)' }
|
package/src/commands/rules.ts
CHANGED
|
@@ -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
|
|
115
|
+
const override = await getStateOverride(api, {
|
|
116
116
|
project: basename(process.cwd()),
|
|
117
117
|
})
|
|
118
118
|
return {
|
package/src/commands/soul.ts
CHANGED
|
@@ -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
|
|
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)' }
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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'
|
|
@@ -14,7 +14,7 @@ export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
|
|
|
14
14
|
* Strips leading 'v' prefix. Only compares major.minor.patch.
|
|
15
15
|
*/
|
|
16
16
|
export function compareSemver(a: string, b: string): number {
|
|
17
|
-
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
|
17
|
+
const parse = (v: string) => v.replace(/^v/, '').replace(/-.*$/, '').split('.').map(Number)
|
|
18
18
|
const pa = parse(a)
|
|
19
19
|
const pb = parse(b)
|
|
20
20
|
for (let i = 0; i < 3; i++) {
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
//
|
|
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) {
|