@brainjar/cli 0.4.0 → 0.5.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.
- package/README.md +3 -2
- package/package.json +3 -3
- package/src/cli.ts +4 -0
- package/src/client.ts +5 -4
- package/src/commands/context.ts +220 -0
- package/src/commands/init.ts +16 -11
- package/src/commands/persona.ts +55 -0
- package/src/commands/rules.ts +44 -3
- package/src/commands/server.ts +34 -59
- package/src/commands/soul.ts +50 -0
- package/src/commands/upgrade.ts +35 -0
- package/src/config.ts +229 -38
- package/src/daemon.ts +72 -25
- package/src/errors.ts +20 -0
- package/src/upgrade.ts +121 -0
- package/src/version-check.ts +2 -2
package/src/commands/soul.ts
CHANGED
|
@@ -39,6 +39,15 @@ export const soul = Cli.create('soul', {
|
|
|
39
39
|
lines.push(c.options.description)
|
|
40
40
|
lines.push('')
|
|
41
41
|
}
|
|
42
|
+
lines.push('## Voice')
|
|
43
|
+
lines.push('- ')
|
|
44
|
+
lines.push('')
|
|
45
|
+
lines.push('## Character')
|
|
46
|
+
lines.push('- ')
|
|
47
|
+
lines.push('')
|
|
48
|
+
lines.push('## Standards')
|
|
49
|
+
lines.push('- ')
|
|
50
|
+
lines.push('')
|
|
42
51
|
|
|
43
52
|
const content = lines.join('\n')
|
|
44
53
|
await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
|
|
@@ -55,6 +64,47 @@ export const soul = Cli.create('soul', {
|
|
|
55
64
|
}
|
|
56
65
|
},
|
|
57
66
|
})
|
|
67
|
+
.command('update', {
|
|
68
|
+
description: 'Update a soul\'s content (reads from stdin)',
|
|
69
|
+
args: z.object({
|
|
70
|
+
name: z.string().describe('Soul name'),
|
|
71
|
+
}),
|
|
72
|
+
async run(c) {
|
|
73
|
+
const name = normalizeSlug(c.args.name, 'soul name')
|
|
74
|
+
const api = await getApi()
|
|
75
|
+
|
|
76
|
+
// Validate it exists
|
|
77
|
+
try {
|
|
78
|
+
await api.get<ApiSoul>(`/api/v1/souls/${name}`)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
81
|
+
throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
|
|
82
|
+
}
|
|
83
|
+
throw e
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const chunks: Uint8Array[] = []
|
|
87
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
88
|
+
chunks.push(chunk)
|
|
89
|
+
}
|
|
90
|
+
const content = Buffer.concat(chunks).toString().trim()
|
|
91
|
+
|
|
92
|
+
if (!content) {
|
|
93
|
+
throw createError(ErrorCode.MISSING_ARG, {
|
|
94
|
+
message: 'No content provided. Pipe content via stdin.',
|
|
95
|
+
hint: `echo "# ${name}\\n..." | brainjar soul update ${name}`,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
|
|
100
|
+
|
|
101
|
+
// Sync if this soul is active
|
|
102
|
+
const state = await getEffectiveState(api)
|
|
103
|
+
if (state.soul === name) await sync({ api })
|
|
104
|
+
|
|
105
|
+
return { updated: name }
|
|
106
|
+
},
|
|
107
|
+
})
|
|
58
108
|
.command('list', {
|
|
59
109
|
description: 'List available souls',
|
|
60
110
|
async run() {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Cli, z } from 'incur'
|
|
2
|
+
import { upgradeCli, upgradeServerBinary } from '../upgrade.js'
|
|
3
|
+
import type { UpgradeResult } from '../upgrade.js'
|
|
4
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
5
|
+
|
|
6
|
+
export const upgrade = Cli.create('upgrade', {
|
|
7
|
+
description: 'Upgrade brainjar CLI and server to latest versions',
|
|
8
|
+
options: z.object({
|
|
9
|
+
'cli-only': z.boolean().default(false).describe('Only upgrade the CLI'),
|
|
10
|
+
'server-only': z.boolean().default(false).describe('Only upgrade the server'),
|
|
11
|
+
}),
|
|
12
|
+
async run(c) {
|
|
13
|
+
const cliOnly = c.options['cli-only']
|
|
14
|
+
const serverOnly = c.options['server-only']
|
|
15
|
+
|
|
16
|
+
if (cliOnly && serverOnly) {
|
|
17
|
+
throw createError(ErrorCode.MUTUALLY_EXCLUSIVE, {
|
|
18
|
+
message: '--cli-only and --server-only are mutually exclusive.',
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result: UpgradeResult = {}
|
|
23
|
+
|
|
24
|
+
if (!serverOnly) {
|
|
25
|
+
result.cli = await upgradeCli()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Server upgrade always targets the local binary, regardless of active context
|
|
29
|
+
if (!cliOnly) {
|
|
30
|
+
result.server = await upgradeServerBinary()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return result
|
|
34
|
+
},
|
|
35
|
+
})
|
package/src/config.ts
CHANGED
|
@@ -4,31 +4,67 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
|
4
4
|
import { getBrainjarDir, paths } from './paths.js'
|
|
5
5
|
import type { Backend } from './paths.js'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// ─── Context types ──────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface LocalContext {
|
|
8
10
|
url: string
|
|
9
|
-
mode: 'local'
|
|
11
|
+
mode: 'local'
|
|
10
12
|
bin: string
|
|
11
13
|
pid_file: string
|
|
12
14
|
log_file: string
|
|
15
|
+
workspace: string
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export interface
|
|
16
|
-
|
|
18
|
+
export interface RemoteContext {
|
|
19
|
+
url: string
|
|
20
|
+
mode: 'remote'
|
|
17
21
|
workspace: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ServerContext = LocalContext | RemoteContext
|
|
25
|
+
|
|
26
|
+
export function isLocalContext(ctx: ServerContext): ctx is LocalContext {
|
|
27
|
+
return ctx.mode === 'local'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Config types ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export interface Config {
|
|
33
|
+
version: 2
|
|
34
|
+
current_context: string
|
|
35
|
+
contexts: Record<string, ServerContext>
|
|
18
36
|
backend: Backend
|
|
19
37
|
}
|
|
20
38
|
|
|
21
|
-
|
|
39
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Get the active context from config. */
|
|
42
|
+
export function activeContext(config: Config): ServerContext {
|
|
43
|
+
return config.contexts[config.current_context]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get the local context from config. Always present. */
|
|
47
|
+
export function localContext(config: Config): LocalContext {
|
|
48
|
+
return config.contexts.local as LocalContext
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultLocalContext(): LocalContext {
|
|
22
52
|
const dir = getBrainjarDir()
|
|
23
53
|
return {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
log_file: `${dir}/server.log`,
|
|
30
|
-
},
|
|
54
|
+
url: 'http://localhost:7742',
|
|
55
|
+
mode: 'local',
|
|
56
|
+
bin: `${dir}/bin/brainjar-server`,
|
|
57
|
+
pid_file: `${dir}/server.pid`,
|
|
58
|
+
log_file: `${dir}/server.log`,
|
|
31
59
|
workspace: 'default',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaults(): Config {
|
|
64
|
+
return {
|
|
65
|
+
version: 2,
|
|
66
|
+
current_context: 'local',
|
|
67
|
+
contexts: { local: defaultLocalContext() },
|
|
32
68
|
backend: 'claude',
|
|
33
69
|
}
|
|
34
70
|
}
|
|
@@ -41,15 +77,80 @@ function isValidBackend(v: unknown): v is Backend {
|
|
|
41
77
|
return v === 'claude' || v === 'codex'
|
|
42
78
|
}
|
|
43
79
|
|
|
80
|
+
/** Derive a context name from a URL hostname. */
|
|
81
|
+
export function contextNameFromUrl(url: string): string {
|
|
82
|
+
try {
|
|
83
|
+
const hostname = new URL(url).hostname
|
|
84
|
+
return hostname.replace(/\./g, '-')
|
|
85
|
+
} catch {
|
|
86
|
+
return 'remote'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Find a unique context name, appending -2, -3, etc. if needed. */
|
|
91
|
+
export function uniqueContextName(base: string, existing: Record<string, unknown>): string {
|
|
92
|
+
if (!(base in existing)) return base
|
|
93
|
+
let i = 2
|
|
94
|
+
while (`${base}-${i}` in existing) i++
|
|
95
|
+
return `${base}-${i}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Migration: v1 → v2 ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
interface V1Config {
|
|
101
|
+
server: {
|
|
102
|
+
url: string
|
|
103
|
+
mode: 'local' | 'remote'
|
|
104
|
+
bin: string
|
|
105
|
+
pid_file: string
|
|
106
|
+
log_file: string
|
|
107
|
+
}
|
|
108
|
+
workspace: string
|
|
109
|
+
backend: Backend
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function migrateV1(v1: V1Config): Config {
|
|
113
|
+
const dir = getBrainjarDir()
|
|
114
|
+
const localCtx: LocalContext = {
|
|
115
|
+
url: 'http://localhost:7742',
|
|
116
|
+
mode: 'local',
|
|
117
|
+
bin: v1.server.bin || `${dir}/bin/brainjar-server`,
|
|
118
|
+
pid_file: v1.server.pid_file || `${dir}/server.pid`,
|
|
119
|
+
log_file: v1.server.log_file || `${dir}/server.log`,
|
|
120
|
+
workspace: v1.workspace || 'default',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const config: Config = {
|
|
124
|
+
version: 2,
|
|
125
|
+
current_context: 'local',
|
|
126
|
+
contexts: { local: localCtx },
|
|
127
|
+
backend: v1.backend || 'claude',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (v1.server.mode === 'remote') {
|
|
131
|
+
const name = contextNameFromUrl(v1.server.url)
|
|
132
|
+
const uniqueName = uniqueContextName(name, config.contexts)
|
|
133
|
+
config.contexts[uniqueName] = {
|
|
134
|
+
url: v1.server.url,
|
|
135
|
+
mode: 'remote',
|
|
136
|
+
workspace: v1.workspace || 'default',
|
|
137
|
+
}
|
|
138
|
+
config.current_context = uniqueName
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return config
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Read / Write ───────────────────────────────────────────────────────────
|
|
145
|
+
|
|
44
146
|
/**
|
|
45
147
|
* Read config from ~/.brainjar/config.yaml.
|
|
46
148
|
* Returns defaults if file doesn't exist.
|
|
149
|
+
* Migrates v1 configs to v2 on read.
|
|
47
150
|
* Applies env var overrides on top.
|
|
48
|
-
* Throws if file exists but is corrupt YAML.
|
|
49
151
|
*/
|
|
50
152
|
export async function readConfig(): Promise<Config> {
|
|
51
|
-
|
|
52
|
-
let config = { ...def, server: { ...def.server } }
|
|
153
|
+
let config: Config
|
|
53
154
|
|
|
54
155
|
try {
|
|
55
156
|
const raw = await readFile(paths.config, 'utf-8')
|
|
@@ -63,32 +164,107 @@ export async function readConfig(): Promise<Config> {
|
|
|
63
164
|
if (parsed && typeof parsed === 'object') {
|
|
64
165
|
const p = parsed as Record<string, unknown>
|
|
65
166
|
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
167
|
+
if (p.version === 2) {
|
|
168
|
+
// v2 config
|
|
169
|
+
config = parseV2(p)
|
|
170
|
+
} else {
|
|
171
|
+
// v1 config (no version field)
|
|
172
|
+
config = parseV1(p)
|
|
76
173
|
}
|
|
174
|
+
} else {
|
|
175
|
+
config = defaults()
|
|
77
176
|
}
|
|
78
177
|
} catch (e) {
|
|
79
|
-
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(
|
|
178
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(defaults())
|
|
80
179
|
throw e
|
|
81
180
|
}
|
|
82
181
|
|
|
83
182
|
return applyEnvOverrides(config)
|
|
84
183
|
}
|
|
85
184
|
|
|
185
|
+
function parseV2(p: Record<string, unknown>): Config {
|
|
186
|
+
const def = defaults()
|
|
187
|
+
const defLocal = localContext(def)
|
|
188
|
+
const config: Config = {
|
|
189
|
+
version: 2,
|
|
190
|
+
current_context: typeof p.current_context === 'string' ? p.current_context : 'local',
|
|
191
|
+
contexts: {},
|
|
192
|
+
backend: isValidBackend(p.backend) ? p.backend : def.backend,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (p.contexts && typeof p.contexts === 'object') {
|
|
196
|
+
for (const [name, raw] of Object.entries(p.contexts as Record<string, unknown>)) {
|
|
197
|
+
if (!raw || typeof raw !== 'object') continue
|
|
198
|
+
const ctx = raw as Record<string, unknown>
|
|
199
|
+
if (ctx.mode === 'local') {
|
|
200
|
+
config.contexts[name] = {
|
|
201
|
+
url: typeof ctx.url === 'string' ? ctx.url : 'http://localhost:7742',
|
|
202
|
+
mode: 'local',
|
|
203
|
+
bin: typeof ctx.bin === 'string' ? ctx.bin : defLocal.bin,
|
|
204
|
+
pid_file: typeof ctx.pid_file === 'string' ? ctx.pid_file : defLocal.pid_file,
|
|
205
|
+
log_file: typeof ctx.log_file === 'string' ? ctx.log_file : defLocal.log_file,
|
|
206
|
+
workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
config.contexts[name] = {
|
|
210
|
+
url: typeof ctx.url === 'string' ? ctx.url : '',
|
|
211
|
+
mode: 'remote',
|
|
212
|
+
workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Ensure local context always exists
|
|
219
|
+
if (!config.contexts.local) {
|
|
220
|
+
config.contexts.local = defaultLocalContext()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Ensure current_context points to an existing context
|
|
224
|
+
if (!(config.current_context in config.contexts)) {
|
|
225
|
+
config.current_context = 'local'
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return config
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseV1(p: Record<string, unknown>): Config {
|
|
232
|
+
const dir = getBrainjarDir()
|
|
233
|
+
const v1: V1Config = {
|
|
234
|
+
server: {
|
|
235
|
+
url: 'http://localhost:7742',
|
|
236
|
+
mode: 'local',
|
|
237
|
+
bin: `${dir}/bin/brainjar-server`,
|
|
238
|
+
pid_file: `${dir}/server.pid`,
|
|
239
|
+
log_file: `${dir}/server.log`,
|
|
240
|
+
},
|
|
241
|
+
workspace: 'default',
|
|
242
|
+
backend: 'claude',
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (typeof p.workspace === 'string') v1.workspace = p.workspace
|
|
246
|
+
if (isValidBackend(p.backend)) v1.backend = p.backend
|
|
247
|
+
|
|
248
|
+
if (p.server && typeof p.server === 'object') {
|
|
249
|
+
const s = p.server as Record<string, unknown>
|
|
250
|
+
if (typeof s.url === 'string') v1.server.url = s.url
|
|
251
|
+
if (isValidMode(s.mode)) v1.server.mode = s.mode
|
|
252
|
+
if (typeof s.bin === 'string') v1.server.bin = s.bin
|
|
253
|
+
if (typeof s.pid_file === 'string') v1.server.pid_file = s.pid_file
|
|
254
|
+
if (typeof s.log_file === 'string') v1.server.log_file = s.log_file
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return migrateV1(v1)
|
|
258
|
+
}
|
|
259
|
+
|
|
86
260
|
function applyEnvOverrides(config: Config): Config {
|
|
261
|
+
const ctx = activeContext(config)
|
|
262
|
+
|
|
87
263
|
const url = process.env.BRAINJAR_SERVER_URL
|
|
88
|
-
if (typeof url === 'string' && url)
|
|
264
|
+
if (typeof url === 'string' && url) ctx.url = url
|
|
89
265
|
|
|
90
266
|
const workspace = process.env.BRAINJAR_WORKSPACE
|
|
91
|
-
if (typeof workspace === 'string' && workspace)
|
|
267
|
+
if (typeof workspace === 'string' && workspace) ctx.workspace = workspace
|
|
92
268
|
|
|
93
269
|
const backend = process.env.BRAINJAR_BACKEND
|
|
94
270
|
if (isValidBackend(backend)) config.backend = backend
|
|
@@ -98,21 +274,36 @@ function applyEnvOverrides(config: Config): Config {
|
|
|
98
274
|
|
|
99
275
|
/**
|
|
100
276
|
* Write config to ~/.brainjar/config.yaml.
|
|
101
|
-
* Atomic write (tmp + rename).
|
|
277
|
+
* Always writes v2 format. Atomic write (tmp + rename).
|
|
102
278
|
*/
|
|
103
279
|
export async function writeConfig(config: Config): Promise<void> {
|
|
104
|
-
const doc = {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
bin: config.server.bin,
|
|
109
|
-
pid_file: config.server.pid_file,
|
|
110
|
-
log_file: config.server.log_file,
|
|
111
|
-
},
|
|
112
|
-
workspace: config.workspace,
|
|
280
|
+
const doc: Record<string, unknown> = {
|
|
281
|
+
version: 2,
|
|
282
|
+
current_context: config.current_context,
|
|
283
|
+
contexts: {} as Record<string, unknown>,
|
|
113
284
|
backend: config.backend,
|
|
114
285
|
}
|
|
115
286
|
|
|
287
|
+
const contexts = doc.contexts as Record<string, unknown>
|
|
288
|
+
for (const [name, ctx] of Object.entries(config.contexts)) {
|
|
289
|
+
if (isLocalContext(ctx)) {
|
|
290
|
+
contexts[name] = {
|
|
291
|
+
url: ctx.url,
|
|
292
|
+
mode: ctx.mode,
|
|
293
|
+
bin: ctx.bin,
|
|
294
|
+
pid_file: ctx.pid_file,
|
|
295
|
+
log_file: ctx.log_file,
|
|
296
|
+
workspace: ctx.workspace,
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
contexts[name] = {
|
|
300
|
+
url: ctx.url,
|
|
301
|
+
mode: ctx.mode,
|
|
302
|
+
workspace: ctx.workspace,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
116
307
|
await mkdir(dirname(paths.config), { recursive: true })
|
|
117
308
|
const tmp = `${paths.config}.tmp`
|
|
118
309
|
await writeFile(tmp, stringifyYaml(doc))
|
package/src/daemon.ts
CHANGED
|
@@ -4,17 +4,39 @@ import { readFile, writeFile, rm, access, open, chmod, mkdir } from 'node:fs/pro
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { Errors } from 'incur'
|
|
7
|
-
import { readConfig } from './config.js'
|
|
7
|
+
import { readConfig, activeContext, localContext } from './config.js'
|
|
8
8
|
import { ErrorCode, createError } from './errors.js'
|
|
9
9
|
|
|
10
10
|
export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Compare two semver strings. Returns -1, 0, or 1.
|
|
14
|
+
* Strips leading 'v' prefix. Only compares major.minor.patch.
|
|
15
|
+
*/
|
|
16
|
+
export function compareSemver(a: string, b: string): number {
|
|
17
|
+
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
|
18
|
+
const pa = parse(a)
|
|
19
|
+
const pb = parse(b)
|
|
20
|
+
for (let i = 0; i < 3; i++) {
|
|
21
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
22
|
+
if (diff !== 0) return diff > 0 ? 1 : -1
|
|
23
|
+
}
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
const { IncurError } = Errors
|
|
13
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Minimum server version this CLI is compatible with.
|
|
31
|
+
* Bump when the CLI depends on server features/API changes.
|
|
32
|
+
*/
|
|
33
|
+
export const MIN_SERVER_VERSION = '0.2.1'
|
|
34
|
+
|
|
14
35
|
export interface HealthStatus {
|
|
15
36
|
healthy: boolean
|
|
16
37
|
url: string
|
|
17
38
|
latencyMs?: number
|
|
39
|
+
serverVersion?: string
|
|
18
40
|
error?: string
|
|
19
41
|
}
|
|
20
42
|
|
|
@@ -26,13 +48,27 @@ export interface DaemonStatus {
|
|
|
26
48
|
healthy: boolean
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Assert the server version is compatible with this CLI.
|
|
53
|
+
* No-op if the server doesn't report a version (old servers).
|
|
54
|
+
*/
|
|
55
|
+
function assertCompatible(serverVersion: string | undefined): void {
|
|
56
|
+
if (!serverVersion) return
|
|
57
|
+
if (compareSemver(serverVersion, MIN_SERVER_VERSION) < 0) {
|
|
58
|
+
throw createError(ErrorCode.SERVER_INCOMPATIBLE, {
|
|
59
|
+
message: `Server ${serverVersion} is incompatible with this CLI (requires >= ${MIN_SERVER_VERSION}).`,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
/**
|
|
30
65
|
* Check if the server is healthy.
|
|
31
66
|
* Returns health status without throwing.
|
|
32
67
|
*/
|
|
33
68
|
export async function healthCheck(options?: { timeout?: number; url?: string }): Promise<HealthStatus> {
|
|
34
69
|
const config = await readConfig()
|
|
35
|
-
const
|
|
70
|
+
const ctx = activeContext(config)
|
|
71
|
+
const url = options?.url ?? ctx.url
|
|
36
72
|
const timeout = options?.timeout ?? 2000
|
|
37
73
|
const start = Date.now()
|
|
38
74
|
|
|
@@ -44,9 +80,9 @@ export async function healthCheck(options?: { timeout?: number; url?: string }):
|
|
|
44
80
|
|
|
45
81
|
if (response.status === 200) {
|
|
46
82
|
try {
|
|
47
|
-
const body = await response.json() as { status?: string }
|
|
83
|
+
const body = await response.json() as { status?: string; version?: string }
|
|
48
84
|
if (body.status === 'ok') {
|
|
49
|
-
return { healthy: true, url, latencyMs }
|
|
85
|
+
return { healthy: true, url, latencyMs, serverVersion: body.version }
|
|
50
86
|
}
|
|
51
87
|
} catch {}
|
|
52
88
|
return { healthy: true, url, latencyMs }
|
|
@@ -200,7 +236,8 @@ export async function downloadAndVerify(binPath: string, versionBase: string): P
|
|
|
200
236
|
*/
|
|
201
237
|
export async function ensureBinary(): Promise<void> {
|
|
202
238
|
const config = await readConfig()
|
|
203
|
-
const
|
|
239
|
+
const local = localContext(config)
|
|
240
|
+
const binPath = local.bin
|
|
204
241
|
|
|
205
242
|
try {
|
|
206
243
|
await access(binPath)
|
|
@@ -221,7 +258,8 @@ export async function ensureBinary(): Promise<void> {
|
|
|
221
258
|
export async function upgradeServer(): Promise<{ version: string; alreadyLatest: boolean }> {
|
|
222
259
|
const { getInstalledServerVersion, setInstalledServerVersion } = await import('./version-check.js')
|
|
223
260
|
const config = await readConfig()
|
|
224
|
-
const
|
|
261
|
+
const local = localContext(config)
|
|
262
|
+
const binPath = local.bin
|
|
225
263
|
|
|
226
264
|
const version = await fetchLatestVersion()
|
|
227
265
|
const installed = await getInstalledServerVersion()
|
|
@@ -242,7 +280,8 @@ export async function upgradeServer(): Promise<{ version: string; alreadyLatest:
|
|
|
242
280
|
*/
|
|
243
281
|
export async function start(): Promise<{ pid: number }> {
|
|
244
282
|
const config = await readConfig()
|
|
245
|
-
const
|
|
283
|
+
const local = localContext(config)
|
|
284
|
+
const { bin, pid_file, log_file, url } = local
|
|
246
285
|
|
|
247
286
|
try {
|
|
248
287
|
await access(bin)
|
|
@@ -290,7 +329,7 @@ export async function start(): Promise<{ pid: number }> {
|
|
|
290
329
|
*/
|
|
291
330
|
export async function stop(): Promise<{ stopped: boolean }> {
|
|
292
331
|
const config = await readConfig()
|
|
293
|
-
const { pid_file } = config
|
|
332
|
+
const { pid_file } = localContext(config)
|
|
294
333
|
|
|
295
334
|
const pid = await readPid(pid_file)
|
|
296
335
|
if (pid === null) return { stopped: false }
|
|
@@ -324,15 +363,16 @@ export async function stop(): Promise<{ stopped: boolean }> {
|
|
|
324
363
|
*/
|
|
325
364
|
export async function status(): Promise<DaemonStatus> {
|
|
326
365
|
const config = await readConfig()
|
|
327
|
-
const
|
|
366
|
+
const ctx = activeContext(config)
|
|
367
|
+
const local = localContext(config)
|
|
328
368
|
|
|
329
|
-
const pid = await readPid(pid_file)
|
|
369
|
+
const pid = await readPid(local.pid_file)
|
|
330
370
|
const running = pid !== null && isAlive(pid)
|
|
331
|
-
const health = await healthCheck({ timeout: 2000, url })
|
|
371
|
+
const health = await healthCheck({ timeout: 2000, url: ctx.url })
|
|
332
372
|
|
|
333
373
|
return {
|
|
334
|
-
mode,
|
|
335
|
-
url,
|
|
374
|
+
mode: ctx.mode,
|
|
375
|
+
url: ctx.url,
|
|
336
376
|
running,
|
|
337
377
|
pid: running ? pid : null,
|
|
338
378
|
healthy: health.healthy,
|
|
@@ -346,7 +386,7 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
|
|
|
346
386
|
const config = await readConfig()
|
|
347
387
|
const lines = options?.lines ?? 50
|
|
348
388
|
try {
|
|
349
|
-
const content = await readFile(config.
|
|
389
|
+
const content = await readFile(localContext(config).log_file, 'utf-8')
|
|
350
390
|
const allLines = content.trimEnd().split('\n')
|
|
351
391
|
return allLines.slice(-lines).join('\n')
|
|
352
392
|
} catch (e) {
|
|
@@ -363,21 +403,25 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
|
|
|
363
403
|
*/
|
|
364
404
|
export async function ensureRunning(): Promise<void> {
|
|
365
405
|
const config = await readConfig()
|
|
366
|
-
const
|
|
406
|
+
const ctx = activeContext(config)
|
|
407
|
+
const local = localContext(config)
|
|
367
408
|
|
|
368
409
|
// Check health first — fast path
|
|
369
|
-
const health = await healthCheck({ timeout: 2000, url })
|
|
370
|
-
if (health.healthy)
|
|
410
|
+
const health = await healthCheck({ timeout: 2000, url: ctx.url })
|
|
411
|
+
if (health.healthy) {
|
|
412
|
+
assertCompatible(health.serverVersion)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
371
415
|
|
|
372
|
-
if (mode === 'remote') {
|
|
416
|
+
if (ctx.mode === 'remote') {
|
|
373
417
|
throw createError(ErrorCode.SERVER_UNREACHABLE, {
|
|
374
|
-
params: [url],
|
|
375
|
-
hint: `Check the URL or run 'brainjar
|
|
418
|
+
params: [ctx.url],
|
|
419
|
+
hint: `Check the URL or run 'brainjar context add <name> <url>'.`,
|
|
376
420
|
})
|
|
377
421
|
}
|
|
378
422
|
|
|
379
423
|
// Local mode: auto-start
|
|
380
|
-
await cleanStalePid(
|
|
424
|
+
await cleanStalePid(local.pid_file)
|
|
381
425
|
|
|
382
426
|
try {
|
|
383
427
|
await start()
|
|
@@ -385,7 +429,7 @@ export async function ensureRunning(): Promise<void> {
|
|
|
385
429
|
if (e instanceof IncurError) throw e
|
|
386
430
|
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
387
431
|
message: 'Failed to start brainjar server.',
|
|
388
|
-
hint: `Check ${
|
|
432
|
+
hint: `Check ${local.log_file}`,
|
|
389
433
|
})
|
|
390
434
|
}
|
|
391
435
|
|
|
@@ -393,12 +437,15 @@ export async function ensureRunning(): Promise<void> {
|
|
|
393
437
|
const deadline = Date.now() + 10_000
|
|
394
438
|
while (Date.now() < deadline) {
|
|
395
439
|
await new Promise(r => setTimeout(r, 200))
|
|
396
|
-
const check = await healthCheck({ timeout: 2000, url })
|
|
397
|
-
if (check.healthy)
|
|
440
|
+
const check = await healthCheck({ timeout: 2000, url: ctx.url })
|
|
441
|
+
if (check.healthy) {
|
|
442
|
+
assertCompatible(check.serverVersion)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
398
445
|
}
|
|
399
446
|
|
|
400
447
|
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
401
448
|
message: 'Server started but failed health check after 10s.',
|
|
402
|
-
hint: `Check ${
|
|
449
|
+
hint: `Check ${local.log_file}`,
|
|
403
450
|
})
|
|
404
451
|
}
|
package/src/errors.ts
CHANGED
|
@@ -53,12 +53,19 @@ export const ErrorCode = {
|
|
|
53
53
|
SERVER_UNREACHABLE: 'SERVER_UNREACHABLE',
|
|
54
54
|
BINARY_NOT_FOUND: 'BINARY_NOT_FOUND',
|
|
55
55
|
SERVER_START_FAILED: 'SERVER_START_FAILED',
|
|
56
|
+
SERVER_INCOMPATIBLE: 'SERVER_INCOMPATIBLE',
|
|
56
57
|
|
|
57
58
|
// Validation
|
|
58
59
|
MUTUALLY_EXCLUSIVE: 'MUTUALLY_EXCLUSIVE',
|
|
59
60
|
MISSING_ARG: 'MISSING_ARG',
|
|
60
61
|
NO_OVERRIDES: 'NO_OVERRIDES',
|
|
61
62
|
|
|
63
|
+
// Contexts
|
|
64
|
+
CONTEXT_NOT_FOUND: 'CONTEXT_NOT_FOUND',
|
|
65
|
+
CONTEXT_PROTECTED: 'CONTEXT_PROTECTED',
|
|
66
|
+
CONTEXT_EXISTS: 'CONTEXT_EXISTS',
|
|
67
|
+
CONTEXT_ACTIVE: 'CONTEXT_ACTIVE',
|
|
68
|
+
|
|
62
69
|
// Other
|
|
63
70
|
INVALID_MODE: 'INVALID_MODE',
|
|
64
71
|
SHELL_ERROR: 'SHELL_ERROR',
|
|
@@ -99,6 +106,12 @@ export const Messages: Partial<Record<ErrorCode, string | ((...args: string[]) =
|
|
|
99
106
|
PACK_NOT_DIR: (path: string) => `Pack path "${path}" is a file, not a directory. Packs are directories.`,
|
|
100
107
|
PACK_NOT_FOUND: (path: string) => `Pack path "${path}" does not exist.`,
|
|
101
108
|
|
|
109
|
+
// Contexts
|
|
110
|
+
CONTEXT_NOT_FOUND: (name: string) => `Context "${name}" not found.`,
|
|
111
|
+
CONTEXT_EXISTS: (name: string) => `Context "${name}" already exists.`,
|
|
112
|
+
CONTEXT_PROTECTED: (name: string) => `Context "${name}" is protected and cannot be removed or renamed.`,
|
|
113
|
+
CONTEXT_ACTIVE: (name: string) => `Context "${name}" is the active context. Switch first.`,
|
|
114
|
+
|
|
102
115
|
// Infra
|
|
103
116
|
SERVER_UNREACHABLE: (url: string) => `Cannot reach server at ${url}`,
|
|
104
117
|
}
|
|
@@ -132,10 +145,17 @@ export const Hints: Partial<Record<ErrorCode, string | ((...args: string[]) => s
|
|
|
132
145
|
PACK_DIR_EXISTS: 'Remove the directory first, or use --out to write elsewhere.',
|
|
133
146
|
PACK_NO_MANIFEST: 'A valid pack needs a pack.yaml at its root.',
|
|
134
147
|
|
|
148
|
+
// Contexts
|
|
149
|
+
CONTEXT_NOT_FOUND: 'List available contexts: `brainjar context list`',
|
|
150
|
+
CONTEXT_EXISTS: 'Pick a different name.',
|
|
151
|
+
CONTEXT_PROTECTED: 'The local context is always present and cannot be modified.',
|
|
152
|
+
CONTEXT_ACTIVE: 'Switch to a different context first: `brainjar context use <name>`',
|
|
153
|
+
|
|
135
154
|
// Infra
|
|
136
155
|
BINARY_NOT_FOUND: 'Install the server: `brainjar init`',
|
|
137
156
|
SERVER_UNREACHABLE: 'Start the server: `brainjar server start`, or set a remote: `brainjar server remote <url>`',
|
|
138
157
|
SERVER_START_FAILED: 'Check server logs: `brainjar server logs`',
|
|
158
|
+
SERVER_INCOMPATIBLE: 'Run `brainjar upgrade` to update both CLI and server.',
|
|
139
159
|
SERVER_UNAVAILABLE: 'Server is starting up. Retry in a moment, or check: `brainjar server status`',
|
|
140
160
|
UNAUTHORIZED: 'Verify server config: `brainjar server status`',
|
|
141
161
|
SERVER_ERROR: 'Check server logs: `brainjar server logs`',
|