@brainjar/cli 0.4.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # brainjar
2
2
 
3
- [![CI](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
5
5
  [![downloads](https://img.shields.io/npm/dm/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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|upgrade
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.4.1",
3
+ "version": "0.5.0",
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/brainjar-cli.git"
9
+ "url": "https://github.com/brainjar-sh/cli.git"
10
10
  },
11
11
  "homepage": "https://brainjar.sh",
12
- "bugs": "https://github.com/brainjar-sh/brainjar-cli/issues",
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 serverUrl = (options?.serverUrl ?? config.server.url).replace(/\/$/, '')
59
- const workspace = options?.workspace ?? config.workspace
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 = config.server.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)
@@ -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
- server: {
36
- url: 'http://localhost:7742',
37
- mode: 'local',
38
- bin: `${brainjarDir}/bin/brainjar-server`,
39
- pid_file: `${brainjarDir}/server.pid`,
40
- log_file: `${brainjarDir}/server.log`,
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
- if (config.server.mode === 'local') {
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: config.server.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: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
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
  }
@@ -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 assertLocalMode(config: { server: { mode: string } }, action: string) {
20
- if (config.server.mode === 'remote') {
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: `Server is in remote mode. Cannot ${action}.`,
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
- assertLocalMode(config, 'start')
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: config.server.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: config.server.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
- assertLocalMode(config, 'stop')
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), config.server.log_file], {
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 managed local server',
112
+ description: 'Switch to local server (use `brainjar context use local` instead)',
110
113
  async run() {
111
114
  const config = await readConfig()
112
- config.server.url = 'http://localhost:7742'
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: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
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: config.server.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
- config.server.url = url
148
- config.server.mode = 'remote'
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: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
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
- export interface ServerConfig {
7
+ // ─── Context types ──────────────────────────────────────────────────────────
8
+
9
+ export interface LocalContext {
8
10
  url: string
9
- mode: 'local' | 'remote'
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 Config {
16
- server: ServerConfig
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
- function defaults(): Config {
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
- 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
- },
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
- const def = defaults()
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 (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
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(config)
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) config.server.url = 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) config.workspace = 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
- 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,
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 url = options?.url ?? config.server.url
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 binPath = config.server.bin
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 binPath = config.server.bin
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 { bin, pid_file, log_file, url } = config.server
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.server
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 { mode, url, pid_file } = config.server
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.server.log_file, 'utf-8')
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 { mode, url } = config.server
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) return
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 server remote <url>'.`,
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(config.server.pid_file)
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 ${config.server.log_file}`,
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) return
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 ${config.server.log_file}`,
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
+ }
@@ -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}) — npm update -g @brainjar/cli`)
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 server upgrade`)
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