@brainjar/cli 0.4.1 → 0.5.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 +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/server.ts +34 -59
- 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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# brainjar
|
|
2
2
|
|
|
3
|
-
[](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@brainjar/cli)
|
|
5
5
|
[](https://www.npmjs.com/package/@brainjar/cli)
|
|
6
6
|
[](LICENSE)
|
|
@@ -50,7 +50,8 @@ brainjar pack export|import
|
|
|
50
50
|
brainjar hooks install|remove|status [--local]
|
|
51
51
|
brainjar shell [--brain <name>] [--soul <name>] [--persona <name>]
|
|
52
52
|
brainjar reset [--backend claude|codex]
|
|
53
|
-
brainjar server start|stop|status|logs|local|remote
|
|
53
|
+
brainjar server start|stop|status|logs|local|remote
|
|
54
|
+
brainjar upgrade [--cli-only] [--server-only]
|
|
54
55
|
brainjar migrate [--dry-run] [--skip-backup]
|
|
55
56
|
```
|
|
56
57
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainjar/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/brainjar-sh/
|
|
9
|
+
"url": "https://github.com/brainjar-sh/cli.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://brainjar.sh",
|
|
12
|
-
"bugs": "https://github.com/brainjar-sh/
|
|
12
|
+
"bugs": "https://github.com/brainjar-sh/cli/issues",
|
|
13
13
|
"keywords": [
|
|
14
14
|
"ai",
|
|
15
15
|
"agent",
|
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { hooks } from './commands/hooks.js'
|
|
|
15
15
|
import { pack } from './commands/pack.js'
|
|
16
16
|
import { server } from './commands/server.js'
|
|
17
17
|
import { migrate } from './commands/migrate.js'
|
|
18
|
+
import { upgrade } from './commands/upgrade.js'
|
|
19
|
+
import { context } from './commands/context.js'
|
|
18
20
|
|
|
19
21
|
Cli.create('brainjar', {
|
|
20
22
|
description: 'Shape how your AI thinks — soul, persona, rules',
|
|
@@ -35,4 +37,6 @@ Cli.create('brainjar', {
|
|
|
35
37
|
.command(pack)
|
|
36
38
|
.command(server)
|
|
37
39
|
.command(migrate)
|
|
40
|
+
.command(upgrade)
|
|
41
|
+
.command(context)
|
|
38
42
|
.serve()
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Errors } from 'incur'
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
|
-
import { readConfig } from './config.js'
|
|
3
|
+
import { readConfig, activeContext } from './config.js'
|
|
4
4
|
import { getLocalDir } from './paths.js'
|
|
5
5
|
import { access } from 'node:fs/promises'
|
|
6
6
|
import { ensureRunning } from './daemon.js'
|
|
@@ -55,11 +55,12 @@ async function detectProject(explicit?: string): Promise<string | null> {
|
|
|
55
55
|
*/
|
|
56
56
|
export async function createClient(options?: ClientOptions): Promise<BrainjarClient> {
|
|
57
57
|
const config = await readConfig()
|
|
58
|
-
const
|
|
59
|
-
const
|
|
58
|
+
const ctx = activeContext(config)
|
|
59
|
+
const serverUrl = (options?.serverUrl ?? ctx.url).replace(/\/$/, '')
|
|
60
|
+
const workspace = options?.workspace ?? ctx.workspace
|
|
60
61
|
const session = options?.session ?? process.env.BRAINJAR_SESSION ?? null
|
|
61
62
|
const defaultTimeout = options?.timeout ?? 10_000
|
|
62
|
-
const mode =
|
|
63
|
+
const mode = ctx.mode
|
|
63
64
|
|
|
64
65
|
async function request<T>(method: string, path: string, body?: unknown, reqOpts?: RequestOptions): Promise<T> {
|
|
65
66
|
const url = `${serverUrl}${path}`
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Cli, z } from 'incur'
|
|
2
|
+
import { readConfig, writeConfig, activeContext, isLocalContext, contextNameFromUrl, uniqueContextName } from '../config.js'
|
|
3
|
+
import type { RemoteContext } from '../config.js'
|
|
4
|
+
import { healthCheck, ensureRunning } from '../daemon.js'
|
|
5
|
+
import { getApi } from '../client.js'
|
|
6
|
+
import { sync } from '../sync.js'
|
|
7
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
8
|
+
|
|
9
|
+
const SLUG_RE = /^[a-zA-Z0-9_-]+$/
|
|
10
|
+
|
|
11
|
+
const listCmd = Cli.create('list', {
|
|
12
|
+
description: 'List all contexts',
|
|
13
|
+
async run() {
|
|
14
|
+
const config = await readConfig()
|
|
15
|
+
const entries = Object.entries(config.contexts).map(([name, ctx]) => ({
|
|
16
|
+
name,
|
|
17
|
+
active: name === config.current_context,
|
|
18
|
+
mode: ctx.mode,
|
|
19
|
+
url: ctx.url,
|
|
20
|
+
workspace: ctx.workspace,
|
|
21
|
+
}))
|
|
22
|
+
return { contexts: entries }
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const addCmd = Cli.create('add', {
|
|
27
|
+
description: 'Add a remote context',
|
|
28
|
+
args: z.object({
|
|
29
|
+
name: z.string().describe('Context name'),
|
|
30
|
+
url: z.string().describe('Server URL'),
|
|
31
|
+
}),
|
|
32
|
+
options: z.object({
|
|
33
|
+
workspace: z.string().default('default').describe('Workspace name'),
|
|
34
|
+
}),
|
|
35
|
+
async run(c) {
|
|
36
|
+
const name = c.args.name
|
|
37
|
+
const url = c.args.url.replace(/\/$/, '')
|
|
38
|
+
|
|
39
|
+
if (!SLUG_RE.test(name)) {
|
|
40
|
+
throw createError(ErrorCode.VALIDATION_ERROR, {
|
|
41
|
+
message: `Invalid context name "${name}". Use only letters, numbers, hyphens, and underscores.`,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (name === 'local') {
|
|
46
|
+
throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const config = await readConfig()
|
|
50
|
+
|
|
51
|
+
if (name in config.contexts) {
|
|
52
|
+
throw createError(ErrorCode.CONTEXT_EXISTS, { params: [name] })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const health = await healthCheck({ url, timeout: 5000 })
|
|
56
|
+
if (!health.healthy) {
|
|
57
|
+
throw createError(ErrorCode.SERVER_UNREACHABLE, { params: [url] })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
config.contexts[name] = {
|
|
61
|
+
url,
|
|
62
|
+
mode: 'remote',
|
|
63
|
+
workspace: c.options.workspace,
|
|
64
|
+
}
|
|
65
|
+
await writeConfig(config)
|
|
66
|
+
|
|
67
|
+
return { added: name, url, hint: `Switch with \`brainjar context use ${name}\`` }
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const removeCmd = Cli.create('remove', {
|
|
72
|
+
description: 'Remove a context',
|
|
73
|
+
args: z.object({
|
|
74
|
+
name: z.string().describe('Context name'),
|
|
75
|
+
}),
|
|
76
|
+
async run(c) {
|
|
77
|
+
const name = c.args.name
|
|
78
|
+
const config = await readConfig()
|
|
79
|
+
|
|
80
|
+
if (name === 'local') {
|
|
81
|
+
throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!(name in config.contexts)) {
|
|
85
|
+
throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (name === config.current_context) {
|
|
89
|
+
throw createError(ErrorCode.CONTEXT_ACTIVE, { params: [name] })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
delete config.contexts[name]
|
|
93
|
+
await writeConfig(config)
|
|
94
|
+
|
|
95
|
+
return { removed: name }
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const useCmd = Cli.create('use', {
|
|
100
|
+
description: 'Switch active context',
|
|
101
|
+
args: z.object({
|
|
102
|
+
name: z.string().describe('Context name'),
|
|
103
|
+
}),
|
|
104
|
+
async run(c) {
|
|
105
|
+
const name = c.args.name
|
|
106
|
+
const config = await readConfig()
|
|
107
|
+
|
|
108
|
+
if (!(name in config.contexts)) {
|
|
109
|
+
throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
config.current_context = name
|
|
113
|
+
await writeConfig(config)
|
|
114
|
+
|
|
115
|
+
const ctx = activeContext(config)
|
|
116
|
+
|
|
117
|
+
// If switching to local, ensure running
|
|
118
|
+
if (isLocalContext(ctx)) {
|
|
119
|
+
await ensureRunning()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Sync if server is reachable
|
|
123
|
+
const health = await healthCheck({ url: ctx.url, timeout: 3000 })
|
|
124
|
+
if (health.healthy) {
|
|
125
|
+
const api = await getApi()
|
|
126
|
+
|
|
127
|
+
// Ensure workspace exists
|
|
128
|
+
try {
|
|
129
|
+
await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
130
|
+
} catch (e: any) {
|
|
131
|
+
if (e.code !== 'CONFLICT') throw e
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await sync({ api })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { active: name, mode: ctx.mode, url: ctx.url, workspace: ctx.workspace }
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const showCmd = Cli.create('show', {
|
|
142
|
+
description: 'Show context details',
|
|
143
|
+
args: z.object({
|
|
144
|
+
name: z.string().optional().describe('Context name (defaults to active)'),
|
|
145
|
+
}),
|
|
146
|
+
async run(c) {
|
|
147
|
+
const config = await readConfig()
|
|
148
|
+
const name = c.args.name ?? config.current_context
|
|
149
|
+
|
|
150
|
+
if (!(name in config.contexts)) {
|
|
151
|
+
throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ctx = config.contexts[name]
|
|
155
|
+
return {
|
|
156
|
+
name,
|
|
157
|
+
active: name === config.current_context,
|
|
158
|
+
...ctx,
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const renameCmd = Cli.create('rename', {
|
|
164
|
+
description: 'Rename a context',
|
|
165
|
+
args: z.object({
|
|
166
|
+
old: z.string().describe('Current name'),
|
|
167
|
+
new: z.string().describe('New name'),
|
|
168
|
+
}),
|
|
169
|
+
async run(c) {
|
|
170
|
+
const oldName = c.args.old
|
|
171
|
+
const newName = c.args.new
|
|
172
|
+
|
|
173
|
+
if (oldName === 'local') {
|
|
174
|
+
throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (newName === 'local') {
|
|
178
|
+
throw createError(ErrorCode.CONTEXT_PROTECTED, {
|
|
179
|
+
message: 'Cannot rename to "local" — that name is reserved.',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!SLUG_RE.test(newName)) {
|
|
184
|
+
throw createError(ErrorCode.VALIDATION_ERROR, {
|
|
185
|
+
message: `Invalid context name "${newName}". Use only letters, numbers, hyphens, and underscores.`,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const config = await readConfig()
|
|
190
|
+
|
|
191
|
+
if (!(oldName in config.contexts)) {
|
|
192
|
+
throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [oldName] })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (newName in config.contexts) {
|
|
196
|
+
throw createError(ErrorCode.CONTEXT_EXISTS, { params: [newName] })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
config.contexts[newName] = config.contexts[oldName]
|
|
200
|
+
delete config.contexts[oldName]
|
|
201
|
+
|
|
202
|
+
if (config.current_context === oldName) {
|
|
203
|
+
config.current_context = newName
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await writeConfig(config)
|
|
207
|
+
|
|
208
|
+
return { renamed: { from: oldName, to: newName } }
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
export const context = Cli.create('context', {
|
|
213
|
+
description: 'Manage server contexts — named server profiles',
|
|
214
|
+
})
|
|
215
|
+
.command(listCmd)
|
|
216
|
+
.command(addCmd)
|
|
217
|
+
.command(removeCmd)
|
|
218
|
+
.command(useCmd)
|
|
219
|
+
.command(showCmd)
|
|
220
|
+
.command(renameCmd)
|
package/src/commands/init.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { buildSeedBundle } from '../seeds.js'
|
|
|
5
5
|
import { putState } from '../state.js'
|
|
6
6
|
import { sync } from '../sync.js'
|
|
7
7
|
import { getApi } from '../client.js'
|
|
8
|
-
import { readConfig, writeConfig, type Config } from '../config.js'
|
|
8
|
+
import { readConfig, writeConfig, activeContext, isLocalContext, type Config } from '../config.js'
|
|
9
9
|
import { ensureBinary, upgradeServer } from '../daemon.js'
|
|
10
10
|
import type { ApiImportResult } from '../api-types.js'
|
|
11
11
|
|
|
@@ -32,14 +32,18 @@ export const init = Cli.create('init', {
|
|
|
32
32
|
|
|
33
33
|
if (!configExists) {
|
|
34
34
|
const config: Config = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
version: 2,
|
|
36
|
+
current_context: 'local',
|
|
37
|
+
contexts: {
|
|
38
|
+
local: {
|
|
39
|
+
url: 'http://localhost:7742',
|
|
40
|
+
mode: 'local',
|
|
41
|
+
bin: `${brainjarDir}/bin/brainjar-server`,
|
|
42
|
+
pid_file: `${brainjarDir}/server.pid`,
|
|
43
|
+
log_file: `${brainjarDir}/server.log`,
|
|
44
|
+
workspace: 'default',
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
workspace: 'default',
|
|
43
47
|
backend: c.options.backend as Backend,
|
|
44
48
|
}
|
|
45
49
|
await writeConfig(config)
|
|
@@ -48,9 +52,10 @@ export const init = Cli.create('init', {
|
|
|
48
52
|
// 3. Ensure server binary exists and is up to date
|
|
49
53
|
await ensureBinary()
|
|
50
54
|
const config = await readConfig()
|
|
51
|
-
|
|
55
|
+
const ctx = activeContext(config)
|
|
56
|
+
if (isLocalContext(ctx)) {
|
|
52
57
|
// Only upgrade if server isn't already running (can't overwrite a running binary)
|
|
53
|
-
const health = await (await import('../daemon.js')).healthCheck({ timeout: 1000, url:
|
|
58
|
+
const health = await (await import('../daemon.js')).healthCheck({ timeout: 1000, url: ctx.url })
|
|
54
59
|
if (!health.healthy) {
|
|
55
60
|
await upgradeServer()
|
|
56
61
|
}
|
|
@@ -61,7 +66,7 @@ export const init = Cli.create('init', {
|
|
|
61
66
|
|
|
62
67
|
// 5. Ensure workspace exists (ignore conflict if already created)
|
|
63
68
|
try {
|
|
64
|
-
await api.post('/api/v1/workspaces', { name:
|
|
69
|
+
await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
65
70
|
} catch (e: any) {
|
|
66
71
|
if (e.code !== 'CONFLICT') throw e
|
|
67
72
|
}
|
package/src/commands/server.ts
CHANGED
|
@@ -7,19 +7,19 @@ import {
|
|
|
7
7
|
status as daemonStatus,
|
|
8
8
|
ensureRunning,
|
|
9
9
|
readLogFile,
|
|
10
|
-
upgradeServer,
|
|
11
10
|
} from '../daemon.js'
|
|
12
|
-
import { readConfig, writeConfig } from '../config.js'
|
|
11
|
+
import { readConfig, writeConfig, activeContext, localContext, contextNameFromUrl, uniqueContextName } from '../config.js'
|
|
13
12
|
import { getApi } from '../client.js'
|
|
14
13
|
import { sync } from '../sync.js'
|
|
15
14
|
|
|
16
15
|
const { IncurError } = Errors
|
|
17
16
|
import { ErrorCode, createError } from '../errors.js'
|
|
18
17
|
|
|
19
|
-
function
|
|
20
|
-
|
|
18
|
+
function assertLocalContext(config: { current_context: string; contexts: Record<string, { mode: string }> }, action: string) {
|
|
19
|
+
const ctx = config.contexts[config.current_context]
|
|
20
|
+
if (ctx.mode === 'remote') {
|
|
21
21
|
throw createError(ErrorCode.INVALID_MODE, {
|
|
22
|
-
message: `
|
|
22
|
+
message: `Active context is remote. Cannot ${action}.`,
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -35,6 +35,7 @@ const statusCmd = Cli.create('status', {
|
|
|
35
35
|
healthy: s.healthy,
|
|
36
36
|
running: s.running,
|
|
37
37
|
pid: s.pid,
|
|
38
|
+
serverVersion: health.serverVersion ?? null,
|
|
38
39
|
latencyMs: health.latencyMs ?? null,
|
|
39
40
|
}
|
|
40
41
|
},
|
|
@@ -44,12 +45,13 @@ const startCmd = Cli.create('start', {
|
|
|
44
45
|
description: 'Start the local server daemon',
|
|
45
46
|
async run() {
|
|
46
47
|
const config = await readConfig()
|
|
47
|
-
|
|
48
|
+
assertLocalContext(config, 'start')
|
|
49
|
+
const ctx = activeContext(config)
|
|
48
50
|
|
|
49
51
|
const health = await healthCheck({ timeout: 2000 })
|
|
50
52
|
if (health.healthy) {
|
|
51
53
|
const s = await daemonStatus()
|
|
52
|
-
return { already_running: true, pid: s.pid, url:
|
|
54
|
+
return { already_running: true, pid: s.pid, url: ctx.url }
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const { pid } = await start()
|
|
@@ -58,7 +60,7 @@ const startCmd = Cli.create('start', {
|
|
|
58
60
|
while (Date.now() < deadline) {
|
|
59
61
|
await new Promise(r => setTimeout(r, 200))
|
|
60
62
|
const check = await healthCheck({ timeout: 2000 })
|
|
61
|
-
if (check.healthy) return { started: true, pid, url:
|
|
63
|
+
if (check.healthy) return { started: true, pid, url: ctx.url }
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
@@ -71,7 +73,7 @@ const stopCmd = Cli.create('stop', {
|
|
|
71
73
|
description: 'Stop the local server daemon',
|
|
72
74
|
async run() {
|
|
73
75
|
const config = await readConfig()
|
|
74
|
-
|
|
76
|
+
assertLocalContext(config, 'stop')
|
|
75
77
|
|
|
76
78
|
const result = await stop()
|
|
77
79
|
if (!result.stopped) {
|
|
@@ -89,9 +91,10 @@ const logsCmd = Cli.create('logs', {
|
|
|
89
91
|
}),
|
|
90
92
|
async run(c) {
|
|
91
93
|
const config = await readConfig()
|
|
94
|
+
const local = localContext(config)
|
|
92
95
|
|
|
93
96
|
if (c.options.follow) {
|
|
94
|
-
const child = spawn('tail', ['-f', '-n', String(c.options.lines),
|
|
97
|
+
const child = spawn('tail', ['-f', '-n', String(c.options.lines), local.log_file], {
|
|
95
98
|
stdio: 'inherit',
|
|
96
99
|
})
|
|
97
100
|
await new Promise<void>((resolve) => {
|
|
@@ -106,32 +109,31 @@ const logsCmd = Cli.create('logs', {
|
|
|
106
109
|
})
|
|
107
110
|
|
|
108
111
|
const localCmd = Cli.create('local', {
|
|
109
|
-
description: 'Switch to
|
|
112
|
+
description: 'Switch to local server (use `brainjar context use local` instead)',
|
|
110
113
|
async run() {
|
|
111
114
|
const config = await readConfig()
|
|
112
|
-
config.
|
|
113
|
-
config.server.mode = 'local'
|
|
115
|
+
config.current_context = 'local'
|
|
114
116
|
await writeConfig(config)
|
|
115
117
|
|
|
116
118
|
await ensureRunning()
|
|
117
119
|
|
|
118
120
|
const api = await getApi()
|
|
121
|
+
const ctx = activeContext(config)
|
|
119
122
|
|
|
120
|
-
// Ensure workspace exists (ignore conflict if already created)
|
|
121
123
|
try {
|
|
122
|
-
await api.post('/api/v1/workspaces', { name:
|
|
124
|
+
await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
123
125
|
} catch (e: any) {
|
|
124
126
|
if (e.code !== 'CONFLICT') throw e
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
await sync({ api })
|
|
128
130
|
|
|
129
|
-
return { mode: 'local', url:
|
|
131
|
+
return { mode: 'local', url: ctx.url }
|
|
130
132
|
},
|
|
131
133
|
})
|
|
132
134
|
|
|
133
135
|
const remoteCmd = Cli.create('remote', {
|
|
134
|
-
description: 'Switch to a remote server',
|
|
136
|
+
description: 'Switch to a remote server (use `brainjar context add` instead)',
|
|
135
137
|
args: z.object({
|
|
136
138
|
url: z.string().describe('Remote server URL'),
|
|
137
139
|
}),
|
|
@@ -144,15 +146,26 @@ const remoteCmd = Cli.create('remote', {
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
const config = await readConfig()
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
|
|
150
|
+
// Find existing context with this URL, or create one
|
|
151
|
+
let ctxName = Object.entries(config.contexts).find(([, ctx]) => ctx.url === url)?.[0]
|
|
152
|
+
if (!ctxName) {
|
|
153
|
+
ctxName = uniqueContextName(contextNameFromUrl(url), config.contexts)
|
|
154
|
+
config.contexts[ctxName] = {
|
|
155
|
+
url,
|
|
156
|
+
mode: 'remote',
|
|
157
|
+
workspace: 'default',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
config.current_context = ctxName
|
|
149
162
|
await writeConfig(config)
|
|
150
163
|
|
|
151
164
|
const api = await getApi()
|
|
165
|
+
const ctx = activeContext(config)
|
|
152
166
|
|
|
153
|
-
// Ensure workspace exists (ignore conflict if already created)
|
|
154
167
|
try {
|
|
155
|
-
await api.post('/api/v1/workspaces', { name:
|
|
168
|
+
await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
156
169
|
} catch (e: any) {
|
|
157
170
|
if (e.code !== 'CONFLICT') throw e
|
|
158
171
|
}
|
|
@@ -163,43 +176,6 @@ const remoteCmd = Cli.create('remote', {
|
|
|
163
176
|
},
|
|
164
177
|
})
|
|
165
178
|
|
|
166
|
-
const upgradeCmd = Cli.create('upgrade', {
|
|
167
|
-
description: 'Upgrade the server binary to the latest version',
|
|
168
|
-
async run() {
|
|
169
|
-
const config = await readConfig()
|
|
170
|
-
assertLocalMode(config, 'upgrade')
|
|
171
|
-
|
|
172
|
-
// Stop server if running before replacing binary
|
|
173
|
-
const s = await daemonStatus()
|
|
174
|
-
const wasRunning = s.running
|
|
175
|
-
|
|
176
|
-
if (wasRunning) {
|
|
177
|
-
await stop()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const result = await upgradeServer()
|
|
181
|
-
|
|
182
|
-
if (result.alreadyLatest) {
|
|
183
|
-
if (wasRunning) await start()
|
|
184
|
-
return { upgraded: false, version: result.version, message: 'Already on latest version' }
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Restart if it was running
|
|
188
|
-
if (wasRunning) {
|
|
189
|
-
const { pid } = await start()
|
|
190
|
-
const deadline = Date.now() + 10_000
|
|
191
|
-
while (Date.now() < deadline) {
|
|
192
|
-
await new Promise(r => setTimeout(r, 200))
|
|
193
|
-
const check = await healthCheck({ timeout: 2000 })
|
|
194
|
-
if (check.healthy) return { upgraded: true, version: result.version, pid, restarted: true }
|
|
195
|
-
}
|
|
196
|
-
return { upgraded: true, version: result.version, restarted: false, warning: 'Server upgraded but failed health check after restart' }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return { upgraded: true, version: result.version }
|
|
200
|
-
},
|
|
201
|
-
})
|
|
202
|
-
|
|
203
179
|
export const server = Cli.create('server', {
|
|
204
180
|
description: 'Manage the brainjar server',
|
|
205
181
|
})
|
|
@@ -209,4 +185,3 @@ export const server = Cli.create('server', {
|
|
|
209
185
|
.command(logsCmd)
|
|
210
186
|
.command(localCmd)
|
|
211
187
|
.command(remoteCmd)
|
|
212
|
-
.command(upgradeCmd)
|
|
@@ -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/, '').replace(/-.*$/, '').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.2'
|
|
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`',
|
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
healthCheck,
|
|
4
|
+
start,
|
|
5
|
+
stop,
|
|
6
|
+
status as daemonStatus,
|
|
7
|
+
upgradeServer,
|
|
8
|
+
} from './daemon.js'
|
|
9
|
+
import { checkForUpdates } from './version-check.js'
|
|
10
|
+
import { ErrorCode, createError } from './errors.js'
|
|
11
|
+
import pkg from '../package.json'
|
|
12
|
+
|
|
13
|
+
export interface ComponentResult {
|
|
14
|
+
upgraded: boolean
|
|
15
|
+
from: string
|
|
16
|
+
to: string
|
|
17
|
+
message?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ServerResult extends ComponentResult {
|
|
21
|
+
restarted?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UpgradeResult {
|
|
25
|
+
cli?: ComponentResult
|
|
26
|
+
server?: ServerResult
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect which package manager installed brainjar.
|
|
31
|
+
* Checks the runtime binary path first, then falls back to npm.
|
|
32
|
+
*/
|
|
33
|
+
export function detectPackageManager(): 'bun' | 'npm' {
|
|
34
|
+
const argv0 = process.argv[0] ?? ''
|
|
35
|
+
if (argv0.includes('bun')) return 'bun'
|
|
36
|
+
return 'npm'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Shell out to a package manager and capture stdout/stderr. */
|
|
40
|
+
function exec(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
execFile(cmd, args, { timeout: 120_000 }, (error, stdout, stderr) => {
|
|
43
|
+
if (error) reject(Object.assign(error, { stderr }))
|
|
44
|
+
else resolve({ stdout, stderr })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upgrade the CLI npm package to latest.
|
|
51
|
+
*/
|
|
52
|
+
export async function upgradeCli(): Promise<ComponentResult> {
|
|
53
|
+
const currentVersion = pkg.version
|
|
54
|
+
|
|
55
|
+
// Check if already on latest
|
|
56
|
+
const updates = await checkForUpdates(currentVersion)
|
|
57
|
+
if (!updates?.cli) {
|
|
58
|
+
return { upgraded: false, from: currentVersion, to: currentVersion, message: 'Already on latest version' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const latestVersion = updates.cli.latest
|
|
62
|
+
const pm = detectPackageManager()
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (pm === 'bun') {
|
|
66
|
+
await exec('bun', ['install', '-g', `@brainjar/cli@${latestVersion}`])
|
|
67
|
+
} else {
|
|
68
|
+
await exec('npm', ['install', '-g', `@brainjar/cli@${latestVersion}`])
|
|
69
|
+
}
|
|
70
|
+
} catch (e: any) {
|
|
71
|
+
const stderr = e.stderr ?? e.message ?? ''
|
|
72
|
+
const isPermission = stderr.includes('EACCES') || stderr.includes('permission')
|
|
73
|
+
throw createError(ErrorCode.SHELL_ERROR, {
|
|
74
|
+
message: `Failed to upgrade CLI via ${pm}: ${stderr.trim()}`,
|
|
75
|
+
hint: isPermission
|
|
76
|
+
? `Try running with sudo, or fix npm permissions: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally`
|
|
77
|
+
: 'Check your network connection and try again.',
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { upgraded: true, from: currentVersion, to: latestVersion }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Upgrade the server binary. Handles stop/restart lifecycle.
|
|
86
|
+
*/
|
|
87
|
+
export async function upgradeServerBinary(): Promise<ServerResult> {
|
|
88
|
+
const { getInstalledServerVersion } = await import('./version-check.js')
|
|
89
|
+
const installedVersion = (await getInstalledServerVersion()) ?? 'unknown'
|
|
90
|
+
|
|
91
|
+
// Stop server if running before replacing binary
|
|
92
|
+
const s = await daemonStatus()
|
|
93
|
+
const wasRunning = s.running
|
|
94
|
+
|
|
95
|
+
if (wasRunning) {
|
|
96
|
+
await stop()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = await upgradeServer()
|
|
100
|
+
|
|
101
|
+
if (result.alreadyLatest) {
|
|
102
|
+
if (wasRunning) await start()
|
|
103
|
+
return { upgraded: false, from: installedVersion, to: result.version, message: 'Already on latest version' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Restart if it was running
|
|
107
|
+
if (wasRunning) {
|
|
108
|
+
await start()
|
|
109
|
+
const deadline = Date.now() + 10_000
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
await new Promise(r => setTimeout(r, 200))
|
|
112
|
+
const check = await healthCheck({ timeout: 2000 })
|
|
113
|
+
if (check.healthy) {
|
|
114
|
+
return { upgraded: true, from: installedVersion, to: result.version, restarted: true }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { upgraded: true, from: installedVersion, to: result.version, restarted: false, message: 'Upgraded but failed health check after restart' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { upgraded: true, from: installedVersion, to: result.version }
|
|
121
|
+
}
|
package/src/version-check.ts
CHANGED
|
@@ -126,11 +126,11 @@ export async function renderUpdateBanner(currentCliVersion: string): Promise<str
|
|
|
126
126
|
const lines: string[] = []
|
|
127
127
|
|
|
128
128
|
if (updates.cli) {
|
|
129
|
-
lines.push(` ⬆ brainjar ${updates.cli.latest} available (current: ${updates.cli.current}) —
|
|
129
|
+
lines.push(` ⬆ brainjar ${updates.cli.latest} available (current: ${updates.cli.current}) — brainjar upgrade`)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
if (updates.server) {
|
|
133
|
-
lines.push(` ⬆ server ${updates.server.latest} available (current: ${updates.server.current}) — brainjar
|
|
133
|
+
lines.push(` ⬆ server ${updates.server.latest} available (current: ${updates.server.current}) — brainjar upgrade`)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
return lines.length > 0 ? lines.join('\n') : undefined
|