@brainjar/cli 0.3.0 → 0.4.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 +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +65 -40
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +97 -145
- package/src/commands/rules.ts +71 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +53 -46
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +36 -41
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/commands/rules.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Cli, z, Errors } from 'incur'
|
|
2
|
+
import { basename } from 'node:path'
|
|
2
3
|
|
|
3
4
|
const { IncurError } = Errors
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { paths } from '../paths.js'
|
|
7
|
-
import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, normalizeSlug, listAvailableRules } from '../state.js'
|
|
5
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
6
|
+
import { normalizeSlug, getEffectiveState, putState } from '../state.js'
|
|
8
7
|
import { sync } from '../sync.js'
|
|
8
|
+
import { getApi } from '../client.js'
|
|
9
|
+
import type { ApiRule, ApiRuleList } from '../api-types.js'
|
|
9
10
|
|
|
10
11
|
export const rules = Cli.create('rules', {
|
|
11
12
|
description: 'Manage rules — behavioral constraints for the agent',
|
|
@@ -13,66 +14,23 @@ export const rules = Cli.create('rules', {
|
|
|
13
14
|
.command('create', {
|
|
14
15
|
description: 'Create a new rule',
|
|
15
16
|
args: z.object({
|
|
16
|
-
name: z.string().describe('Rule name
|
|
17
|
+
name: z.string().describe('Rule name'),
|
|
17
18
|
}),
|
|
18
19
|
options: z.object({
|
|
19
20
|
description: z.string().optional().describe('One-line description of the rule'),
|
|
20
|
-
pack: z.boolean().default(false).describe('Create as a rule pack (
|
|
21
|
+
pack: z.boolean().default(false).describe('Create as a rule pack (multiple entries)'),
|
|
21
22
|
}),
|
|
22
23
|
async run(c) {
|
|
23
|
-
await requireBrainjarDir()
|
|
24
24
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
25
|
+
const api = await getApi()
|
|
25
26
|
|
|
26
|
-
if
|
|
27
|
-
const dirPath = join(paths.rules, name)
|
|
28
|
-
try {
|
|
29
|
-
await access(dirPath)
|
|
30
|
-
throw new IncurError({
|
|
31
|
-
code: 'RULE_EXISTS',
|
|
32
|
-
message: `Rule "${name}" already exists.`,
|
|
33
|
-
hint: 'Choose a different name or edit the existing files.',
|
|
34
|
-
})
|
|
35
|
-
} catch (e) {
|
|
36
|
-
if (e instanceof IncurError) throw e
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
await mkdir(dirPath, { recursive: true })
|
|
40
|
-
|
|
41
|
-
const scaffold = [
|
|
42
|
-
`# ${name}`,
|
|
43
|
-
'',
|
|
44
|
-
c.options.description ?? 'Describe what this rule enforces and why.',
|
|
45
|
-
'',
|
|
46
|
-
'## Constraints',
|
|
47
|
-
'- ',
|
|
48
|
-
'',
|
|
49
|
-
].join('\n')
|
|
50
|
-
|
|
51
|
-
await writeFile(join(dirPath, `${name}.md`), scaffold)
|
|
52
|
-
|
|
53
|
-
if (c.agent || c.formatExplicit) {
|
|
54
|
-
return { created: dirPath, name, pack: true, template: scaffold }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
created: dirPath,
|
|
59
|
-
name,
|
|
60
|
-
pack: true,
|
|
61
|
-
template: `\n${scaffold}`,
|
|
62
|
-
next: `Edit ${join(dirPath, `${name}.md`)} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const dest = join(paths.rules, `${name}.md`)
|
|
27
|
+
// Check if it already exists
|
|
67
28
|
try {
|
|
68
|
-
await
|
|
69
|
-
throw
|
|
70
|
-
code: 'RULE_EXISTS',
|
|
71
|
-
message: `Rule "${name}" already exists.`,
|
|
72
|
-
hint: 'Choose a different name or edit the existing file.',
|
|
73
|
-
})
|
|
29
|
+
await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
30
|
+
throw createError(ErrorCode.RULE_EXISTS, { params: [name] })
|
|
74
31
|
} catch (e) {
|
|
75
|
-
if (e instanceof IncurError) throw e
|
|
32
|
+
if (e instanceof IncurError && e.code === ErrorCode.RULE_EXISTS) throw e
|
|
33
|
+
if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
|
|
76
34
|
}
|
|
77
35
|
|
|
78
36
|
const scaffold = [
|
|
@@ -85,44 +43,48 @@ export const rules = Cli.create('rules', {
|
|
|
85
43
|
'',
|
|
86
44
|
].join('\n')
|
|
87
45
|
|
|
88
|
-
await
|
|
46
|
+
await api.put<ApiRule>(`/api/v1/rules/${name}`, {
|
|
47
|
+
entries: [{ name: `${name}.md`, content: scaffold }],
|
|
48
|
+
})
|
|
89
49
|
|
|
90
50
|
if (c.agent || c.formatExplicit) {
|
|
91
|
-
return { created:
|
|
51
|
+
return { created: name, name, pack: c.options.pack, template: scaffold }
|
|
92
52
|
}
|
|
93
53
|
|
|
94
54
|
return {
|
|
95
|
-
created:
|
|
55
|
+
created: name,
|
|
96
56
|
name,
|
|
57
|
+
pack: c.options.pack,
|
|
97
58
|
template: `\n${scaffold}`,
|
|
98
|
-
next: `
|
|
59
|
+
next: `Run \`brainjar rules show ${name}\` to view, then \`brainjar rules add ${name}\` to activate.`,
|
|
99
60
|
}
|
|
100
61
|
},
|
|
101
62
|
})
|
|
102
63
|
.command('list', {
|
|
103
64
|
description: 'List available and active rules',
|
|
104
65
|
options: z.object({
|
|
105
|
-
|
|
66
|
+
project: z.boolean().default(false).describe('Show project rules delta only'),
|
|
106
67
|
}),
|
|
107
68
|
async run(c) {
|
|
108
|
-
await
|
|
69
|
+
const api = await getApi()
|
|
70
|
+
const available = await api.get<ApiRuleList>('/api/v1/rules')
|
|
71
|
+
const availableSlugs = available.rules.map(r => r.slug)
|
|
109
72
|
|
|
110
|
-
if (c.options.
|
|
111
|
-
const
|
|
112
|
-
|
|
73
|
+
if (c.options.project) {
|
|
74
|
+
const override = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
|
|
75
|
+
project: basename(process.cwd()),
|
|
76
|
+
})
|
|
113
77
|
return {
|
|
114
|
-
add:
|
|
115
|
-
remove:
|
|
116
|
-
available,
|
|
117
|
-
scope: '
|
|
78
|
+
add: override.rules_to_add ?? [],
|
|
79
|
+
remove: override.rules_to_remove ?? [],
|
|
80
|
+
available: availableSlugs,
|
|
81
|
+
scope: 'project',
|
|
118
82
|
}
|
|
119
83
|
}
|
|
120
84
|
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
const active = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
|
|
125
|
-
return { active, available, rules: effective.rules }
|
|
85
|
+
const state = await getEffectiveState(api)
|
|
86
|
+
const active = state.rules
|
|
87
|
+
return { active, available: availableSlugs, rules: state.rules }
|
|
126
88
|
},
|
|
127
89
|
})
|
|
128
90
|
.command('show', {
|
|
@@ -131,98 +93,52 @@ export const rules = Cli.create('rules', {
|
|
|
131
93
|
name: z.string().describe('Rule name to show'),
|
|
132
94
|
}),
|
|
133
95
|
async run(c) {
|
|
134
|
-
await requireBrainjarDir()
|
|
135
96
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
136
|
-
const
|
|
137
|
-
const filePath = join(paths.rules, `${name}.md`)
|
|
97
|
+
const api = await getApi()
|
|
138
98
|
|
|
139
|
-
// Try directory of .md files first
|
|
140
99
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const content = await readFile(join(dirPath, file), 'utf-8')
|
|
148
|
-
sections.push(content.trim())
|
|
149
|
-
}
|
|
150
|
-
return { name, content: sections.join('\n\n') }
|
|
100
|
+
const rule = await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
101
|
+
const content = rule.entries.map(e => e.content.trim()).join('\n\n')
|
|
102
|
+
return { name, content }
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
105
|
+
throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
|
|
151
106
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Try single .md file
|
|
155
|
-
try {
|
|
156
|
-
const content = await readFile(filePath, 'utf-8')
|
|
157
|
-
return { name, content: content.trim() }
|
|
158
|
-
} catch {}
|
|
159
|
-
|
|
160
|
-
throw new IncurError({
|
|
161
|
-
code: 'RULE_NOT_FOUND',
|
|
162
|
-
message: `Rule "${name}" not found.`,
|
|
163
|
-
hint: 'Run `brainjar rules list` to see available rules.',
|
|
164
|
-
})
|
|
107
|
+
throw e
|
|
108
|
+
}
|
|
165
109
|
},
|
|
166
110
|
})
|
|
167
111
|
.command('add', {
|
|
168
112
|
description: 'Activate a rule or rule pack',
|
|
169
113
|
args: z.object({
|
|
170
|
-
name: z.string().describe('Rule name
|
|
114
|
+
name: z.string().describe('Rule name to activate'),
|
|
171
115
|
}),
|
|
172
116
|
options: z.object({
|
|
173
|
-
|
|
117
|
+
project: z.boolean().default(false).describe('Add rule at project scope'),
|
|
174
118
|
}),
|
|
175
119
|
async run(c) {
|
|
176
|
-
await requireBrainjarDir()
|
|
177
120
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
178
|
-
|
|
179
|
-
const dirPath = join(paths.rules, name)
|
|
180
|
-
const filePath = join(paths.rules, `${name}.md`)
|
|
181
|
-
let found = false
|
|
121
|
+
const api = await getApi()
|
|
182
122
|
|
|
123
|
+
// Validate it exists on server
|
|
183
124
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await readFile(filePath, 'utf-8')
|
|
191
|
-
found = true
|
|
192
|
-
} catch {}
|
|
125
|
+
await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
128
|
+
throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
|
|
129
|
+
}
|
|
130
|
+
throw e
|
|
193
131
|
}
|
|
194
132
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
hint: 'Place .md files or directories in ~/.brainjar/rules/',
|
|
200
|
-
})
|
|
201
|
-
}
|
|
133
|
+
const mutationOpts = c.options.project
|
|
134
|
+
? { project: basename(process.cwd()) }
|
|
135
|
+
: undefined
|
|
136
|
+
await putState(api, { rules_to_add: [name] }, mutationOpts)
|
|
202
137
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const local = await readLocalState()
|
|
206
|
-
const adds = local.rules?.add ?? []
|
|
207
|
-
if (!adds.includes(name)) adds.push(name)
|
|
208
|
-
// Also remove from local removes if present
|
|
209
|
-
const removes = (local.rules?.remove ?? []).filter(r => r !== name)
|
|
210
|
-
local.rules = { add: adds, ...(removes.length ? { remove: removes } : {}) }
|
|
211
|
-
await writeLocalState(local)
|
|
212
|
-
await sync({ local: true })
|
|
213
|
-
})
|
|
214
|
-
} else {
|
|
215
|
-
await withStateLock(async () => {
|
|
216
|
-
const state = await readState()
|
|
217
|
-
if (!state.rules.includes(name)) {
|
|
218
|
-
state.rules.push(name)
|
|
219
|
-
await writeState(state)
|
|
220
|
-
}
|
|
221
|
-
await sync()
|
|
222
|
-
})
|
|
223
|
-
}
|
|
138
|
+
await sync({ api })
|
|
139
|
+
if (c.options.project) await sync({ api, project: true })
|
|
224
140
|
|
|
225
|
-
return { activated: name,
|
|
141
|
+
return { activated: name, project: c.options.project }
|
|
226
142
|
},
|
|
227
143
|
})
|
|
228
144
|
.command('remove', {
|
|
@@ -231,39 +147,20 @@ export const rules = Cli.create('rules', {
|
|
|
231
147
|
name: z.string().describe('Rule name to remove'),
|
|
232
148
|
}),
|
|
233
149
|
options: z.object({
|
|
234
|
-
|
|
150
|
+
project: z.boolean().default(false).describe('Remove rule at project scope'),
|
|
235
151
|
}),
|
|
236
152
|
async run(c) {
|
|
237
|
-
await requireBrainjarDir()
|
|
238
153
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
154
|
+
const api = await getApi()
|
|
239
155
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
local.rules = { ...(adds.length ? { add: adds } : {}), remove: removes }
|
|
248
|
-
await writeLocalState(local)
|
|
249
|
-
await sync({ local: true })
|
|
250
|
-
})
|
|
251
|
-
} else {
|
|
252
|
-
await withStateLock(async () => {
|
|
253
|
-
const state = await readState()
|
|
254
|
-
if (!state.rules.includes(name)) {
|
|
255
|
-
throw new IncurError({
|
|
256
|
-
code: 'RULE_NOT_ACTIVE',
|
|
257
|
-
message: `Rule "${name}" is not active.`,
|
|
258
|
-
hint: 'Run `brainjar rules list` to see active rules.',
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
state.rules = state.rules.filter(r => r !== name)
|
|
262
|
-
await writeState(state)
|
|
263
|
-
await sync()
|
|
264
|
-
})
|
|
265
|
-
}
|
|
156
|
+
const mutationOpts = c.options.project
|
|
157
|
+
? { project: basename(process.cwd()) }
|
|
158
|
+
: undefined
|
|
159
|
+
await putState(api, { rules_to_remove: [name] }, mutationOpts)
|
|
160
|
+
|
|
161
|
+
await sync({ api })
|
|
162
|
+
if (c.options.project) await sync({ api, project: true })
|
|
266
163
|
|
|
267
|
-
return { removed: name,
|
|
164
|
+
return { removed: name, project: c.options.project }
|
|
268
165
|
},
|
|
269
166
|
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Cli, z, Errors } from 'incur'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
healthCheck,
|
|
5
|
+
start,
|
|
6
|
+
stop,
|
|
7
|
+
status as daemonStatus,
|
|
8
|
+
ensureRunning,
|
|
9
|
+
readLogFile,
|
|
10
|
+
upgradeServer,
|
|
11
|
+
} from '../daemon.js'
|
|
12
|
+
import { readConfig, writeConfig } from '../config.js'
|
|
13
|
+
import { getApi } from '../client.js'
|
|
14
|
+
import { sync } from '../sync.js'
|
|
15
|
+
|
|
16
|
+
const { IncurError } = Errors
|
|
17
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
18
|
+
|
|
19
|
+
function assertLocalMode(config: { server: { mode: string } }, action: string) {
|
|
20
|
+
if (config.server.mode === 'remote') {
|
|
21
|
+
throw createError(ErrorCode.INVALID_MODE, {
|
|
22
|
+
message: `Server is in remote mode. Cannot ${action}.`,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const statusCmd = Cli.create('status', {
|
|
28
|
+
description: 'Show server status',
|
|
29
|
+
async run() {
|
|
30
|
+
const s = await daemonStatus()
|
|
31
|
+
const health = await healthCheck({ timeout: 2000 })
|
|
32
|
+
return {
|
|
33
|
+
mode: s.mode,
|
|
34
|
+
url: s.url,
|
|
35
|
+
healthy: s.healthy,
|
|
36
|
+
running: s.running,
|
|
37
|
+
pid: s.pid,
|
|
38
|
+
latencyMs: health.latencyMs ?? null,
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const startCmd = Cli.create('start', {
|
|
44
|
+
description: 'Start the local server daemon',
|
|
45
|
+
async run() {
|
|
46
|
+
const config = await readConfig()
|
|
47
|
+
assertLocalMode(config, 'start')
|
|
48
|
+
|
|
49
|
+
const health = await healthCheck({ timeout: 2000 })
|
|
50
|
+
if (health.healthy) {
|
|
51
|
+
const s = await daemonStatus()
|
|
52
|
+
return { already_running: true, pid: s.pid, url: config.server.url }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { pid } = await start()
|
|
56
|
+
|
|
57
|
+
const deadline = Date.now() + 10_000
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
await new Promise(r => setTimeout(r, 200))
|
|
60
|
+
const check = await healthCheck({ timeout: 2000 })
|
|
61
|
+
if (check.healthy) return { started: true, pid, url: config.server.url }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
65
|
+
message: 'Server started but failed health check after 10s.',
|
|
66
|
+
})
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const stopCmd = Cli.create('stop', {
|
|
71
|
+
description: 'Stop the local server daemon',
|
|
72
|
+
async run() {
|
|
73
|
+
const config = await readConfig()
|
|
74
|
+
assertLocalMode(config, 'stop')
|
|
75
|
+
|
|
76
|
+
const result = await stop()
|
|
77
|
+
if (!result.stopped) {
|
|
78
|
+
return { stopped: false, reason: 'not running' }
|
|
79
|
+
}
|
|
80
|
+
return { stopped: true }
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const logsCmd = Cli.create('logs', {
|
|
85
|
+
description: 'Show server logs',
|
|
86
|
+
options: z.object({
|
|
87
|
+
lines: z.number().default(50).describe('Number of lines to show'),
|
|
88
|
+
follow: z.boolean().default(false).describe('Follow log output'),
|
|
89
|
+
}),
|
|
90
|
+
async run(c) {
|
|
91
|
+
const config = await readConfig()
|
|
92
|
+
|
|
93
|
+
if (c.options.follow) {
|
|
94
|
+
const child = spawn('tail', ['-f', '-n', String(c.options.lines), config.server.log_file], {
|
|
95
|
+
stdio: 'inherit',
|
|
96
|
+
})
|
|
97
|
+
await new Promise<void>((resolve) => {
|
|
98
|
+
child.on('close', () => resolve())
|
|
99
|
+
})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const content = await readLogFile({ lines: c.options.lines })
|
|
104
|
+
return content || 'No logs found.'
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const localCmd = Cli.create('local', {
|
|
109
|
+
description: 'Switch to managed local server',
|
|
110
|
+
async run() {
|
|
111
|
+
const config = await readConfig()
|
|
112
|
+
config.server.url = 'http://localhost:7742'
|
|
113
|
+
config.server.mode = 'local'
|
|
114
|
+
await writeConfig(config)
|
|
115
|
+
|
|
116
|
+
await ensureRunning()
|
|
117
|
+
|
|
118
|
+
const api = await getApi()
|
|
119
|
+
|
|
120
|
+
// Ensure workspace exists (ignore conflict if already created)
|
|
121
|
+
try {
|
|
122
|
+
await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
if (e.code !== 'CONFLICT') throw e
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await sync({ api })
|
|
128
|
+
|
|
129
|
+
return { mode: 'local', url: config.server.url }
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const remoteCmd = Cli.create('remote', {
|
|
134
|
+
description: 'Switch to a remote server',
|
|
135
|
+
args: z.object({
|
|
136
|
+
url: z.string().describe('Remote server URL'),
|
|
137
|
+
}),
|
|
138
|
+
async run(c) {
|
|
139
|
+
const url = c.args.url.replace(/\/$/, '')
|
|
140
|
+
|
|
141
|
+
const health = await healthCheck({ url, timeout: 5000 })
|
|
142
|
+
if (!health.healthy) {
|
|
143
|
+
throw createError(ErrorCode.SERVER_UNREACHABLE, { params: [url] })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const config = await readConfig()
|
|
147
|
+
config.server.url = url
|
|
148
|
+
config.server.mode = 'remote'
|
|
149
|
+
await writeConfig(config)
|
|
150
|
+
|
|
151
|
+
const api = await getApi()
|
|
152
|
+
|
|
153
|
+
// Ensure workspace exists (ignore conflict if already created)
|
|
154
|
+
try {
|
|
155
|
+
await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
if (e.code !== 'CONFLICT') throw e
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await sync({ api })
|
|
161
|
+
|
|
162
|
+
return { mode: 'remote', url }
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
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
|
+
export const server = Cli.create('server', {
|
|
204
|
+
description: 'Manage the brainjar server',
|
|
205
|
+
})
|
|
206
|
+
.command(statusCmd)
|
|
207
|
+
.command(startCmd)
|
|
208
|
+
.command(stopCmd)
|
|
209
|
+
.command(logsCmd)
|
|
210
|
+
.command(localCmd)
|
|
211
|
+
.command(remoteCmd)
|
|
212
|
+
.command(upgradeCmd)
|