@brainjar/cli 0.3.0 → 0.4.1
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/README.md +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +65 -40
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +152 -145
- package/src/commands/rules.ts +112 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +53 -46
- package/src/commands/soul.ts +125 -110
- package/src/commands/status.ts +36 -41
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
|
+
import { getBrainjarDir, paths } from './paths.js'
|
|
5
|
+
import type { Backend } from './paths.js'
|
|
6
|
+
|
|
7
|
+
export interface ServerConfig {
|
|
8
|
+
url: string
|
|
9
|
+
mode: 'local' | 'remote'
|
|
10
|
+
bin: string
|
|
11
|
+
pid_file: string
|
|
12
|
+
log_file: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Config {
|
|
16
|
+
server: ServerConfig
|
|
17
|
+
workspace: string
|
|
18
|
+
backend: Backend
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaults(): Config {
|
|
22
|
+
const dir = getBrainjarDir()
|
|
23
|
+
return {
|
|
24
|
+
server: {
|
|
25
|
+
url: 'http://localhost:7742',
|
|
26
|
+
mode: 'local',
|
|
27
|
+
bin: `${dir}/bin/brainjar-server`,
|
|
28
|
+
pid_file: `${dir}/server.pid`,
|
|
29
|
+
log_file: `${dir}/server.log`,
|
|
30
|
+
},
|
|
31
|
+
workspace: 'default',
|
|
32
|
+
backend: 'claude',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isValidMode(v: unknown): v is 'local' | 'remote' {
|
|
37
|
+
return v === 'local' || v === 'remote'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidBackend(v: unknown): v is Backend {
|
|
41
|
+
return v === 'claude' || v === 'codex'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read config from ~/.brainjar/config.yaml.
|
|
46
|
+
* Returns defaults if file doesn't exist.
|
|
47
|
+
* Applies env var overrides on top.
|
|
48
|
+
* Throws if file exists but is corrupt YAML.
|
|
49
|
+
*/
|
|
50
|
+
export async function readConfig(): Promise<Config> {
|
|
51
|
+
const def = defaults()
|
|
52
|
+
let config = { ...def, server: { ...def.server } }
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const raw = await readFile(paths.config, 'utf-8')
|
|
56
|
+
let parsed: unknown
|
|
57
|
+
try {
|
|
58
|
+
parsed = parseYaml(raw)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
throw new Error(`config.yaml is corrupt: ${(e as Error).message}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed && typeof parsed === 'object') {
|
|
64
|
+
const p = parsed as Record<string, unknown>
|
|
65
|
+
|
|
66
|
+
if (typeof p.workspace === 'string') config.workspace = p.workspace
|
|
67
|
+
if (isValidBackend(p.backend)) config.backend = p.backend
|
|
68
|
+
|
|
69
|
+
if (p.server && typeof p.server === 'object') {
|
|
70
|
+
const s = p.server as Record<string, unknown>
|
|
71
|
+
if (typeof s.url === 'string') config.server.url = s.url
|
|
72
|
+
if (isValidMode(s.mode)) config.server.mode = s.mode
|
|
73
|
+
if (typeof s.bin === 'string') config.server.bin = s.bin
|
|
74
|
+
if (typeof s.pid_file === 'string') config.server.pid_file = s.pid_file
|
|
75
|
+
if (typeof s.log_file === 'string') config.server.log_file = s.log_file
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(config)
|
|
80
|
+
throw e
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return applyEnvOverrides(config)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function applyEnvOverrides(config: Config): Config {
|
|
87
|
+
const url = process.env.BRAINJAR_SERVER_URL
|
|
88
|
+
if (typeof url === 'string' && url) config.server.url = url
|
|
89
|
+
|
|
90
|
+
const workspace = process.env.BRAINJAR_WORKSPACE
|
|
91
|
+
if (typeof workspace === 'string' && workspace) config.workspace = workspace
|
|
92
|
+
|
|
93
|
+
const backend = process.env.BRAINJAR_BACKEND
|
|
94
|
+
if (isValidBackend(backend)) config.backend = backend
|
|
95
|
+
|
|
96
|
+
return config
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write config to ~/.brainjar/config.yaml.
|
|
101
|
+
* Atomic write (tmp + rename).
|
|
102
|
+
*/
|
|
103
|
+
export async function writeConfig(config: Config): Promise<void> {
|
|
104
|
+
const doc = {
|
|
105
|
+
server: {
|
|
106
|
+
url: config.server.url,
|
|
107
|
+
mode: config.server.mode,
|
|
108
|
+
bin: config.server.bin,
|
|
109
|
+
pid_file: config.server.pid_file,
|
|
110
|
+
log_file: config.server.log_file,
|
|
111
|
+
},
|
|
112
|
+
workspace: config.workspace,
|
|
113
|
+
backend: config.backend,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await mkdir(dirname(paths.config), { recursive: true })
|
|
117
|
+
const tmp = `${paths.config}.tmp`
|
|
118
|
+
await writeFile(tmp, stringifyYaml(doc))
|
|
119
|
+
await rename(tmp, paths.config)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Get the config file path. */
|
|
123
|
+
export function getConfigPath(): string {
|
|
124
|
+
return paths.config
|
|
125
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFile, writeFile, rm, access, open, chmod, mkdir } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { Errors } from 'incur'
|
|
7
|
+
import { readConfig } from './config.js'
|
|
8
|
+
import { ErrorCode, createError } from './errors.js'
|
|
9
|
+
|
|
10
|
+
export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
|
|
11
|
+
|
|
12
|
+
const { IncurError } = Errors
|
|
13
|
+
|
|
14
|
+
export interface HealthStatus {
|
|
15
|
+
healthy: boolean
|
|
16
|
+
url: string
|
|
17
|
+
latencyMs?: number
|
|
18
|
+
error?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DaemonStatus {
|
|
22
|
+
mode: 'local' | 'remote'
|
|
23
|
+
url: string
|
|
24
|
+
running: boolean
|
|
25
|
+
pid: number | null
|
|
26
|
+
healthy: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if the server is healthy.
|
|
31
|
+
* Returns health status without throwing.
|
|
32
|
+
*/
|
|
33
|
+
export async function healthCheck(options?: { timeout?: number; url?: string }): Promise<HealthStatus> {
|
|
34
|
+
const config = await readConfig()
|
|
35
|
+
const url = options?.url ?? config.server.url
|
|
36
|
+
const timeout = options?.timeout ?? 2000
|
|
37
|
+
const start = Date.now()
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`${url}/healthz`, {
|
|
41
|
+
signal: AbortSignal.timeout(timeout),
|
|
42
|
+
})
|
|
43
|
+
const latencyMs = Date.now() - start
|
|
44
|
+
|
|
45
|
+
if (response.status === 200) {
|
|
46
|
+
try {
|
|
47
|
+
const body = await response.json() as { status?: string }
|
|
48
|
+
if (body.status === 'ok') {
|
|
49
|
+
return { healthy: true, url, latencyMs }
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
return { healthy: true, url, latencyMs }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { healthy: false, url, error: `Server returned ${response.status}` }
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { healthy: false, url, error: (e as Error).message }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read PID from pid_file, return null if missing or unreadable. */
|
|
62
|
+
async function readPid(pidFile: string): Promise<number | null> {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await readFile(pidFile, 'utf-8')
|
|
65
|
+
const pid = parseInt(raw.trim(), 10)
|
|
66
|
+
return Number.isFinite(pid) ? pid : null
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Check if a process is alive. */
|
|
73
|
+
function isAlive(pid: number): boolean {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0)
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove stale PID file if process is dead. Returns true if PID file was stale. */
|
|
83
|
+
async function cleanStalePid(pidFile: string): Promise<boolean> {
|
|
84
|
+
const pid = await readPid(pidFile)
|
|
85
|
+
if (pid === null) return false
|
|
86
|
+
if (!isAlive(pid)) {
|
|
87
|
+
await rm(pidFile, { force: true })
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function detectPlatform(): { os: string; arch: string } {
|
|
94
|
+
const platform = process.platform
|
|
95
|
+
const arch = process.arch
|
|
96
|
+
|
|
97
|
+
const osMap: Record<string, string> = { darwin: 'darwin', linux: 'linux' }
|
|
98
|
+
const archMap: Record<string, string> = { arm64: 'arm64', x64: 'amd64' }
|
|
99
|
+
|
|
100
|
+
const os = osMap[platform]
|
|
101
|
+
const mapped = archMap[arch]
|
|
102
|
+
|
|
103
|
+
if (!os || !mapped) {
|
|
104
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
105
|
+
message: `Unsupported platform: ${platform}/${arch}. Supported: darwin/linux × amd64/arm64.`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { os, arch: mapped }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch the latest server version from the distribution endpoint.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchLatestVersion(distBase: string = DIST_BASE): Promise<string> {
|
|
116
|
+
const response = await fetch(`${distBase}/latest`)
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
119
|
+
message: `Failed to fetch latest server version: HTTP ${response.status}`,
|
|
120
|
+
hint: 'Check your network connection or try again later.',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
return (await response.text()).trim()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Download a tarball, verify its SHA-256 checksum, and extract the binary.
|
|
128
|
+
* Exported for testing — ensureBinary() is the public entry point.
|
|
129
|
+
*/
|
|
130
|
+
export async function downloadAndVerify(binPath: string, versionBase: string): Promise<void> {
|
|
131
|
+
const { os, arch } = detectPlatform()
|
|
132
|
+
const tarballName = `brainjar-server-${os}-${arch}.tar.gz`
|
|
133
|
+
const tarballUrl = `${versionBase}/${tarballName}`
|
|
134
|
+
const checksumsUrl = `${versionBase}/checksums.txt`
|
|
135
|
+
|
|
136
|
+
await mkdir(dirname(binPath), { recursive: true })
|
|
137
|
+
|
|
138
|
+
const [checksumsResponse, tarballResponse] = await Promise.all([
|
|
139
|
+
fetch(checksumsUrl),
|
|
140
|
+
fetch(tarballUrl),
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
if (!tarballResponse.ok) {
|
|
144
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
145
|
+
message: `Failed to download server binary: HTTP ${tarballResponse.status} from ${tarballUrl}`,
|
|
146
|
+
hint: `Download manually from ${versionBase} and place at ${binPath}`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const buffer = Buffer.from(await tarballResponse.arrayBuffer())
|
|
151
|
+
|
|
152
|
+
if (checksumsResponse.ok) {
|
|
153
|
+
const checksumsText = await checksumsResponse.text()
|
|
154
|
+
const expectedHash = checksumsText
|
|
155
|
+
.split('\n')
|
|
156
|
+
.find(line => line.includes(tarballName))
|
|
157
|
+
?.split(/\s+/)[0]
|
|
158
|
+
|
|
159
|
+
if (expectedHash) {
|
|
160
|
+
const actualHash = createHash('sha256').update(buffer).digest('hex')
|
|
161
|
+
if (actualHash !== expectedHash) {
|
|
162
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
163
|
+
message: `Checksum mismatch for ${tarballName}: expected ${expectedHash}, got ${actualHash}`,
|
|
164
|
+
hint: 'The download may be corrupted. Retry, or download manually.',
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Extract tarball to a temp dir, then move the binary into place
|
|
171
|
+
const tmpDir = join(tmpdir(), `brainjar-dl-${Date.now()}`)
|
|
172
|
+
const tarPath = join(tmpDir, tarballName)
|
|
173
|
+
await mkdir(tmpDir, { recursive: true })
|
|
174
|
+
await writeFile(tarPath, buffer)
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
execFileSync('tar', ['xzf', tarPath, '-C', tmpDir])
|
|
178
|
+
const extractedBin = join(tmpDir, 'brainjar-server')
|
|
179
|
+
|
|
180
|
+
// Verify the binary was extracted
|
|
181
|
+
try {
|
|
182
|
+
await access(extractedBin)
|
|
183
|
+
} catch {
|
|
184
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
185
|
+
message: `Tarball did not contain expected brainjar-server binary`,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const binContent = await readFile(extractedBin)
|
|
190
|
+
await writeFile(binPath, binContent)
|
|
191
|
+
await chmod(binPath, 0o755)
|
|
192
|
+
} finally {
|
|
193
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Ensure the server binary exists at the configured path.
|
|
199
|
+
* Fetches latest version from get.brainjar.sh, downloads tarball, verifies checksum.
|
|
200
|
+
*/
|
|
201
|
+
export async function ensureBinary(): Promise<void> {
|
|
202
|
+
const config = await readConfig()
|
|
203
|
+
const binPath = config.server.bin
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await access(binPath)
|
|
207
|
+
return
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
const { setInstalledServerVersion } = await import('./version-check.js')
|
|
211
|
+
const version = await fetchLatestVersion()
|
|
212
|
+
const versionBase = `${DIST_BASE}/${version}`
|
|
213
|
+
await downloadAndVerify(binPath, versionBase)
|
|
214
|
+
await setInstalledServerVersion(version)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Download the latest server binary, replacing any existing one.
|
|
219
|
+
* Returns the version that was installed.
|
|
220
|
+
*/
|
|
221
|
+
export async function upgradeServer(): Promise<{ version: string; alreadyLatest: boolean }> {
|
|
222
|
+
const { getInstalledServerVersion, setInstalledServerVersion } = await import('./version-check.js')
|
|
223
|
+
const config = await readConfig()
|
|
224
|
+
const binPath = config.server.bin
|
|
225
|
+
|
|
226
|
+
const version = await fetchLatestVersion()
|
|
227
|
+
const installed = await getInstalledServerVersion()
|
|
228
|
+
|
|
229
|
+
if (installed === version) {
|
|
230
|
+
return { version, alreadyLatest: true }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const versionBase = `${DIST_BASE}/${version}`
|
|
234
|
+
await downloadAndVerify(binPath, versionBase)
|
|
235
|
+
await setInstalledServerVersion(version)
|
|
236
|
+
return { version, alreadyLatest: false }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Start the server daemon.
|
|
241
|
+
* Spawns the binary in detached mode, writes PID file.
|
|
242
|
+
*/
|
|
243
|
+
export async function start(): Promise<{ pid: number }> {
|
|
244
|
+
const config = await readConfig()
|
|
245
|
+
const { bin, pid_file, log_file, url } = config.server
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await access(bin)
|
|
249
|
+
} catch {
|
|
250
|
+
throw createError(ErrorCode.BINARY_NOT_FOUND, {
|
|
251
|
+
message: `Server binary not found at ${bin}`,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Extract port from URL
|
|
256
|
+
let port: string
|
|
257
|
+
try {
|
|
258
|
+
port = new URL(url).port || '7742'
|
|
259
|
+
} catch {
|
|
260
|
+
port = '7742'
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const logFd = await open(log_file, 'a')
|
|
264
|
+
|
|
265
|
+
const child = spawn(bin, [], {
|
|
266
|
+
detached: true,
|
|
267
|
+
stdio: ['ignore', logFd.fd, logFd.fd],
|
|
268
|
+
env: { ...process.env, PORT: port, BRAINJAR_POSTGRES_EMBEDDED: 'true' },
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const pid = child.pid
|
|
272
|
+
if (!pid) {
|
|
273
|
+
await logFd.close()
|
|
274
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
275
|
+
message: 'Failed to start brainjar server — no PID returned.',
|
|
276
|
+
hint: `Check ${log_file}`,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
child.unref()
|
|
281
|
+
await logFd.close()
|
|
282
|
+
await writeFile(pid_file, String(pid))
|
|
283
|
+
|
|
284
|
+
return { pid }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Stop the server daemon.
|
|
289
|
+
* Sends SIGTERM, waits up to 5s, falls back to SIGKILL.
|
|
290
|
+
*/
|
|
291
|
+
export async function stop(): Promise<{ stopped: boolean }> {
|
|
292
|
+
const config = await readConfig()
|
|
293
|
+
const { pid_file } = config.server
|
|
294
|
+
|
|
295
|
+
const pid = await readPid(pid_file)
|
|
296
|
+
if (pid === null) return { stopped: false }
|
|
297
|
+
if (!isAlive(pid)) {
|
|
298
|
+
await rm(pid_file, { force: true })
|
|
299
|
+
return { stopped: false }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
process.kill(pid, 'SIGTERM')
|
|
303
|
+
|
|
304
|
+
// Poll for exit, up to 5s
|
|
305
|
+
const deadline = Date.now() + 5000
|
|
306
|
+
while (Date.now() < deadline) {
|
|
307
|
+
await new Promise(r => setTimeout(r, 100))
|
|
308
|
+
if (!isAlive(pid)) {
|
|
309
|
+
await rm(pid_file, { force: true })
|
|
310
|
+
return { stopped: true }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Force kill
|
|
315
|
+
try {
|
|
316
|
+
process.kill(pid, 'SIGKILL')
|
|
317
|
+
} catch {}
|
|
318
|
+
await rm(pid_file, { force: true })
|
|
319
|
+
return { stopped: true }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the current daemon status.
|
|
324
|
+
*/
|
|
325
|
+
export async function status(): Promise<DaemonStatus> {
|
|
326
|
+
const config = await readConfig()
|
|
327
|
+
const { mode, url, pid_file } = config.server
|
|
328
|
+
|
|
329
|
+
const pid = await readPid(pid_file)
|
|
330
|
+
const running = pid !== null && isAlive(pid)
|
|
331
|
+
const health = await healthCheck({ timeout: 2000, url })
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
mode,
|
|
335
|
+
url,
|
|
336
|
+
running,
|
|
337
|
+
pid: running ? pid : null,
|
|
338
|
+
healthy: health.healthy,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read the last N lines of the server log file.
|
|
344
|
+
*/
|
|
345
|
+
export async function readLogFile(options?: { lines?: number }): Promise<string> {
|
|
346
|
+
const config = await readConfig()
|
|
347
|
+
const lines = options?.lines ?? 50
|
|
348
|
+
try {
|
|
349
|
+
const content = await readFile(config.server.log_file, 'utf-8')
|
|
350
|
+
const allLines = content.trimEnd().split('\n')
|
|
351
|
+
return allLines.slice(-lines).join('\n')
|
|
352
|
+
} catch (e) {
|
|
353
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
354
|
+
return ''
|
|
355
|
+
}
|
|
356
|
+
throw e
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Ensure the server is running and healthy.
|
|
362
|
+
* Called by commands before making API calls.
|
|
363
|
+
*/
|
|
364
|
+
export async function ensureRunning(): Promise<void> {
|
|
365
|
+
const config = await readConfig()
|
|
366
|
+
const { mode, url } = config.server
|
|
367
|
+
|
|
368
|
+
// Check health first — fast path
|
|
369
|
+
const health = await healthCheck({ timeout: 2000, url })
|
|
370
|
+
if (health.healthy) return
|
|
371
|
+
|
|
372
|
+
if (mode === 'remote') {
|
|
373
|
+
throw createError(ErrorCode.SERVER_UNREACHABLE, {
|
|
374
|
+
params: [url],
|
|
375
|
+
hint: `Check the URL or run 'brainjar server remote <url>'.`,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Local mode: auto-start
|
|
380
|
+
await cleanStalePid(config.server.pid_file)
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await start()
|
|
384
|
+
} catch (e) {
|
|
385
|
+
if (e instanceof IncurError) throw e
|
|
386
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
387
|
+
message: 'Failed to start brainjar server.',
|
|
388
|
+
hint: `Check ${config.server.log_file}`,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Poll until healthy (200ms intervals, 10s timeout)
|
|
393
|
+
const deadline = Date.now() + 10_000
|
|
394
|
+
while (Date.now() < deadline) {
|
|
395
|
+
await new Promise(r => setTimeout(r, 200))
|
|
396
|
+
const check = await healthCheck({ timeout: 2000, url })
|
|
397
|
+
if (check.healthy) return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
401
|
+
message: 'Server started but failed health check after 10s.',
|
|
402
|
+
hint: `Check ${config.server.log_file}`,
|
|
403
|
+
})
|
|
404
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Errors } from 'incur'
|
|
2
|
+
|
|
3
|
+
const { IncurError } = Errors
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Error codes — single source of truth
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export const ErrorCode = {
|
|
10
|
+
// HTTP-mapped
|
|
11
|
+
BAD_REQUEST: 'BAD_REQUEST',
|
|
12
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
13
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
14
|
+
CONFLICT: 'CONFLICT',
|
|
15
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
16
|
+
SERVER_ERROR: 'SERVER_ERROR',
|
|
17
|
+
SERVER_UNAVAILABLE: 'SERVER_UNAVAILABLE',
|
|
18
|
+
API_ERROR: 'API_ERROR',
|
|
19
|
+
|
|
20
|
+
// Domain: souls
|
|
21
|
+
SOUL_EXISTS: 'SOUL_EXISTS',
|
|
22
|
+
SOUL_NOT_FOUND: 'SOUL_NOT_FOUND',
|
|
23
|
+
|
|
24
|
+
// Domain: personas
|
|
25
|
+
PERSONA_EXISTS: 'PERSONA_EXISTS',
|
|
26
|
+
PERSONA_NOT_FOUND: 'PERSONA_NOT_FOUND',
|
|
27
|
+
|
|
28
|
+
// Domain: brains
|
|
29
|
+
BRAIN_EXISTS: 'BRAIN_EXISTS',
|
|
30
|
+
BRAIN_NOT_FOUND: 'BRAIN_NOT_FOUND',
|
|
31
|
+
|
|
32
|
+
// Domain: rules
|
|
33
|
+
RULE_EXISTS: 'RULE_EXISTS',
|
|
34
|
+
RULE_NOT_FOUND: 'RULE_NOT_FOUND',
|
|
35
|
+
RULES_NOT_FOUND: 'RULES_NOT_FOUND',
|
|
36
|
+
|
|
37
|
+
// Domain: state
|
|
38
|
+
NO_ACTIVE_SOUL: 'NO_ACTIVE_SOUL',
|
|
39
|
+
NO_ACTIVE_PERSONA: 'NO_ACTIVE_PERSONA',
|
|
40
|
+
|
|
41
|
+
// Packs
|
|
42
|
+
PACK_INVALID_VERSION: 'PACK_INVALID_VERSION',
|
|
43
|
+
PACK_DIR_EXISTS: 'PACK_DIR_EXISTS',
|
|
44
|
+
PACK_NO_MANIFEST: 'PACK_NO_MANIFEST',
|
|
45
|
+
PACK_CORRUPT_MANIFEST: 'PACK_CORRUPT_MANIFEST',
|
|
46
|
+
PACK_INVALID_MANIFEST: 'PACK_INVALID_MANIFEST',
|
|
47
|
+
PACK_MISSING_FILE: 'PACK_MISSING_FILE',
|
|
48
|
+
PACK_NOT_DIR: 'PACK_NOT_DIR',
|
|
49
|
+
PACK_NOT_FOUND: 'PACK_NOT_FOUND',
|
|
50
|
+
|
|
51
|
+
// Infra
|
|
52
|
+
TIMEOUT: 'TIMEOUT',
|
|
53
|
+
SERVER_UNREACHABLE: 'SERVER_UNREACHABLE',
|
|
54
|
+
BINARY_NOT_FOUND: 'BINARY_NOT_FOUND',
|
|
55
|
+
SERVER_START_FAILED: 'SERVER_START_FAILED',
|
|
56
|
+
|
|
57
|
+
// Validation
|
|
58
|
+
MUTUALLY_EXCLUSIVE: 'MUTUALLY_EXCLUSIVE',
|
|
59
|
+
MISSING_ARG: 'MISSING_ARG',
|
|
60
|
+
NO_OVERRIDES: 'NO_OVERRIDES',
|
|
61
|
+
|
|
62
|
+
// Other
|
|
63
|
+
INVALID_MODE: 'INVALID_MODE',
|
|
64
|
+
SHELL_ERROR: 'SHELL_ERROR',
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Message templates — parameterized messages are functions, static are strings
|
|
71
|
+
// Not every code needs an entry. Codes with context-dependent messages
|
|
72
|
+
// (e.g. PACK_INVALID_MANIFEST) keep messages inline at the call site.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export const Messages: Partial<Record<ErrorCode, string | ((...args: string[]) => string)>> = {
|
|
76
|
+
// Souls
|
|
77
|
+
SOUL_EXISTS: (name: string) => `Soul "${name}" already exists.`,
|
|
78
|
+
SOUL_NOT_FOUND: (name: string) => `Soul "${name}" not found.`,
|
|
79
|
+
|
|
80
|
+
// Personas
|
|
81
|
+
PERSONA_EXISTS: (name: string) => `Persona "${name}" already exists.`,
|
|
82
|
+
PERSONA_NOT_FOUND: (name: string) => `Persona "${name}" not found.`,
|
|
83
|
+
|
|
84
|
+
// Brains
|
|
85
|
+
BRAIN_EXISTS: (name: string) => `Brain "${name}" already exists.`,
|
|
86
|
+
BRAIN_NOT_FOUND: (name: string) => `Brain "${name}" not found.`,
|
|
87
|
+
|
|
88
|
+
// Rules
|
|
89
|
+
RULE_EXISTS: (name: string) => `Rule "${name}" already exists.`,
|
|
90
|
+
RULE_NOT_FOUND: (name: string) => `Rule "${name}" not found.`,
|
|
91
|
+
|
|
92
|
+
// State
|
|
93
|
+
NO_ACTIVE_SOUL: 'Cannot save brain: no active soul.',
|
|
94
|
+
NO_ACTIVE_PERSONA: 'Cannot save brain: no active persona.',
|
|
95
|
+
|
|
96
|
+
// Packs
|
|
97
|
+
PACK_DIR_EXISTS: (dir: string) => `Pack directory "${dir}" already exists.`,
|
|
98
|
+
PACK_NO_MANIFEST: (dir: string) => `No pack.yaml found in "${dir}". Is this a brainjar pack?`,
|
|
99
|
+
PACK_NOT_DIR: (path: string) => `Pack path "${path}" is a file, not a directory. Packs are directories.`,
|
|
100
|
+
PACK_NOT_FOUND: (path: string) => `Pack path "${path}" does not exist.`,
|
|
101
|
+
|
|
102
|
+
// Infra
|
|
103
|
+
SERVER_UNREACHABLE: (url: string) => `Cannot reach server at ${url}`,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Hints — not every code has one
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const Hints: Partial<Record<ErrorCode, string | ((...args: string[]) => string)>> = {
|
|
111
|
+
// Domain: souls
|
|
112
|
+
SOUL_EXISTS: 'Pick a different name, or edit the existing one: `brainjar soul show <name>`',
|
|
113
|
+
SOUL_NOT_FOUND: 'List available souls: `brainjar soul list`',
|
|
114
|
+
|
|
115
|
+
// Domain: personas
|
|
116
|
+
PERSONA_EXISTS: 'Pick a different name, or edit the existing one: `brainjar persona show <name>`',
|
|
117
|
+
PERSONA_NOT_FOUND: 'List available personas: `brainjar persona list`',
|
|
118
|
+
|
|
119
|
+
// Domain: brains
|
|
120
|
+
BRAIN_EXISTS: 'Overwrite with --overwrite, or pick a different name.',
|
|
121
|
+
BRAIN_NOT_FOUND: 'List available brains: `brainjar brain list`',
|
|
122
|
+
|
|
123
|
+
// Domain: rules
|
|
124
|
+
RULE_EXISTS: 'Pick a different name, or edit the existing one: `brainjar rules show <name>`',
|
|
125
|
+
RULE_NOT_FOUND: 'List available rules: `brainjar rules list`',
|
|
126
|
+
|
|
127
|
+
// Domain: state
|
|
128
|
+
NO_ACTIVE_SOUL: 'Activate a soul first: `brainjar soul use <name>`',
|
|
129
|
+
NO_ACTIVE_PERSONA: 'Activate a persona first: `brainjar persona use <name>`',
|
|
130
|
+
|
|
131
|
+
// Packs
|
|
132
|
+
PACK_DIR_EXISTS: 'Remove the directory first, or use --out to write elsewhere.',
|
|
133
|
+
PACK_NO_MANIFEST: 'A valid pack needs a pack.yaml at its root.',
|
|
134
|
+
|
|
135
|
+
// Infra
|
|
136
|
+
BINARY_NOT_FOUND: 'Install the server: `brainjar init`',
|
|
137
|
+
SERVER_UNREACHABLE: 'Start the server: `brainjar server start`, or set a remote: `brainjar server remote <url>`',
|
|
138
|
+
SERVER_START_FAILED: 'Check server logs: `brainjar server logs`',
|
|
139
|
+
SERVER_UNAVAILABLE: 'Server is starting up. Retry in a moment, or check: `brainjar server status`',
|
|
140
|
+
UNAUTHORIZED: 'Verify server config: `brainjar server status`',
|
|
141
|
+
SERVER_ERROR: 'Check server logs: `brainjar server logs`',
|
|
142
|
+
|
|
143
|
+
// Validation
|
|
144
|
+
INVALID_MODE: 'Switch to local mode: `brainjar server local`',
|
|
145
|
+
NO_OVERRIDES: 'Pass --brain, --soul, --persona, --rules-add, or --rules-remove.',
|
|
146
|
+
MUTUALLY_EXCLUSIVE: 'Use one or the other, not both.',
|
|
147
|
+
MISSING_ARG: 'Run with --help to see usage.',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Factory — convenience for common patterns. Not mandatory.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export interface CreateErrorOptions {
|
|
155
|
+
message?: string
|
|
156
|
+
params?: string[]
|
|
157
|
+
hint?: string
|
|
158
|
+
retryable?: boolean
|
|
159
|
+
cause?: Error
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createError(code: ErrorCode, options?: CreateErrorOptions): InstanceType<typeof IncurError> {
|
|
163
|
+
const template = Messages[code]
|
|
164
|
+
const message = options?.message
|
|
165
|
+
?? (typeof template === 'function' ? template(...(options?.params ?? [])) : template)
|
|
166
|
+
?? code
|
|
167
|
+
const hintTemplate = Hints[code]
|
|
168
|
+
const hint = options?.hint
|
|
169
|
+
?? (typeof hintTemplate === 'function' ? (hintTemplate as (...args: string[]) => string)(...(options?.params ?? [])) : hintTemplate)
|
|
170
|
+
|
|
171
|
+
return new IncurError({ code, message, hint, retryable: options?.retryable, cause: options?.cause })
|
|
172
|
+
}
|