broods 0.1.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/LICENSE.md +105 -0
- package/README.md +41 -0
- package/dist/cli/index.js +3204 -0
- package/dist/index.d.ts +1008 -0
- package/dist/index.js +845 -0
- package/package.json +53 -0
|
@@ -0,0 +1,3204 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/cli/index.ts
|
|
5
|
+
import { mkdir as mkdir3, readFile as readFile3, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
|
|
6
|
+
import { createHash as createHash2 } from "crypto";
|
|
7
|
+
import { basename as basename2, dirname as dirname2, join as join4, relative as relative2, resolve as resolve4 } from "path";
|
|
8
|
+
import { watch } from "fs";
|
|
9
|
+
import { spawn as spawn2 } from "child_process";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { performance } from "perf_hooks";
|
|
12
|
+
|
|
13
|
+
// src/manifest.ts
|
|
14
|
+
import { pathToFileURL } from "url";
|
|
15
|
+
import { createHash } from "crypto";
|
|
16
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
17
|
+
import { basename, isAbsolute, join as join3, relative, resolve as resolve2 } from "path";
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
21
|
+
import { homedir } from "os";
|
|
22
|
+
import { dirname, join } from "path";
|
|
23
|
+
var PROJECT_DIR = "broods";
|
|
24
|
+
var GENERATED_DIR = "_generated";
|
|
25
|
+
var USER_CONFIG_PATH = join(homedir(), ".broods", "config.json");
|
|
26
|
+
async function readStoredAuth() {
|
|
27
|
+
const envToken = process.env.BROODS_TOKEN;
|
|
28
|
+
const envUrl = process.env.BROODS_DASHBOARD_URL;
|
|
29
|
+
if (envToken && envUrl) {
|
|
30
|
+
return {
|
|
31
|
+
dashboardUrl: envUrl,
|
|
32
|
+
token: envToken,
|
|
33
|
+
createdAt: new Date().toISOString()
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(await readFile(USER_CONFIG_PATH, "utf8"));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function writeStoredAuth(config) {
|
|
43
|
+
await mkdir(dirname(USER_CONFIG_PATH), { recursive: true });
|
|
44
|
+
await writeFile(USER_CONFIG_PATH, `${JSON.stringify(config, null, 2)}
|
|
45
|
+
`, "utf8");
|
|
46
|
+
}
|
|
47
|
+
function stripTrailingSlash(value) {
|
|
48
|
+
return value.replace(/\/+$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/runtime-config.ts
|
|
52
|
+
import { existsSync, readFileSync } from "fs";
|
|
53
|
+
import { join as join2, resolve } from "path";
|
|
54
|
+
var loadedEnvForCwd = null;
|
|
55
|
+
function loadBroodsRuntimeConfig(cwd = process.cwd()) {
|
|
56
|
+
loadEnvFiles(cwd);
|
|
57
|
+
const stored = readStoredAuthSync();
|
|
58
|
+
return {
|
|
59
|
+
dashboardUrl: process.env.BROODS_DASHBOARD_URL ?? stored?.dashboardUrl,
|
|
60
|
+
token: process.env.BROODS_TOKEN ?? stored?.token,
|
|
61
|
+
project: process.env.BROODS_PROJECT,
|
|
62
|
+
environment: process.env.BROODS_ENVIRONMENT
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function loadEnvFiles(cwd) {
|
|
66
|
+
const root = resolve(cwd);
|
|
67
|
+
if (loadedEnvForCwd === root)
|
|
68
|
+
return;
|
|
69
|
+
loadedEnvForCwd = root;
|
|
70
|
+
const originallySet = new Set(Object.keys(process.env));
|
|
71
|
+
for (const file of [".env", ".env.local"]) {
|
|
72
|
+
const path = join2(root, file);
|
|
73
|
+
if (!existsSync(path))
|
|
74
|
+
continue;
|
|
75
|
+
for (const [key, value] of Object.entries(parseEnv(readFileSync(path, "utf8")))) {
|
|
76
|
+
if (!originallySet.has(key))
|
|
77
|
+
process.env[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parseEnv(source) {
|
|
82
|
+
const values = {};
|
|
83
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
84
|
+
const line = rawLine.trim();
|
|
85
|
+
if (!line || line.startsWith("#"))
|
|
86
|
+
continue;
|
|
87
|
+
const assignment = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
88
|
+
const eq = assignment.indexOf("=");
|
|
89
|
+
if (eq <= 0)
|
|
90
|
+
continue;
|
|
91
|
+
const key = assignment.slice(0, eq).trim();
|
|
92
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
93
|
+
continue;
|
|
94
|
+
values[key] = unquoteEnvValue(assignment.slice(eq + 1).trim());
|
|
95
|
+
}
|
|
96
|
+
return values;
|
|
97
|
+
}
|
|
98
|
+
function unquoteEnvValue(value) {
|
|
99
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
100
|
+
return value.slice(1, -1).replace(/\\n/g, `
|
|
101
|
+
`).replace(/\\"/g, '"');
|
|
102
|
+
}
|
|
103
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
104
|
+
return value.slice(1, -1);
|
|
105
|
+
}
|
|
106
|
+
const commentIndex = value.indexOf(" #");
|
|
107
|
+
return commentIndex >= 0 ? value.slice(0, commentIndex).trimEnd() : value;
|
|
108
|
+
}
|
|
109
|
+
function readStoredAuthSync() {
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(readFileSync(USER_CONFIG_PATH, "utf8"));
|
|
112
|
+
if (typeof value.dashboardUrl !== "string" || typeof value.token !== "string")
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
dashboardUrl: stripTrailingSlash(value.dashboardUrl),
|
|
116
|
+
token: value.token
|
|
117
|
+
};
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/resources.ts
|
|
124
|
+
var RESOURCE_MARKER = Symbol.for("broods.resource");
|
|
125
|
+
var CONFIG_MARKER = Symbol.for("broods.config");
|
|
126
|
+
var CHANNEL_MARKER = Symbol.for("broods.channel");
|
|
127
|
+
var env = new Proxy(function env2(name) {
|
|
128
|
+
return { __beeblastEnv: true, name };
|
|
129
|
+
}, {
|
|
130
|
+
get(target, property, receiver) {
|
|
131
|
+
if (typeof property === "string")
|
|
132
|
+
return { __beeblastEnv: true, name: property };
|
|
133
|
+
return Reflect.get(target, property, receiver);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
function defineResource(kind, input) {
|
|
137
|
+
if (input.config === undefined) {
|
|
138
|
+
throw new Error(`Resource "${input.name}" must include config`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
[RESOURCE_MARKER]: true,
|
|
142
|
+
kind,
|
|
143
|
+
name: input.name,
|
|
144
|
+
...input.description ? { description: input.description } : {},
|
|
145
|
+
config: input.config
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function defineChannel(type, config) {
|
|
149
|
+
return {
|
|
150
|
+
[CHANNEL_MARKER]: true,
|
|
151
|
+
kind: "channel",
|
|
152
|
+
type,
|
|
153
|
+
config
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function defineTelegramChannel(config) {
|
|
157
|
+
return defineChannel("telegram", config);
|
|
158
|
+
}
|
|
159
|
+
function defineGitHubChannel(config) {
|
|
160
|
+
return defineChannel("github", config);
|
|
161
|
+
}
|
|
162
|
+
function defineSlackChannel(config) {
|
|
163
|
+
return defineChannel("slack", config);
|
|
164
|
+
}
|
|
165
|
+
function defineDiscordChannel(config) {
|
|
166
|
+
return defineChannel("discord", config);
|
|
167
|
+
}
|
|
168
|
+
function definePancakeChannel(config) {
|
|
169
|
+
const { ignoreTagIds, ...channelConfig } = config;
|
|
170
|
+
return defineChannel("pancake", {
|
|
171
|
+
...channelConfig,
|
|
172
|
+
...ignoreTagIds === undefined ? {} : { options: { ignoreTagIds } }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function defineZaloChannel(config) {
|
|
176
|
+
return defineChannel("zalo", config);
|
|
177
|
+
}
|
|
178
|
+
function defineBroods(config) {
|
|
179
|
+
return { [CONFIG_MARKER]: true, config };
|
|
180
|
+
}
|
|
181
|
+
function defineAgent(input) {
|
|
182
|
+
return defineResource("agent", input);
|
|
183
|
+
}
|
|
184
|
+
function defineWorkspace(input) {
|
|
185
|
+
return defineResource("workspace", input);
|
|
186
|
+
}
|
|
187
|
+
function defineSandbox(input) {
|
|
188
|
+
return defineResource("sandbox", input);
|
|
189
|
+
}
|
|
190
|
+
function defineSkill(input) {
|
|
191
|
+
return defineResource("skill", input);
|
|
192
|
+
}
|
|
193
|
+
function defineTool(input) {
|
|
194
|
+
return defineResource("tool", input);
|
|
195
|
+
}
|
|
196
|
+
function defineCron(input) {
|
|
197
|
+
return defineResource("cron", input);
|
|
198
|
+
}
|
|
199
|
+
function isResource(value) {
|
|
200
|
+
return Boolean(value && typeof value === "object" && value[RESOURCE_MARKER]);
|
|
201
|
+
}
|
|
202
|
+
function isChannelDefinition(value) {
|
|
203
|
+
return Boolean(value && typeof value === "object" && value[CHANNEL_MARKER]);
|
|
204
|
+
}
|
|
205
|
+
function isBroodsConfig(value) {
|
|
206
|
+
return Boolean(value && typeof value === "object" && value[CONFIG_MARKER]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/manifest.ts
|
|
210
|
+
var MAX_BUNDLE_FILE_BYTES = 1e6;
|
|
211
|
+
var MAX_BUNDLE_TOTAL_BYTES = 5000000;
|
|
212
|
+
var MAX_BUNDLE_FILES = 200;
|
|
213
|
+
var SKIPPED_BUNDLE_DIRECTORIES = new Set(["node_modules", ".git"]);
|
|
214
|
+
var UNSAFE_BUNDLE_FILE_NAMES = [
|
|
215
|
+
/^\.env(?:\.|$)/i,
|
|
216
|
+
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$/i,
|
|
217
|
+
/\.(?:pem|key|p12|pfx)$/i
|
|
218
|
+
];
|
|
219
|
+
async function compileProject(options = {}) {
|
|
220
|
+
const cwd = options.cwd ?? process.cwd();
|
|
221
|
+
loadBroodsRuntimeConfig(cwd);
|
|
222
|
+
const root = resolve2(cwd, PROJECT_DIR);
|
|
223
|
+
const files = await listTypeScriptFiles(root);
|
|
224
|
+
const exports = await loadExports(files);
|
|
225
|
+
const config = await findConfig(exports, cwd, options.project);
|
|
226
|
+
const resourceExports = exports.filter((entry) => isResource(entry.value)).map((entry) => ({ exportName: entry.exportName, resource: entry.value }));
|
|
227
|
+
const resources = resourceExports.map((entry) => entry.resource);
|
|
228
|
+
assertUniqueResources(resources);
|
|
229
|
+
const channels = compileChannels(resourceExports, exports);
|
|
230
|
+
for (const resource of resources)
|
|
231
|
+
assertKnownConfigKeys(resource);
|
|
232
|
+
for (const resource of resources)
|
|
233
|
+
assertSupportedWorkspaceStorage(resource);
|
|
234
|
+
assertSupportedWorkspaceSandboxMounts(resources);
|
|
235
|
+
const resourceAliases = aliasesForResources(resourceExports);
|
|
236
|
+
const environment = resolveEnvironment(config, options.environment ?? (options.useRuntimeEnvironment === false ? undefined : process.env.BROODS_ENVIRONMENT), options.command ?? "dev");
|
|
237
|
+
const manifestResources = (await Promise.all(resources.map((resource) => toManifestResource(resource, root)))).sort((a, b) => `${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`));
|
|
238
|
+
return {
|
|
239
|
+
config,
|
|
240
|
+
resources,
|
|
241
|
+
resourceAliases,
|
|
242
|
+
channels,
|
|
243
|
+
manifest: {
|
|
244
|
+
version: 1,
|
|
245
|
+
project: config.project,
|
|
246
|
+
environment,
|
|
247
|
+
resources: manifestResources
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function collectEnvRefNames(manifest) {
|
|
252
|
+
const names = new Set;
|
|
253
|
+
function walk(value) {
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
for (const entry of value)
|
|
256
|
+
walk(entry);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (value && typeof value === "object") {
|
|
260
|
+
const record = value;
|
|
261
|
+
if (record.__beeblastEnv === true && typeof record.name === "string") {
|
|
262
|
+
names.add(record.name);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const entry of Object.values(record))
|
|
266
|
+
walk(entry);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const resource of manifest.resources)
|
|
270
|
+
walk(resource.config);
|
|
271
|
+
return [...names].sort();
|
|
272
|
+
}
|
|
273
|
+
function resolveEnvironment(config, explicit, command) {
|
|
274
|
+
if (explicit)
|
|
275
|
+
return explicit;
|
|
276
|
+
const configured = config.environments?.[command];
|
|
277
|
+
if (configured)
|
|
278
|
+
return configured;
|
|
279
|
+
return command === "deploy" ? "production" : "development";
|
|
280
|
+
}
|
|
281
|
+
async function listTypeScriptFiles(root) {
|
|
282
|
+
const results = [];
|
|
283
|
+
async function walk(dir) {
|
|
284
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
const full = join3(dir, entry.name);
|
|
287
|
+
if (entry.isDirectory()) {
|
|
288
|
+
if (entry.name === GENERATED_DIR || entry.name === "generated")
|
|
289
|
+
continue;
|
|
290
|
+
await walk(full);
|
|
291
|
+
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
|
|
292
|
+
results.push(full);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
await walk(root);
|
|
297
|
+
return results.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
|
|
298
|
+
}
|
|
299
|
+
async function loadExports(files) {
|
|
300
|
+
const values = [];
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const href = `${pathToFileURL(file).href}?t=${Date.now()}`;
|
|
303
|
+
const mod = await import(href);
|
|
304
|
+
values.push(...Object.entries(mod).map(([exportName, value]) => ({ exportName, value })));
|
|
305
|
+
}
|
|
306
|
+
return values;
|
|
307
|
+
}
|
|
308
|
+
async function findConfig(exports, cwd, explicitProject) {
|
|
309
|
+
const config = exports.find((entry) => isBroodsConfig(entry.value))?.value;
|
|
310
|
+
const configValue = config?.config ?? {};
|
|
311
|
+
const project = explicitProject ?? process.env.BROODS_PROJECT ?? configValue.project ?? normalizeProjectName(basename(resolve2(cwd)));
|
|
312
|
+
if (!project.trim()) {
|
|
313
|
+
throw new Error("Project name is required. Pass --project <name> or set BROODS_PROJECT.");
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
...configValue,
|
|
317
|
+
project
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
var KNOWN_AGENT_CONFIG_KEYS = new Set([
|
|
321
|
+
"agent",
|
|
322
|
+
"model",
|
|
323
|
+
"provider",
|
|
324
|
+
"session",
|
|
325
|
+
"hooks",
|
|
326
|
+
"channels",
|
|
327
|
+
"tools",
|
|
328
|
+
"sandbox",
|
|
329
|
+
"workspaces",
|
|
330
|
+
"subagent",
|
|
331
|
+
"skills",
|
|
332
|
+
"publicAccess"
|
|
333
|
+
]);
|
|
334
|
+
var AGENT_KEY_SUGGESTIONS = {
|
|
335
|
+
workspace: "workspaces",
|
|
336
|
+
skill: "skills",
|
|
337
|
+
tool: "tools",
|
|
338
|
+
channel: "channels",
|
|
339
|
+
hook: "hooks",
|
|
340
|
+
subagents: "subagent",
|
|
341
|
+
sandboxes: "sandbox",
|
|
342
|
+
systemPrompt: "agent",
|
|
343
|
+
system: "agent"
|
|
344
|
+
};
|
|
345
|
+
function assertKnownConfigKeys(resource) {
|
|
346
|
+
if (resource.kind !== "agent")
|
|
347
|
+
return;
|
|
348
|
+
const config = resource.config;
|
|
349
|
+
for (const key of Object.keys(config)) {
|
|
350
|
+
if (KNOWN_AGENT_CONFIG_KEYS.has(key))
|
|
351
|
+
continue;
|
|
352
|
+
const suggestion = AGENT_KEY_SUGGESTIONS[key];
|
|
353
|
+
const hint = suggestion ? ` Did you mean "${suggestion}"?` : ` Allowed keys: ${[...KNOWN_AGENT_CONFIG_KEYS].sort().join(", ")}.`;
|
|
354
|
+
throw new Error(`Agent "${resource.name}" has an unknown config key "${key}".${hint}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function assertSupportedWorkspaceStorage(resource) {
|
|
358
|
+
if (resource.kind !== "workspace")
|
|
359
|
+
return;
|
|
360
|
+
const config = resource.config;
|
|
361
|
+
const storage = config.storage;
|
|
362
|
+
if (storage === undefined)
|
|
363
|
+
return;
|
|
364
|
+
if (!storage || typeof storage !== "object" || Array.isArray(storage)) {
|
|
365
|
+
throw new Error(`Workspace "${resource.name}" config.storage must be an object`);
|
|
366
|
+
}
|
|
367
|
+
const provider = storage.provider;
|
|
368
|
+
if (provider === undefined || provider === "s3")
|
|
369
|
+
return;
|
|
370
|
+
if (provider === "vercel") {
|
|
371
|
+
throw new Error(`Workspace "${resource.name}" uses storage.provider "vercel", but Vercel Drive workspace storage is not supported yet. Use storage.provider "s3" or omit storage until Vercel Drive is wired.`);
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`Workspace "${resource.name}" config.storage.provider must be one of: s3`);
|
|
374
|
+
}
|
|
375
|
+
function assertSupportedWorkspaceSandboxMounts(resources) {
|
|
376
|
+
const sandboxes = new Map(resources.filter((resource) => resource.kind === "sandbox").map((resource) => [resource.name, resource]));
|
|
377
|
+
for (const resource of resources) {
|
|
378
|
+
if (resource.kind !== "agent")
|
|
379
|
+
continue;
|
|
380
|
+
const config = resource.config;
|
|
381
|
+
const agentSandbox = resolveLocalSandbox(config.sandbox, sandboxes);
|
|
382
|
+
const workspaces = config.workspaces;
|
|
383
|
+
if (!Array.isArray(workspaces))
|
|
384
|
+
continue;
|
|
385
|
+
for (const entry of workspaces) {
|
|
386
|
+
const workspaceName = workspaceNameFor(entry);
|
|
387
|
+
const sandbox = effectiveWorkspaceSandbox(entry, agentSandbox, sandboxes);
|
|
388
|
+
if (!sandbox || supportsS3WorkspaceMount(sandbox))
|
|
389
|
+
continue;
|
|
390
|
+
throw new Error(`Agent "${resource.name}" workspace "${workspaceName}" uses sandbox "${sandbox.name}" (${sandboxProvider(sandbox)}) which does not support S3 workspace mounts. Use lambda, or daytona/kubernetes with options.mountAwsS3Buckets: true, or set this workspace ref to sandbox: null for read-only S3 access.`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function resolveLocalSandbox(value, sandboxes) {
|
|
395
|
+
if (isResource(value) && value.kind === "sandbox")
|
|
396
|
+
return value;
|
|
397
|
+
if (typeof value === "string")
|
|
398
|
+
return sandboxes.get(value);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
function effectiveWorkspaceSandbox(entry, agentSandbox, sandboxes) {
|
|
402
|
+
if (entry && typeof entry === "object" && "sandbox" in entry) {
|
|
403
|
+
const sandbox = entry.sandbox;
|
|
404
|
+
if (sandbox === null)
|
|
405
|
+
return;
|
|
406
|
+
return resolveLocalSandbox(sandbox, sandboxes);
|
|
407
|
+
}
|
|
408
|
+
return agentSandbox;
|
|
409
|
+
}
|
|
410
|
+
function workspaceNameFor(entry) {
|
|
411
|
+
if (isResource(entry) && entry.kind === "workspace")
|
|
412
|
+
return entry.name;
|
|
413
|
+
if (entry && typeof entry === "object" && "workspace" in entry) {
|
|
414
|
+
const workspace = entry.workspace;
|
|
415
|
+
if (isResource(workspace))
|
|
416
|
+
return workspace.name;
|
|
417
|
+
if (typeof workspace === "string")
|
|
418
|
+
return workspace;
|
|
419
|
+
}
|
|
420
|
+
return "<unknown>";
|
|
421
|
+
}
|
|
422
|
+
function supportsS3WorkspaceMount(sandbox) {
|
|
423
|
+
const provider = sandboxProvider(sandbox);
|
|
424
|
+
if (provider === "lambda")
|
|
425
|
+
return true;
|
|
426
|
+
if (provider !== "daytona" && provider !== "kubernetes")
|
|
427
|
+
return false;
|
|
428
|
+
const options = sandbox.config.options;
|
|
429
|
+
return Boolean(options && typeof options === "object" && !Array.isArray(options) && options.mountAwsS3Buckets === true);
|
|
430
|
+
}
|
|
431
|
+
function sandboxProvider(sandbox) {
|
|
432
|
+
return typeof sandbox.config.provider === "string" ? sandbox.config.provider : "lambda";
|
|
433
|
+
}
|
|
434
|
+
function assertUniqueResources(resources) {
|
|
435
|
+
const seen = new Set;
|
|
436
|
+
for (const resource of resources) {
|
|
437
|
+
const key = `${resource.kind}:${resource.name}`;
|
|
438
|
+
if (seen.has(key)) {
|
|
439
|
+
throw new Error(`Duplicate resource: ${key}`);
|
|
440
|
+
}
|
|
441
|
+
seen.add(key);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function aliasesForResources(resources) {
|
|
445
|
+
const aliases = {};
|
|
446
|
+
const seenAliases = new Set;
|
|
447
|
+
for (const { exportName, resource } of resources) {
|
|
448
|
+
if (exportName === "default" || !isValidIdentifier(exportName))
|
|
449
|
+
continue;
|
|
450
|
+
const key = `${resource.kind}:${exportName}`;
|
|
451
|
+
if (seenAliases.has(key)) {
|
|
452
|
+
throw new Error(`Duplicate export alias for ${resource.kind}: ${exportName}`);
|
|
453
|
+
}
|
|
454
|
+
seenAliases.add(key);
|
|
455
|
+
aliases[resource.kind] ??= {};
|
|
456
|
+
aliases[resource.kind][resource.name] = exportName;
|
|
457
|
+
}
|
|
458
|
+
return aliases;
|
|
459
|
+
}
|
|
460
|
+
function compileChannels(resources, exports) {
|
|
461
|
+
const exportedAliases = new Map;
|
|
462
|
+
for (const entry of exports) {
|
|
463
|
+
if (!isChannelDefinition(entry.value) || entry.exportName === "default" || !isValidIdentifier(entry.exportName))
|
|
464
|
+
continue;
|
|
465
|
+
const previous = exportedAliases.get(entry.value);
|
|
466
|
+
if (previous && previous !== entry.exportName) {
|
|
467
|
+
throw new Error(`Channel is exported more than once: ${previous}, ${entry.exportName}`);
|
|
468
|
+
}
|
|
469
|
+
exportedAliases.set(entry.value, entry.exportName);
|
|
470
|
+
}
|
|
471
|
+
const owners = new Map;
|
|
472
|
+
const aliases = new Set;
|
|
473
|
+
const compiled = [];
|
|
474
|
+
for (const { exportName, resource } of resources) {
|
|
475
|
+
if (resource.kind !== "agent")
|
|
476
|
+
continue;
|
|
477
|
+
const value = resource.config.channels;
|
|
478
|
+
if (value === undefined)
|
|
479
|
+
continue;
|
|
480
|
+
if (!Array.isArray(value)) {
|
|
481
|
+
throw new Error(`Agent "${resource.name}" config.channels must be an array of channel definitions`);
|
|
482
|
+
}
|
|
483
|
+
const types = new Set;
|
|
484
|
+
for (const entry of value) {
|
|
485
|
+
if (!isChannelDefinition(entry)) {
|
|
486
|
+
throw new Error(`Agent "${resource.name}" config.channels must contain channel definitions`);
|
|
487
|
+
}
|
|
488
|
+
const owner = owners.get(entry);
|
|
489
|
+
if (owner && owner !== resource.name) {
|
|
490
|
+
throw new Error(`Channel ${entry.type} is already attached to agent "${owner}" and cannot also attach to "${resource.name}"`);
|
|
491
|
+
}
|
|
492
|
+
if (types.has(entry.type)) {
|
|
493
|
+
throw new Error(`Agent "${resource.name}" cannot configure more than one ${entry.type} channel`);
|
|
494
|
+
}
|
|
495
|
+
owners.set(entry, resource.name);
|
|
496
|
+
types.add(entry.type);
|
|
497
|
+
const fallbackAgent = exportName !== "default" && isValidIdentifier(exportName) ? exportName : resource.name;
|
|
498
|
+
const alias = exportedAliases.get(entry) ?? `${fallbackAgent}${capitalize(entry.type)}Channel`;
|
|
499
|
+
if (aliases.has(alias))
|
|
500
|
+
throw new Error(`Duplicate channel export alias: ${alias}`);
|
|
501
|
+
aliases.add(alias);
|
|
502
|
+
compiled.push({ alias, type: entry.type, agentName: resource.name });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const [channel, alias] of exportedAliases) {
|
|
506
|
+
if (!owners.has(channel))
|
|
507
|
+
throw new Error(`Channel "${alias}" must be attached to exactly one agent`);
|
|
508
|
+
}
|
|
509
|
+
return compiled.sort((left, right) => left.alias.localeCompare(right.alias));
|
|
510
|
+
}
|
|
511
|
+
function capitalize(value) {
|
|
512
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
513
|
+
}
|
|
514
|
+
function isValidIdentifier(value) {
|
|
515
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
|
516
|
+
}
|
|
517
|
+
async function toManifestResource(resource, projectRoot) {
|
|
518
|
+
return {
|
|
519
|
+
kind: resource.kind,
|
|
520
|
+
name: resource.name,
|
|
521
|
+
...resource.description ? { description: resource.description } : {},
|
|
522
|
+
config: await normalizeConfig(resource, projectRoot)
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
async function normalizeConfig(resource, projectRoot) {
|
|
526
|
+
if (resource.kind === "agent") {
|
|
527
|
+
const config = { ...resource.config };
|
|
528
|
+
if (config.channels !== undefined) {
|
|
529
|
+
if (!Array.isArray(config.channels)) {
|
|
530
|
+
throw new Error(`Agent "${resource.name}" config.channels must be an array of channel definitions`);
|
|
531
|
+
}
|
|
532
|
+
config.channels = Object.fromEntries(config.channels.map((channel) => {
|
|
533
|
+
if (!isChannelDefinition(channel)) {
|
|
534
|
+
throw new Error(`Agent "${resource.name}" config.channels must contain channel definitions`);
|
|
535
|
+
}
|
|
536
|
+
return [channel.type, channel.config];
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
if (isResource(config.sandbox)) {
|
|
540
|
+
config.sandbox = config.sandbox.name;
|
|
541
|
+
}
|
|
542
|
+
if (Array.isArray(config.workspaces)) {
|
|
543
|
+
config.workspaces = config.workspaces.map((workspace) => normalizeWorkspaceRef(workspace, resource.name));
|
|
544
|
+
}
|
|
545
|
+
return rewriteValues(config);
|
|
546
|
+
}
|
|
547
|
+
if (resource.kind === "skill") {
|
|
548
|
+
return await normalizeSkillConfig(resource.config, projectRoot);
|
|
549
|
+
}
|
|
550
|
+
if (resource.kind === "tool") {
|
|
551
|
+
return await normalizeToolConfig(resource.config, projectRoot);
|
|
552
|
+
}
|
|
553
|
+
if (resource.kind === "cron") {
|
|
554
|
+
const config = { ...resource.config };
|
|
555
|
+
const agent = config.agent;
|
|
556
|
+
config.agentId = isResource(agent) ? agent.name : agent;
|
|
557
|
+
config.name = config.name ?? resource.name;
|
|
558
|
+
delete config.agent;
|
|
559
|
+
if (typeof config.input === "string") {
|
|
560
|
+
config.events = [{ role: "user", content: [{ type: "text", text: config.input }] }];
|
|
561
|
+
delete config.input;
|
|
562
|
+
}
|
|
563
|
+
return rewriteValues(config);
|
|
564
|
+
}
|
|
565
|
+
return rewriteValues(resource.config);
|
|
566
|
+
}
|
|
567
|
+
async function normalizeSkillConfig(config, projectRoot) {
|
|
568
|
+
const skillRoot = resolveContainedResourcePath(projectRoot, config.path, "Skill");
|
|
569
|
+
const manifestPath = relative(projectRoot, skillRoot).split("\\").join("/");
|
|
570
|
+
const files = await readBundleFiles(skillRoot);
|
|
571
|
+
if (!files.some((file) => file.path === "SKILL.md")) {
|
|
572
|
+
throw new Error(`Skill folder ${config.path} must contain SKILL.md`);
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
source: "files",
|
|
576
|
+
path: manifestPath,
|
|
577
|
+
files
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
async function normalizeToolConfig(config, projectRoot) {
|
|
581
|
+
const bundlePath = resolveContainedResourcePath(projectRoot, config.path, "Tool");
|
|
582
|
+
const manifestPath = relative(projectRoot, bundlePath).split("\\").join("/");
|
|
583
|
+
assertSafeBundlePath(manifestPath, "Tool");
|
|
584
|
+
const sourceSize = Buffer.byteLength(await readFile2(bundlePath));
|
|
585
|
+
if (sourceSize > MAX_BUNDLE_FILE_BYTES) {
|
|
586
|
+
throw new Error(`Tool source ${manifestPath} is too large (${sourceSize} bytes, max ${MAX_BUNDLE_FILE_BYTES})`);
|
|
587
|
+
}
|
|
588
|
+
const build = await Bun.build({
|
|
589
|
+
entrypoints: [bundlePath],
|
|
590
|
+
root: projectRoot,
|
|
591
|
+
target: "node",
|
|
592
|
+
format: "esm",
|
|
593
|
+
minify: false
|
|
594
|
+
});
|
|
595
|
+
if (!build.success || build.outputs.length !== 1) {
|
|
596
|
+
const details = build.logs.map((entry) => entry.message).filter(Boolean).join("; ");
|
|
597
|
+
throw new Error(`Tool bundle ${manifestPath} failed to build${details ? `: ${details}` : ""}`);
|
|
598
|
+
}
|
|
599
|
+
const bundle = await build.outputs[0].text();
|
|
600
|
+
const bundleSize = Buffer.byteLength(bundle);
|
|
601
|
+
if (bundleSize > MAX_BUNDLE_FILE_BYTES) {
|
|
602
|
+
throw new Error(`Tool bundle ${manifestPath} is too large (${bundleSize} bytes, max ${MAX_BUNDLE_FILE_BYTES})`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
path: manifestPath,
|
|
606
|
+
description: config.description,
|
|
607
|
+
inputSchema: config.inputSchema,
|
|
608
|
+
...config.defaultConfig !== undefined ? { defaultConfig: config.defaultConfig } : {},
|
|
609
|
+
bundle,
|
|
610
|
+
sha256: sha256Hex(bundle)
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function normalizeWorkspaceRef(entry, agentName) {
|
|
614
|
+
if (isResource(entry)) {
|
|
615
|
+
return { name: entry.name, workspaceId: entry.name };
|
|
616
|
+
}
|
|
617
|
+
if (entry && typeof entry === "object" && "workspace" in entry) {
|
|
618
|
+
const ref = entry.workspace;
|
|
619
|
+
const name = isResource(ref) ? ref.name : ref;
|
|
620
|
+
if (typeof name !== "string") {
|
|
621
|
+
throw new Error(`Agent ${agentName} workspace ref must be a defineWorkspace(...) resource or its name`);
|
|
622
|
+
}
|
|
623
|
+
const normalized = { name, workspaceId: name };
|
|
624
|
+
if ("sandbox" in entry) {
|
|
625
|
+
const sandbox = entry.sandbox;
|
|
626
|
+
normalized.sandbox = sandbox === null ? null : isResource(sandbox) ? sandbox.name : sandbox;
|
|
627
|
+
}
|
|
628
|
+
return normalized;
|
|
629
|
+
}
|
|
630
|
+
throw new Error(`Agent ${agentName} workspaces must be defineWorkspace(...) resources or { workspace, sandbox } refs`);
|
|
631
|
+
}
|
|
632
|
+
function resolveContainedResourcePath(projectRoot, resourcePath, kind) {
|
|
633
|
+
if (resourcePath.trim().length === 0)
|
|
634
|
+
throw new Error(`${kind} path is required`);
|
|
635
|
+
if (resourcePath.includes("\x00"))
|
|
636
|
+
throw new Error(`${kind} path must not contain null bytes`);
|
|
637
|
+
const root = resolve2(projectRoot);
|
|
638
|
+
const target = resolve2(root, resourcePath);
|
|
639
|
+
const rel = relative(root, target);
|
|
640
|
+
if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
|
|
641
|
+
return target;
|
|
642
|
+
throw new Error(`${kind} path ${resourcePath} must stay inside ${PROJECT_DIR}/`);
|
|
643
|
+
}
|
|
644
|
+
function rewriteValues(value) {
|
|
645
|
+
if (Array.isArray(value)) {
|
|
646
|
+
return value.map((entry) => rewriteValues(entry));
|
|
647
|
+
}
|
|
648
|
+
if (isResource(value)) {
|
|
649
|
+
return value.name;
|
|
650
|
+
}
|
|
651
|
+
if (value && typeof value === "object") {
|
|
652
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteValues(entry)]));
|
|
653
|
+
}
|
|
654
|
+
return value;
|
|
655
|
+
}
|
|
656
|
+
async function readBundleFiles(root) {
|
|
657
|
+
const files = [];
|
|
658
|
+
let totalBytes = 0;
|
|
659
|
+
async function walk(dir) {
|
|
660
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
661
|
+
for (const entry of entries) {
|
|
662
|
+
const absolute = join3(dir, entry.name);
|
|
663
|
+
const rel = relative(root, absolute).split("\\").join("/");
|
|
664
|
+
if (entry.isDirectory()) {
|
|
665
|
+
if (shouldSkipBundleEntry(entry.name))
|
|
666
|
+
continue;
|
|
667
|
+
await walk(absolute);
|
|
668
|
+
} else if (entry.isFile()) {
|
|
669
|
+
if (shouldSkipBundleEntry(entry.name) || isUnsafeBundlePath(rel))
|
|
670
|
+
continue;
|
|
671
|
+
const bytes = await readFile2(absolute);
|
|
672
|
+
if (bytes.byteLength > MAX_BUNDLE_FILE_BYTES) {
|
|
673
|
+
throw new Error(`Skill bundle file ${rel} is too large (${bytes.byteLength} bytes, max ${MAX_BUNDLE_FILE_BYTES})`);
|
|
674
|
+
}
|
|
675
|
+
totalBytes += bytes.byteLength;
|
|
676
|
+
if (totalBytes > MAX_BUNDLE_TOTAL_BYTES) {
|
|
677
|
+
throw new Error(`Skill bundle at ${root} is too large (${totalBytes} bytes, max ${MAX_BUNDLE_TOTAL_BYTES})`);
|
|
678
|
+
}
|
|
679
|
+
if (files.length >= MAX_BUNDLE_FILES) {
|
|
680
|
+
throw new Error(`Skill bundle at ${root} has too many files (max ${MAX_BUNDLE_FILES})`);
|
|
681
|
+
}
|
|
682
|
+
files.push({
|
|
683
|
+
path: rel,
|
|
684
|
+
size: bytes.byteLength,
|
|
685
|
+
sha256: sha256Hex(bytes),
|
|
686
|
+
contentBase64: bytes.toString("base64"),
|
|
687
|
+
contentType: contentTypeForPath(rel)
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
await walk(root);
|
|
693
|
+
return files.sort((a, b) => String(a.path).localeCompare(String(b.path)));
|
|
694
|
+
}
|
|
695
|
+
function shouldSkipBundleEntry(name) {
|
|
696
|
+
return name.startsWith(".") || SKIPPED_BUNDLE_DIRECTORIES.has(name);
|
|
697
|
+
}
|
|
698
|
+
function assertSafeBundlePath(path, kind) {
|
|
699
|
+
if (isUnsafeBundlePath(path)) {
|
|
700
|
+
throw new Error(`${kind} bundle path ${path} looks like a hidden file or secret and will not be bundled`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function isUnsafeBundlePath(path) {
|
|
704
|
+
const parts = path.split("/");
|
|
705
|
+
return parts.some((part) => part.startsWith(".") || UNSAFE_BUNDLE_FILE_NAMES.some((pattern) => pattern.test(part)));
|
|
706
|
+
}
|
|
707
|
+
function sha256Hex(value) {
|
|
708
|
+
return createHash("sha256").update(value).digest("hex");
|
|
709
|
+
}
|
|
710
|
+
function contentTypeForPath(path) {
|
|
711
|
+
if (path.endsWith(".md"))
|
|
712
|
+
return "text/markdown; charset=utf-8";
|
|
713
|
+
if (path.endsWith(".json"))
|
|
714
|
+
return "application/json";
|
|
715
|
+
if (path.endsWith(".js") || path.endsWith(".mjs"))
|
|
716
|
+
return "application/javascript";
|
|
717
|
+
if (path.endsWith(".ts"))
|
|
718
|
+
return "text/typescript; charset=utf-8";
|
|
719
|
+
if (path.endsWith(".txt"))
|
|
720
|
+
return "text/plain; charset=utf-8";
|
|
721
|
+
return "application/octet-stream";
|
|
722
|
+
}
|
|
723
|
+
function normalizeProjectName(name) {
|
|
724
|
+
return name.replace(/^@/, "").replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/codegen.ts
|
|
728
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
729
|
+
import { resolve as resolve3 } from "path";
|
|
730
|
+
async function writeGeneratedFiles(manifest, ids, cwd = process.cwd(), aliases = {}, endpoint = null, channels = []) {
|
|
731
|
+
const dir = resolve3(cwd, PROJECT_DIR, GENERATED_DIR);
|
|
732
|
+
const scopedIds = idsForManifest(ids, manifest);
|
|
733
|
+
await mkdir2(dir, { recursive: true });
|
|
734
|
+
await writeFile2(resolve3(dir, "ids.ts"), idsFile(scopedIds), "utf8");
|
|
735
|
+
await writeFile2(resolve3(dir, "api.ts"), apiFile(manifest, scopedIds, aliases, endpoint, channels), "utf8");
|
|
736
|
+
await writeFile2(resolve3(dir, "resources.ts"), resourcesFile(manifest), "utf8");
|
|
737
|
+
await writeFile2(resolve3(dir, "dataModel.ts"), dataModelFile(), "utf8");
|
|
738
|
+
}
|
|
739
|
+
function idsForManifest(ids, manifest) {
|
|
740
|
+
const namesByKind = {
|
|
741
|
+
agents: namesForKind(manifest, "agent"),
|
|
742
|
+
workspaces: namesForKind(manifest, "workspace"),
|
|
743
|
+
sandboxes: namesForKind(manifest, "sandbox"),
|
|
744
|
+
crons: namesForKind(manifest, "cron"),
|
|
745
|
+
skills: namesForKind(manifest, "skill"),
|
|
746
|
+
tools: namesForKind(manifest, "tool")
|
|
747
|
+
};
|
|
748
|
+
return {
|
|
749
|
+
agents: pickIds(ids.agents, namesByKind.agents),
|
|
750
|
+
workspaces: pickIds(ids.workspaces, namesByKind.workspaces),
|
|
751
|
+
sandboxes: pickIds(ids.sandboxes, namesByKind.sandboxes),
|
|
752
|
+
crons: pickIds(ids.crons, namesByKind.crons),
|
|
753
|
+
skills: pickIds(ids.skills, namesByKind.skills),
|
|
754
|
+
tools: pickIds(ids.tools, namesByKind.tools)
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function namesForKind(manifest, kind) {
|
|
758
|
+
return new Set(manifest.resources.filter((resource) => resource.kind === kind).map((resource) => resource.name));
|
|
759
|
+
}
|
|
760
|
+
function pickIds(values, names) {
|
|
761
|
+
return Object.fromEntries(Object.entries(values).filter(([name]) => names.has(name)));
|
|
762
|
+
}
|
|
763
|
+
function idsFile(ids) {
|
|
764
|
+
return `// Generated by broods. Do not edit by hand.
|
|
765
|
+
` + `export const ids = ${JSON.stringify(ids, null, 2)} as const;
|
|
766
|
+
` + `export const agents = ids.agents;
|
|
767
|
+
` + `export const workspaces = ids.workspaces;
|
|
768
|
+
` + `export const sandboxes = ids.sandboxes;
|
|
769
|
+
` + `export const crons = ids.crons;
|
|
770
|
+
` + `export const skills = ids.skills;
|
|
771
|
+
` + `export const tools = ids.tools;
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
function resourcesFile(manifest) {
|
|
775
|
+
return `// Generated by broods. Do not edit by hand.
|
|
776
|
+
` + `export const manifest = ${JSON.stringify(manifest, null, 2)} as const;
|
|
777
|
+
`;
|
|
778
|
+
}
|
|
779
|
+
function apiFile(manifest, ids, aliases, endpoint, channels) {
|
|
780
|
+
const agents = Object.keys(ids.agents);
|
|
781
|
+
const scope = endpoint ? `, endpointId: ${JSON.stringify(endpoint.endpointId)}, projectSlug: ${JSON.stringify(endpoint.projectSlug)}, environmentSlug: ${JSON.stringify(endpoint.environmentSlug)}` : "";
|
|
782
|
+
const agentEntries = agents.map((name) => ` ${propertyKey(aliases.agent?.[name] ?? name)}: { kind: "agent", name: ${JSON.stringify(name)}, id: ids.agents[${JSON.stringify(name)}], project: ${JSON.stringify(manifest.project)}, environment: ${JSON.stringify(manifest.environment)}${scope} },`).join(`
|
|
783
|
+
`);
|
|
784
|
+
const channelEntries = endpoint ? channels.map((channel) => {
|
|
785
|
+
const path = `/webhooks/${encodeURIComponent(endpoint.accountId)}/${encodeURIComponent(ids.agents[channel.agentName] ?? channel.agentName)}/${encodeURIComponent(channel.type)}`;
|
|
786
|
+
return ` ${propertyKey(channel.alias)}: { kind: "channel", type: ${JSON.stringify(channel.type)}, agentName: ${JSON.stringify(channel.agentName)}, agentId: ids.agents[${JSON.stringify(channel.agentName)}], accountId: ${JSON.stringify(endpoint.accountId)}, webhookPath: ${JSON.stringify(path)} },`;
|
|
787
|
+
}).join(`
|
|
788
|
+
`) : "";
|
|
789
|
+
return `// Generated by broods. Do not edit by hand.
|
|
790
|
+
` + `import type { ResourceApi } from "broods";
|
|
791
|
+
` + `import { ids } from "./ids";
|
|
792
|
+
|
|
793
|
+
` + `/** Typed references for resources defined in broods/. */
|
|
794
|
+
` + `export const api = {
|
|
795
|
+
` + ` agents: {
|
|
796
|
+
${agentEntries}
|
|
797
|
+
},
|
|
798
|
+
` + ` channels: {
|
|
799
|
+
${channelEntries}
|
|
800
|
+
},
|
|
801
|
+
` + ` workspaces: ${aliasedIdMap("workspaces", ids.workspaces, aliases.workspace)},
|
|
802
|
+
` + ` sandboxes: ${aliasedIdMap("sandboxes", ids.sandboxes, aliases.sandbox)},
|
|
803
|
+
` + ` crons: ${aliasedIdMap("crons", ids.crons, aliases.cron)},
|
|
804
|
+
` + ` skills: ${aliasedIdMap("skills", ids.skills, aliases.skill)},
|
|
805
|
+
` + ` tools: ${aliasedIdMap("tools", ids.tools, aliases.tool)},
|
|
806
|
+
` + `} as const satisfies ResourceApi;
|
|
807
|
+
`;
|
|
808
|
+
}
|
|
809
|
+
function aliasedIdMap(idsKey, values, alias) {
|
|
810
|
+
const names = Object.keys(values);
|
|
811
|
+
if (names.length === 0)
|
|
812
|
+
return "{}";
|
|
813
|
+
const entries = names.map((name) => ` ${propertyKey(alias?.[name] ?? name)}: ids.${idsKey}[${JSON.stringify(name)}],`).join(`
|
|
814
|
+
`);
|
|
815
|
+
return `{
|
|
816
|
+
${entries}
|
|
817
|
+
}`;
|
|
818
|
+
}
|
|
819
|
+
function propertyKey(value) {
|
|
820
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : JSON.stringify(value);
|
|
821
|
+
}
|
|
822
|
+
function dataModelFile() {
|
|
823
|
+
return `// Generated by broods. Do not edit by hand.
|
|
824
|
+
` + `export type { AgentReference, ChannelReference, AgentRunInput, AgentRunResult, ResourceApi } from "broods";
|
|
825
|
+
`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/sync.ts
|
|
829
|
+
class BroodsSyncClient {
|
|
830
|
+
dashboardUrl;
|
|
831
|
+
token;
|
|
832
|
+
fetchImpl;
|
|
833
|
+
constructor(options) {
|
|
834
|
+
this.dashboardUrl = stripTrailingSlash(options.dashboardUrl);
|
|
835
|
+
this.token = options.token;
|
|
836
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
837
|
+
}
|
|
838
|
+
async getManifest(project, environment) {
|
|
839
|
+
const response = await this.request(project, environment, "/manifest", { method: "GET" });
|
|
840
|
+
if (response.status === 404)
|
|
841
|
+
return null;
|
|
842
|
+
await assertOk(response, "Fetch manifest failed");
|
|
843
|
+
return await response.json();
|
|
844
|
+
}
|
|
845
|
+
async putManifest(manifest, prune, rotateRuntimeKey = false) {
|
|
846
|
+
const response = await this.request(manifest.project, manifest.environment, "/manifest", {
|
|
847
|
+
method: "PUT",
|
|
848
|
+
headers: { "Content-Type": "application/json" },
|
|
849
|
+
body: JSON.stringify({ manifest, prune, rotateRuntimeKey })
|
|
850
|
+
});
|
|
851
|
+
await assertOk(response, "Sync manifest failed");
|
|
852
|
+
return await response.json();
|
|
853
|
+
}
|
|
854
|
+
async getRuntimeKey(project, environment) {
|
|
855
|
+
const response = await this.request(project, environment, "/runtime-key", { method: "GET" });
|
|
856
|
+
if (response.status === 404)
|
|
857
|
+
return null;
|
|
858
|
+
await assertOk(response, "Fetch runtime key failed");
|
|
859
|
+
const payload = await response.json();
|
|
860
|
+
if (!payload.apiKey)
|
|
861
|
+
throw new Error("Fetch runtime key failed: response omitted apiKey");
|
|
862
|
+
return { apiKey: payload.apiKey, keyHint: payload.keyHint ?? "" };
|
|
863
|
+
}
|
|
864
|
+
async setEnv(project, environment, name, value) {
|
|
865
|
+
const response = await this.request(project, environment, `/env/${encodeURIComponent(name)}`, {
|
|
866
|
+
method: "PUT",
|
|
867
|
+
headers: { "Content-Type": "application/json" },
|
|
868
|
+
body: JSON.stringify({ value })
|
|
869
|
+
});
|
|
870
|
+
await assertOk(response, "Set environment variable failed");
|
|
871
|
+
}
|
|
872
|
+
async listEnv(project, environment) {
|
|
873
|
+
const response = await this.request(project, environment, "/env", { method: "GET" });
|
|
874
|
+
await assertOk(response, "List environment variables failed");
|
|
875
|
+
const payload = await response.json();
|
|
876
|
+
return payload.variables ?? [];
|
|
877
|
+
}
|
|
878
|
+
async getEnv(project, environment, name) {
|
|
879
|
+
const response = await this.request(project, environment, `/env/${encodeURIComponent(name)}`, { method: "GET" });
|
|
880
|
+
if (response.status === 404)
|
|
881
|
+
return null;
|
|
882
|
+
await assertOk(response, "Read environment variable failed");
|
|
883
|
+
const payload = await response.json();
|
|
884
|
+
return payload.value ?? null;
|
|
885
|
+
}
|
|
886
|
+
async removeEnv(project, environment, name) {
|
|
887
|
+
const response = await this.request(project, environment, `/env/${encodeURIComponent(name)}`, {
|
|
888
|
+
method: "DELETE"
|
|
889
|
+
});
|
|
890
|
+
await assertOk(response, "Remove environment variable failed");
|
|
891
|
+
}
|
|
892
|
+
async getOnboarding() {
|
|
893
|
+
const response = await this.fetchImpl(`${this.dashboardUrl}/api/cli/onboarding`, {
|
|
894
|
+
method: "GET",
|
|
895
|
+
headers: {
|
|
896
|
+
Authorization: `Bearer ${this.token}`
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
await assertOk(response, "Fetch CLI onboarding context failed");
|
|
900
|
+
return await response.json();
|
|
901
|
+
}
|
|
902
|
+
async selectOnboardingOrg(orgId) {
|
|
903
|
+
const response = await this.fetchImpl(`${this.dashboardUrl}/api/cli/onboarding`, {
|
|
904
|
+
method: "POST",
|
|
905
|
+
headers: {
|
|
906
|
+
Authorization: `Bearer ${this.token}`,
|
|
907
|
+
"Content-Type": "application/json"
|
|
908
|
+
},
|
|
909
|
+
body: JSON.stringify({ orgId })
|
|
910
|
+
});
|
|
911
|
+
await assertOk(response, "Select CLI org failed");
|
|
912
|
+
return await response.json();
|
|
913
|
+
}
|
|
914
|
+
async request(project, environment, suffix, init) {
|
|
915
|
+
const url = `${this.dashboardUrl}/api/cli/projects/${encodeURIComponent(project)}` + `/environments/${encodeURIComponent(environment)}${suffix}`;
|
|
916
|
+
return await this.fetchImpl(url, {
|
|
917
|
+
...init,
|
|
918
|
+
headers: {
|
|
919
|
+
Authorization: `Bearer ${this.token}`,
|
|
920
|
+
...init.headers
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function diffManifests(local, remote) {
|
|
926
|
+
const remoteResources = new Map((remote?.resources ?? []).map((entry) => [`${entry.kind}:${entry.name}`, entry]));
|
|
927
|
+
const localResources = new Map(local.resources.map((entry) => [`${entry.kind}:${entry.name}`, entry]));
|
|
928
|
+
const entries = [];
|
|
929
|
+
const unmatchedLocal = [];
|
|
930
|
+
const unmatchedRemote = [];
|
|
931
|
+
for (const [key, resource] of localResources) {
|
|
932
|
+
const remoteResource = remoteResources.get(key);
|
|
933
|
+
if (!remoteResource) {
|
|
934
|
+
unmatchedLocal.push({ key, resource });
|
|
935
|
+
} else if (stableJson(snapshotResource(remoteResource)) !== stableJson(snapshotResource(resource))) {
|
|
936
|
+
entries.push({ operation: "update", kind: resource.kind, name: resource.name });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
for (const [key, resource] of remoteResources) {
|
|
940
|
+
if (!localResources.has(key)) {
|
|
941
|
+
unmatchedRemote.push({ key, resource });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const renamedRemoteKeys = new Set;
|
|
945
|
+
const renamedLocalKeys = new Set;
|
|
946
|
+
for (const localEntry of unmatchedLocal) {
|
|
947
|
+
const match = unmatchedRemote.find((remoteEntry) => !renamedRemoteKeys.has(remoteEntry.key) && isRenamableKind(localEntry.resource.kind) && localEntry.resource.kind === remoteEntry.resource.kind && stableJson(renameSnapshot(localEntry.resource)) === stableJson(renameSnapshot(remoteEntry.resource)));
|
|
948
|
+
if (!match)
|
|
949
|
+
continue;
|
|
950
|
+
renamedLocalKeys.add(localEntry.key);
|
|
951
|
+
renamedRemoteKeys.add(match.key);
|
|
952
|
+
entries.push({
|
|
953
|
+
operation: "rename",
|
|
954
|
+
kind: localEntry.resource.kind,
|
|
955
|
+
name: localEntry.resource.name,
|
|
956
|
+
previousName: match.resource.name
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
for (const { key, resource } of unmatchedLocal) {
|
|
960
|
+
if (!renamedLocalKeys.has(key)) {
|
|
961
|
+
entries.push({ operation: "create", kind: resource.kind, name: resource.name });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
for (const { key, resource } of unmatchedRemote) {
|
|
965
|
+
if (!renamedRemoteKeys.has(key)) {
|
|
966
|
+
entries.push({ operation: "delete", kind: resource.kind, name: resource.name });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return entries.sort((a, b) => diffSortKey(a).localeCompare(diffSortKey(b)));
|
|
970
|
+
}
|
|
971
|
+
function snapshotResource(resource) {
|
|
972
|
+
const normalized = normalizeEnvRefs(resource);
|
|
973
|
+
if (resource.kind !== "skill" && resource.kind !== "tool")
|
|
974
|
+
return normalized;
|
|
975
|
+
return {
|
|
976
|
+
...normalized,
|
|
977
|
+
config: stripArtifactContent(normalized.config)
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function renameSnapshot(resource) {
|
|
981
|
+
const normalized = snapshotResource(resource);
|
|
982
|
+
const { name: _name, ...rest } = normalized;
|
|
983
|
+
return rest;
|
|
984
|
+
}
|
|
985
|
+
function isRenamableKind(kind) {
|
|
986
|
+
return kind === "agent" || kind === "workspace" || kind === "sandbox";
|
|
987
|
+
}
|
|
988
|
+
function diffSortKey(entry) {
|
|
989
|
+
const rank = {
|
|
990
|
+
create: 0,
|
|
991
|
+
rename: 1,
|
|
992
|
+
update: 2,
|
|
993
|
+
delete: 3
|
|
994
|
+
};
|
|
995
|
+
return `${rank[entry.operation]}:${entry.kind}:${entry.previousName ?? ""}:${entry.name}`;
|
|
996
|
+
}
|
|
997
|
+
function normalizeEnvRefs(value) {
|
|
998
|
+
if (Array.isArray(value))
|
|
999
|
+
return value.map(normalizeEnvRefs);
|
|
1000
|
+
if (value && typeof value === "object") {
|
|
1001
|
+
const record = value;
|
|
1002
|
+
if (record.__beeblastEnv === true && typeof record.name === "string") {
|
|
1003
|
+
return `\${${record.name}}`;
|
|
1004
|
+
}
|
|
1005
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, normalizeEnvRefs(entry)]));
|
|
1006
|
+
}
|
|
1007
|
+
return value;
|
|
1008
|
+
}
|
|
1009
|
+
function stripArtifactContent(value) {
|
|
1010
|
+
if (Array.isArray(value))
|
|
1011
|
+
return value.map(stripArtifactContent);
|
|
1012
|
+
if (value && typeof value === "object") {
|
|
1013
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
|
|
1014
|
+
if (key === "contentBase64" || key === "bundle")
|
|
1015
|
+
return [];
|
|
1016
|
+
return [[key, stripArtifactContent(entry)]];
|
|
1017
|
+
}));
|
|
1018
|
+
}
|
|
1019
|
+
return value;
|
|
1020
|
+
}
|
|
1021
|
+
function stableJson(value) {
|
|
1022
|
+
return JSON.stringify(sortValue(value));
|
|
1023
|
+
}
|
|
1024
|
+
function sortValue(value) {
|
|
1025
|
+
if (Array.isArray(value))
|
|
1026
|
+
return value.map(sortValue);
|
|
1027
|
+
if (value && typeof value === "object") {
|
|
1028
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, sortValue(entry)]));
|
|
1029
|
+
}
|
|
1030
|
+
return value;
|
|
1031
|
+
}
|
|
1032
|
+
async function assertOk(response, message) {
|
|
1033
|
+
if (!response.ok) {
|
|
1034
|
+
throw new Error(`${message}: ${response.status} ${await response.text()}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// src/run-input.ts
|
|
1039
|
+
function resolveRunEvents(input) {
|
|
1040
|
+
if (input.events && input.input !== undefined) {
|
|
1041
|
+
throw new Error("Run input accepts either `input` or `events`, not both");
|
|
1042
|
+
}
|
|
1043
|
+
if (input.events && input.events.length > 0)
|
|
1044
|
+
return input.events;
|
|
1045
|
+
if (typeof input.input === "string") {
|
|
1046
|
+
return [{ role: "user", content: [{ type: "text", text: input.input }] }];
|
|
1047
|
+
}
|
|
1048
|
+
throw new Error("Run input requires `input` (string) or a non-empty `events` array");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/stream.ts
|
|
1052
|
+
async function* readSseStream(body) {
|
|
1053
|
+
const reader = body.getReader();
|
|
1054
|
+
const decoder = new TextDecoder;
|
|
1055
|
+
let buffer = "";
|
|
1056
|
+
try {
|
|
1057
|
+
while (true) {
|
|
1058
|
+
const { done, value } = await reader.read();
|
|
1059
|
+
if (done)
|
|
1060
|
+
break;
|
|
1061
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1062
|
+
const lines = buffer.split(`
|
|
1063
|
+
`);
|
|
1064
|
+
buffer = lines.pop() ?? "";
|
|
1065
|
+
for (const line of lines) {
|
|
1066
|
+
if (line.startsWith("data: "))
|
|
1067
|
+
yield line.slice(6);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
reader.releaseLock();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// src/client.ts
|
|
1076
|
+
var DEFAULT_CORE_BASE_URL = "https://gateway.broods.app";
|
|
1077
|
+
|
|
1078
|
+
class BroodsClient {
|
|
1079
|
+
baseUrl;
|
|
1080
|
+
apiKey;
|
|
1081
|
+
fetchImpl;
|
|
1082
|
+
constructor(options = {}) {
|
|
1083
|
+
loadBroodsRuntimeConfig();
|
|
1084
|
+
this.baseUrl = normalizeHttpServiceUrl(options.baseUrl || options.host || process.env.BROODS_BASE_URL || process.env.BROODS_HOST || DEFAULT_CORE_BASE_URL);
|
|
1085
|
+
this.apiKey = options.apiKey || process.env.BROODS_API_KEY || undefined;
|
|
1086
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
1087
|
+
}
|
|
1088
|
+
channelWebhookUrl(ref) {
|
|
1089
|
+
return `${this.baseUrl}${ref.webhookPath.startsWith("/") ? "" : "/"}${ref.webhookPath}`;
|
|
1090
|
+
}
|
|
1091
|
+
agent(refOrName, agentId) {
|
|
1092
|
+
if (typeof refOrName === "string") {
|
|
1093
|
+
const name = refOrName;
|
|
1094
|
+
const id = agentId ?? "";
|
|
1095
|
+
if (!id)
|
|
1096
|
+
throw new Error(`Agent ${name} is missing a generated id. Run broods deploy first.`);
|
|
1097
|
+
return {
|
|
1098
|
+
id,
|
|
1099
|
+
run: (input) => this.run({ ...input, agentName: name, agentId: id }),
|
|
1100
|
+
runAsync: (input) => this.runAsync({ ...input, agentName: name, agentId: id }),
|
|
1101
|
+
stream: (input) => this.stream({ ...input, agentName: name, agentId: id })
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const ref = refOrName;
|
|
1105
|
+
if (!ref.id)
|
|
1106
|
+
throw new Error(`Agent ${ref.name} is missing a generated id. Run broods deploy first.`);
|
|
1107
|
+
return {
|
|
1108
|
+
id: ref.id,
|
|
1109
|
+
run: (input) => this.run(ref, input),
|
|
1110
|
+
runAsync: (input) => this.runAsync(ref, input),
|
|
1111
|
+
stream: (input) => this.stream(ref, input)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
async run(refOrInput, maybeInput) {
|
|
1115
|
+
const events = [];
|
|
1116
|
+
let text = "";
|
|
1117
|
+
const stream = maybeInput ? this.stream(refOrInput, maybeInput) : this.stream(refOrInput);
|
|
1118
|
+
for await (const part of stream) {
|
|
1119
|
+
events.push(part);
|
|
1120
|
+
if (part.type === "text-delta")
|
|
1121
|
+
text += part.text;
|
|
1122
|
+
}
|
|
1123
|
+
return { text, events };
|
|
1124
|
+
}
|
|
1125
|
+
async* stream(refOrInput, maybeInput) {
|
|
1126
|
+
const input = maybeInput ? {
|
|
1127
|
+
...maybeInput,
|
|
1128
|
+
agentId: refOrInput.id,
|
|
1129
|
+
agentName: refOrInput.name
|
|
1130
|
+
} : refOrInput;
|
|
1131
|
+
const body = directRunBody(input, "cli");
|
|
1132
|
+
const targetUrl = maybeInput ? this.scopedUrl(refOrInput) : this.baseUrl;
|
|
1133
|
+
const response = await this.openStream(body, targetUrl);
|
|
1134
|
+
if (!response.ok)
|
|
1135
|
+
throw new Error(`Run failed: ${response.status} ${await response.text()}`);
|
|
1136
|
+
if (!response.body)
|
|
1137
|
+
throw new Error("Run response has no body");
|
|
1138
|
+
for await (const data of readSseStream(response.body)) {
|
|
1139
|
+
let part;
|
|
1140
|
+
try {
|
|
1141
|
+
part = JSON.parse(data);
|
|
1142
|
+
} catch {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (part.type === "error")
|
|
1146
|
+
throw new Error(`Agent run failed: ${formatStreamError(part.error)}`);
|
|
1147
|
+
yield part;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async runAsync(refOrInput, maybeInput) {
|
|
1151
|
+
const input = maybeInput ? {
|
|
1152
|
+
...maybeInput,
|
|
1153
|
+
agentId: refOrInput.id,
|
|
1154
|
+
agentName: refOrInput.name
|
|
1155
|
+
} : refOrInput;
|
|
1156
|
+
const body = directRunBody(input, "async");
|
|
1157
|
+
const response = await this.fetchJson(`${this.baseUrl}/async`, {
|
|
1158
|
+
method: "POST",
|
|
1159
|
+
headers: this.apiKeyHeaders(),
|
|
1160
|
+
body: JSON.stringify(body)
|
|
1161
|
+
});
|
|
1162
|
+
if (response.status !== 202) {
|
|
1163
|
+
throw new Error(`Async run failed: ${response.status} ${await responseErrorDetails(response, "202 JSON")}`);
|
|
1164
|
+
}
|
|
1165
|
+
const accepted = normalizeAsyncAccepted(await response.json(), body);
|
|
1166
|
+
return {
|
|
1167
|
+
...accepted,
|
|
1168
|
+
conversationKey: body.conversationKey,
|
|
1169
|
+
poll: () => this.getAsyncStatus(accepted),
|
|
1170
|
+
wait: (options) => this.waitForAsyncStatus(accepted, options)
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
async getAsyncStatus(status, options = {}) {
|
|
1174
|
+
const statusUrl = this.resolveStatusUrl(status, options);
|
|
1175
|
+
const response = await this.fetchJson(statusUrl, {
|
|
1176
|
+
method: "GET",
|
|
1177
|
+
headers: this.apiKeyHeaders()
|
|
1178
|
+
});
|
|
1179
|
+
if (response.status === 404)
|
|
1180
|
+
return { status: "not_found" };
|
|
1181
|
+
if (!response.ok)
|
|
1182
|
+
throw new Error(`Status check failed: ${response.status} ${await response.text()}`);
|
|
1183
|
+
return await response.json();
|
|
1184
|
+
}
|
|
1185
|
+
async waitForAsyncStatus(status, options = {}) {
|
|
1186
|
+
const deadline = Date.now() + (options.timeoutMs ?? 180000);
|
|
1187
|
+
const intervalMs = options.intervalMs ?? 2000;
|
|
1188
|
+
while (Date.now() < deadline) {
|
|
1189
|
+
if (options.signal?.aborted)
|
|
1190
|
+
throw new Error("Async status polling aborted.");
|
|
1191
|
+
const payload = await this.getAsyncStatus(status, options);
|
|
1192
|
+
if (payload.status === "awaiting_approval" || payload.status === "completed" || payload.status === "failed" || payload.status === "not_found") {
|
|
1193
|
+
return payload;
|
|
1194
|
+
}
|
|
1195
|
+
await sleep(intervalMs, options.signal);
|
|
1196
|
+
}
|
|
1197
|
+
throw new Error("Polling timeout");
|
|
1198
|
+
}
|
|
1199
|
+
async createCron(input) {
|
|
1200
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons`, {
|
|
1201
|
+
method: "POST",
|
|
1202
|
+
headers: this.apiKeyHeaders(),
|
|
1203
|
+
body: JSON.stringify(resolveCronInput(input))
|
|
1204
|
+
});
|
|
1205
|
+
if (response.status !== 201)
|
|
1206
|
+
throw new Error(`Create cron job failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1207
|
+
return await response.json();
|
|
1208
|
+
}
|
|
1209
|
+
async listCrons() {
|
|
1210
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons`, {
|
|
1211
|
+
method: "GET",
|
|
1212
|
+
headers: this.apiKeyHeaders()
|
|
1213
|
+
});
|
|
1214
|
+
if (!response.ok)
|
|
1215
|
+
throw new Error(`List cron jobs failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1216
|
+
const payload = await response.json();
|
|
1217
|
+
return payload.crons;
|
|
1218
|
+
}
|
|
1219
|
+
async getCron(cronId) {
|
|
1220
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons/${encodeURIComponent(cronId)}`, {
|
|
1221
|
+
method: "GET",
|
|
1222
|
+
headers: this.apiKeyHeaders()
|
|
1223
|
+
});
|
|
1224
|
+
if (response.status === 404)
|
|
1225
|
+
return null;
|
|
1226
|
+
if (!response.ok)
|
|
1227
|
+
throw new Error(`Get cron job failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1228
|
+
return await response.json();
|
|
1229
|
+
}
|
|
1230
|
+
async listCronRuns(cronId, options = {}) {
|
|
1231
|
+
const params = new URLSearchParams;
|
|
1232
|
+
if (options.limit !== undefined)
|
|
1233
|
+
params.set("limit", String(options.limit));
|
|
1234
|
+
const suffix = params.size > 0 ? `?${params}` : "";
|
|
1235
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons/${encodeURIComponent(cronId)}/runs${suffix}`, {
|
|
1236
|
+
method: "GET",
|
|
1237
|
+
headers: this.apiKeyHeaders()
|
|
1238
|
+
});
|
|
1239
|
+
if (!response.ok)
|
|
1240
|
+
throw new Error(`List cron job runs failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1241
|
+
const payload = await response.json();
|
|
1242
|
+
return payload.runs;
|
|
1243
|
+
}
|
|
1244
|
+
async updateCron(cronId, patch) {
|
|
1245
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons/${encodeURIComponent(cronId)}`, {
|
|
1246
|
+
method: "PATCH",
|
|
1247
|
+
headers: this.apiKeyHeaders(),
|
|
1248
|
+
body: JSON.stringify(patch)
|
|
1249
|
+
});
|
|
1250
|
+
if (!response.ok)
|
|
1251
|
+
throw new Error(`Update cron job failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1252
|
+
return await response.json();
|
|
1253
|
+
}
|
|
1254
|
+
async deleteCron(cronId) {
|
|
1255
|
+
const response = await this.fetchJson(`${this.baseUrl}/accounts/me/crons/${encodeURIComponent(cronId)}`, {
|
|
1256
|
+
method: "DELETE",
|
|
1257
|
+
headers: this.apiKeyHeaders()
|
|
1258
|
+
});
|
|
1259
|
+
if (response.status === 404)
|
|
1260
|
+
return false;
|
|
1261
|
+
if (!response.ok)
|
|
1262
|
+
throw new Error(`Delete cron job failed: ${response.status} ${await cronErrorDetails(response)}`);
|
|
1263
|
+
const payload = await response.json();
|
|
1264
|
+
return payload.deleted;
|
|
1265
|
+
}
|
|
1266
|
+
scopedUrl(ref) {
|
|
1267
|
+
if (ref.projectSlug && ref.environmentSlug && ref.endpointId) {
|
|
1268
|
+
return `${this.baseUrl}/v1/${encodeURIComponent(ref.projectSlug)}` + `/agents/${encodeURIComponent(ref.environmentSlug)}/${encodeURIComponent(ref.endpointId)}`;
|
|
1269
|
+
}
|
|
1270
|
+
return this.baseUrl;
|
|
1271
|
+
}
|
|
1272
|
+
async openStream(body, targetUrl) {
|
|
1273
|
+
if (this.apiKey) {
|
|
1274
|
+
return await this.fetchCore(body, targetUrl, {
|
|
1275
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
throw new Error(`BroodsClient streams directly from the core service at ${this.baseUrl}. ` + "Provide apiKey. " + "For a self-hosted core service, set host/baseUrl or BROODS_HOST/BROODS_BASE_URL.");
|
|
1279
|
+
}
|
|
1280
|
+
async fetchCore(body, targetUrl, authHeaders) {
|
|
1281
|
+
try {
|
|
1282
|
+
return await this.fetchImpl(targetUrl, {
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
headers: {
|
|
1285
|
+
"Content-Type": "application/json",
|
|
1286
|
+
Accept: "text/event-stream",
|
|
1287
|
+
...authHeaders
|
|
1288
|
+
},
|
|
1289
|
+
body: JSON.stringify(body)
|
|
1290
|
+
});
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
throw new Error(`Cannot access the broods core service at ${targetUrl}. ` + `The SDK uses ${DEFAULT_CORE_BASE_URL} by default; set host/baseUrl or BROODS_HOST/BROODS_BASE_URL ` + `to your own core service URL if your account uses a custom deployment. ` + `Cause: ${error instanceof Error ? error.message : String(error)}`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
apiKeyHeaders() {
|
|
1296
|
+
if (!this.apiKey) {
|
|
1297
|
+
throw new Error("BroodsClient requires apiKey or BROODS_API_KEY.");
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
"Content-Type": "application/json",
|
|
1301
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
async fetchJson(targetUrl, init) {
|
|
1305
|
+
try {
|
|
1306
|
+
return await this.fetchImpl(targetUrl, {
|
|
1307
|
+
...init,
|
|
1308
|
+
headers: {
|
|
1309
|
+
"Content-Type": "application/json",
|
|
1310
|
+
...init.headers ?? {}
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
throw new Error(`Cannot access the broods core service at ${targetUrl}. ` + `Cause: ${error instanceof Error ? error.message : String(error)}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
resolveStatusUrl(status, options) {
|
|
1318
|
+
if (typeof status !== "string")
|
|
1319
|
+
return status.statusUrl;
|
|
1320
|
+
if (/^https?:\/\//.test(status))
|
|
1321
|
+
return status;
|
|
1322
|
+
if (!options.agentId)
|
|
1323
|
+
throw new Error("Polling by status id requires agentId.");
|
|
1324
|
+
return statusUrlFor(this.baseUrl, status, options.agentId);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function normalizeHttpServiceUrl(value) {
|
|
1328
|
+
const trimmed = value.trim();
|
|
1329
|
+
const withProtocol = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1330
|
+
return stripTrailingSlash(withProtocol);
|
|
1331
|
+
}
|
|
1332
|
+
function directRunBody(input, prefix) {
|
|
1333
|
+
const eventId = input.eventId ?? `${prefix}-${Date.now()}`;
|
|
1334
|
+
return {
|
|
1335
|
+
agentId: input.agentId,
|
|
1336
|
+
eventId,
|
|
1337
|
+
conversationKey: input.conversationKey ?? eventId,
|
|
1338
|
+
events: resolveRunEvents(input),
|
|
1339
|
+
...input.system !== undefined ? { system: input.system } : {},
|
|
1340
|
+
...input.model !== undefined ? { model: input.model } : {}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function normalizeAsyncAccepted(payload, requestBody) {
|
|
1344
|
+
if (!payload || typeof payload !== "object") {
|
|
1345
|
+
throw new Error("Async response must be an object");
|
|
1346
|
+
}
|
|
1347
|
+
const statusUrl = payload.statusUrl;
|
|
1348
|
+
if (typeof statusUrl !== "string" || statusUrl.length === 0) {
|
|
1349
|
+
throw new Error("Async response missing statusUrl");
|
|
1350
|
+
}
|
|
1351
|
+
const status = parseStatusUrl(statusUrl);
|
|
1352
|
+
if (!status.statusId)
|
|
1353
|
+
throw new Error("Async response statusUrl missing status id");
|
|
1354
|
+
return {
|
|
1355
|
+
statusUrl,
|
|
1356
|
+
statusId: status.statusId,
|
|
1357
|
+
eventId: status.statusId,
|
|
1358
|
+
agentId: status.agentId ?? requestBody.agentId
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function parseStatusUrl(statusUrl) {
|
|
1362
|
+
const url = new URL(statusUrl);
|
|
1363
|
+
const match = url.pathname.match(/\/status\/([^/]+)$/);
|
|
1364
|
+
return {
|
|
1365
|
+
statusId: match?.[1] ? decodeURIComponent(match[1]) : undefined,
|
|
1366
|
+
agentId: url.searchParams.get("agentId") ?? undefined
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
function statusUrlFor(baseUrl, statusId, agentId) {
|
|
1370
|
+
return `${normalizeHttpServiceUrl(baseUrl)}/status/${encodeURIComponent(statusId)}?agentId=${encodeURIComponent(agentId)}`;
|
|
1371
|
+
}
|
|
1372
|
+
async function responseErrorDetails(response, expected) {
|
|
1373
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1374
|
+
if (contentType.toLowerCase().includes("text/event-stream")) {
|
|
1375
|
+
await response.body?.cancel().catch(() => {});
|
|
1376
|
+
return `expected ${expected}, but the server returned an SSE stream. This usually means the core deployment routed /async to the direct streaming runner instead of the async handler.`;
|
|
1377
|
+
}
|
|
1378
|
+
const text = await response.text();
|
|
1379
|
+
if (text.length <= 2000)
|
|
1380
|
+
return text;
|
|
1381
|
+
return `${text.slice(0, 2000)}... [truncated ${text.length - 2000} chars]`;
|
|
1382
|
+
}
|
|
1383
|
+
async function cronErrorDetails(response) {
|
|
1384
|
+
const text = await response.text();
|
|
1385
|
+
if (text.includes("Request body must include eventId and conversationKey")) {
|
|
1386
|
+
return `${text}. Cron job APIs must be served by the configured baseUrl. ` + "Prefer defining stable cron jobs with defineCron(...) in broods/ and syncing with `broods dev` or `broods deploy`.";
|
|
1387
|
+
}
|
|
1388
|
+
return text;
|
|
1389
|
+
}
|
|
1390
|
+
function resolveCronInput(input) {
|
|
1391
|
+
if ("agentId" in input)
|
|
1392
|
+
return input;
|
|
1393
|
+
const agent = input.agent;
|
|
1394
|
+
const agentId = typeof agent === "string" ? agent : agent.id;
|
|
1395
|
+
const { agent: _agent, ...rest } = input;
|
|
1396
|
+
return { ...rest, agentId };
|
|
1397
|
+
}
|
|
1398
|
+
function sleep(ms, signal) {
|
|
1399
|
+
return new Promise((resolvePromise, reject) => {
|
|
1400
|
+
if (signal?.aborted) {
|
|
1401
|
+
reject(new Error("Async status polling aborted."));
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const timer = setTimeout(resolvePromise, ms);
|
|
1405
|
+
signal?.addEventListener("abort", () => {
|
|
1406
|
+
clearTimeout(timer);
|
|
1407
|
+
reject(new Error("Async status polling aborted."));
|
|
1408
|
+
}, { once: true });
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
function formatStreamError(error) {
|
|
1412
|
+
if (typeof error === "string")
|
|
1413
|
+
return error;
|
|
1414
|
+
if (!error || typeof error !== "object")
|
|
1415
|
+
return String(error);
|
|
1416
|
+
const err = error;
|
|
1417
|
+
const detail = err.data?.error?.message ?? err.message ?? err.responseBody ?? JSON.stringify(error);
|
|
1418
|
+
const prefix = err.name ? `${err.name}: ` : "";
|
|
1419
|
+
const status = err.statusCode ? ` (HTTP ${err.statusCode})` : "";
|
|
1420
|
+
return `${prefix}${detail}${status}`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/websocket.ts
|
|
1424
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 2000;
|
|
1425
|
+
var WS_CONNECTING = 0;
|
|
1426
|
+
var WS_OPEN = 1;
|
|
1427
|
+
var LAMBDA_FUNCTION_URL_HOST_RE = /\.lambda-url\.[a-z0-9-]+\.on\.aws$/i;
|
|
1428
|
+
|
|
1429
|
+
class BroodsWebSocketClient {
|
|
1430
|
+
baseUrl;
|
|
1431
|
+
apiKey;
|
|
1432
|
+
WebSocketImpl;
|
|
1433
|
+
connectTimeoutMs;
|
|
1434
|
+
constructor(options = {}) {
|
|
1435
|
+
loadBroodsRuntimeConfig();
|
|
1436
|
+
this.baseUrl = normalizeWebSocketServiceUrl(options.baseUrl || process.env.BROODS_WEBSOCKET_URL || options.host || process.env.BROODS_BASE_URL || process.env.BROODS_HOST || DEFAULT_CORE_BASE_URL);
|
|
1437
|
+
this.apiKey = options.apiKey || process.env.BROODS_API_KEY || "";
|
|
1438
|
+
if (!this.apiKey)
|
|
1439
|
+
throw new Error("BroodsWebSocketClient requires apiKey or BROODS_API_KEY.");
|
|
1440
|
+
this.WebSocketImpl = options.WebSocket;
|
|
1441
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1442
|
+
}
|
|
1443
|
+
subscribe(input, handlers = {}) {
|
|
1444
|
+
const WebSocketImpl = this.resolveWebSocket();
|
|
1445
|
+
const url = this.buildUrl(input);
|
|
1446
|
+
const agentId = resolveAgentId(input);
|
|
1447
|
+
const accessError = webSocketAccessError(this.baseUrl);
|
|
1448
|
+
const socket = new WebSocketImpl(url);
|
|
1449
|
+
let opened = false;
|
|
1450
|
+
let closed = false;
|
|
1451
|
+
const timeout = setTimeout(() => {
|
|
1452
|
+
if (!opened)
|
|
1453
|
+
fail(accessError);
|
|
1454
|
+
}, this.connectTimeoutMs);
|
|
1455
|
+
const close = (code = 1000, reason = "client closed") => {
|
|
1456
|
+
if (closed)
|
|
1457
|
+
return;
|
|
1458
|
+
closed = true;
|
|
1459
|
+
clearTimeout(timeout);
|
|
1460
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
1461
|
+
if (socket.readyState === WS_OPEN || socket.readyState === WS_CONNECTING) {
|
|
1462
|
+
socket.close(code, reason);
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
const fail = (error) => {
|
|
1466
|
+
handlers.onError?.(error);
|
|
1467
|
+
close(1011, "websocket error");
|
|
1468
|
+
};
|
|
1469
|
+
const finish = () => {
|
|
1470
|
+
handlers.onDone?.();
|
|
1471
|
+
close(1000, "done");
|
|
1472
|
+
};
|
|
1473
|
+
const onAbort = () => {
|
|
1474
|
+
if (socket.readyState === WS_OPEN) {
|
|
1475
|
+
socket.send(JSON.stringify({ type: "cancel" }));
|
|
1476
|
+
}
|
|
1477
|
+
fail(new Error("WebSocket run aborted."));
|
|
1478
|
+
};
|
|
1479
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1480
|
+
socket.onopen = () => {
|
|
1481
|
+
opened = true;
|
|
1482
|
+
clearTimeout(timeout);
|
|
1483
|
+
if (input.signal?.aborted) {
|
|
1484
|
+
onAbort();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
socket.send(JSON.stringify({
|
|
1488
|
+
type: "execute",
|
|
1489
|
+
agentId,
|
|
1490
|
+
events: resolveRunEvents(input),
|
|
1491
|
+
sessionId: input.sessionId,
|
|
1492
|
+
...input.eventId !== undefined ? { eventId: input.eventId } : {},
|
|
1493
|
+
...input.system !== undefined ? { system: input.system } : {},
|
|
1494
|
+
...input.model !== undefined ? { model: input.model } : {}
|
|
1495
|
+
}));
|
|
1496
|
+
};
|
|
1497
|
+
socket.onmessage = (event) => {
|
|
1498
|
+
if (typeof event.data !== "string")
|
|
1499
|
+
return;
|
|
1500
|
+
const payload = parseServerMessage(event.data);
|
|
1501
|
+
if (!payload)
|
|
1502
|
+
return;
|
|
1503
|
+
handlers.onMessage?.(payload);
|
|
1504
|
+
switch (payload.type) {
|
|
1505
|
+
case "meta":
|
|
1506
|
+
handlers.onMeta?.(payload);
|
|
1507
|
+
break;
|
|
1508
|
+
case "done":
|
|
1509
|
+
finish();
|
|
1510
|
+
break;
|
|
1511
|
+
case "error":
|
|
1512
|
+
fail(new Error(formatWireError(payload.error)));
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
socket.onerror = () => {
|
|
1517
|
+
fail(accessError);
|
|
1518
|
+
};
|
|
1519
|
+
socket.onclose = (event) => {
|
|
1520
|
+
clearTimeout(timeout);
|
|
1521
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
1522
|
+
if (closed || event.code === 1000)
|
|
1523
|
+
return;
|
|
1524
|
+
fail(new Error(event.reason || "WebSocket connection closed."));
|
|
1525
|
+
};
|
|
1526
|
+
return {
|
|
1527
|
+
url,
|
|
1528
|
+
close
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
async* stream(input) {
|
|
1532
|
+
const queue = [];
|
|
1533
|
+
let done = false;
|
|
1534
|
+
let error = null;
|
|
1535
|
+
let wake = null;
|
|
1536
|
+
const wakeReader = () => {
|
|
1537
|
+
wake?.();
|
|
1538
|
+
wake = null;
|
|
1539
|
+
};
|
|
1540
|
+
const subscription = this.subscribe(input, {
|
|
1541
|
+
onMessage(message) {
|
|
1542
|
+
queue.push(message);
|
|
1543
|
+
wakeReader();
|
|
1544
|
+
},
|
|
1545
|
+
onDone() {
|
|
1546
|
+
done = true;
|
|
1547
|
+
wakeReader();
|
|
1548
|
+
},
|
|
1549
|
+
onError(nextError) {
|
|
1550
|
+
error = nextError;
|
|
1551
|
+
wakeReader();
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
try {
|
|
1555
|
+
while (true) {
|
|
1556
|
+
if (queue.length > 0) {
|
|
1557
|
+
yield queue.shift();
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (error)
|
|
1561
|
+
throw error;
|
|
1562
|
+
if (done)
|
|
1563
|
+
return;
|
|
1564
|
+
await new Promise((resolve4) => {
|
|
1565
|
+
wake = resolve4;
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
} finally {
|
|
1569
|
+
subscription.close();
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
buildUrl(input) {
|
|
1573
|
+
const endpointId = resolveEndpointId(input);
|
|
1574
|
+
const projectSlug = input.projectSlug ?? input.agent?.projectSlug;
|
|
1575
|
+
const environmentSlug = input.environmentSlug ?? input.agent?.environmentSlug;
|
|
1576
|
+
const projectPrefix = projectSlug ? `/${projectSlug}` : "";
|
|
1577
|
+
const environmentPrefix = environmentSlug ? `/${environmentSlug}` : "";
|
|
1578
|
+
const wsBaseUrl = toWebSocketBaseUrl(this.baseUrl);
|
|
1579
|
+
return `${wsBaseUrl}/v1${projectPrefix}/agents${environmentPrefix}/${encodeURIComponent(endpointId)}/ws` + `?token=${encodeURIComponent(this.apiKey)}`;
|
|
1580
|
+
}
|
|
1581
|
+
resolveWebSocket() {
|
|
1582
|
+
const WebSocketImpl = this.WebSocketImpl ?? globalThis.WebSocket;
|
|
1583
|
+
if (!WebSocketImpl) {
|
|
1584
|
+
throw new Error("WebSocket is not available. Pass a WebSocket implementation in BroodsWebSocketClient options.");
|
|
1585
|
+
}
|
|
1586
|
+
return WebSocketImpl;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function resolveAgentId(input) {
|
|
1590
|
+
const agentId = input.agentId ?? input.agent?.id ?? input.endpointId;
|
|
1591
|
+
if (!agentId) {
|
|
1592
|
+
throw new Error("WebSocket run requires agent, agentId, or endpointId.");
|
|
1593
|
+
}
|
|
1594
|
+
return agentId;
|
|
1595
|
+
}
|
|
1596
|
+
function resolveEndpointId(input) {
|
|
1597
|
+
const endpointId = input.endpointId ?? input.agent?.endpointId ?? input.agent?.id;
|
|
1598
|
+
if (!endpointId) {
|
|
1599
|
+
throw new Error("WebSocket run requires agent or endpointId.");
|
|
1600
|
+
}
|
|
1601
|
+
return endpointId;
|
|
1602
|
+
}
|
|
1603
|
+
function toWebSocketBaseUrl(url) {
|
|
1604
|
+
const parsed = new URL(normalizeWebSocketServiceUrl(url));
|
|
1605
|
+
if (parsed.protocol === "https:")
|
|
1606
|
+
parsed.protocol = "wss:";
|
|
1607
|
+
if (parsed.protocol === "http:")
|
|
1608
|
+
parsed.protocol = "ws:";
|
|
1609
|
+
return stripTrailingSlash(parsed.toString());
|
|
1610
|
+
}
|
|
1611
|
+
function normalizeWebSocketServiceUrl(value) {
|
|
1612
|
+
const trimmed = value.trim();
|
|
1613
|
+
if (/^wss?:\/\//.test(trimmed))
|
|
1614
|
+
return stripTrailingSlash(trimmed);
|
|
1615
|
+
return normalizeHttpServiceUrl(trimmed);
|
|
1616
|
+
}
|
|
1617
|
+
function webSocketAccessError(baseUrl) {
|
|
1618
|
+
const host = new URL(baseUrl).hostname;
|
|
1619
|
+
if (LAMBDA_FUNCTION_URL_HOST_RE.test(host)) {
|
|
1620
|
+
return new Error(`Cannot access the WebSocket service at ${baseUrl}. AWS Lambda Function URLs do not support WebSocket upgrades; ` + "set BROODS_WEBSOCKET_URL or BROODS_HOST to the deployed WebSocket gateway URL instead.");
|
|
1621
|
+
}
|
|
1622
|
+
return new Error(`Cannot access the WebSocket service at ${baseUrl}.`);
|
|
1623
|
+
}
|
|
1624
|
+
function parseServerMessage(data) {
|
|
1625
|
+
try {
|
|
1626
|
+
const value = JSON.parse(data);
|
|
1627
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" ? value : null;
|
|
1628
|
+
} catch {
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
function formatWireError(error) {
|
|
1633
|
+
if (typeof error === "string")
|
|
1634
|
+
return error;
|
|
1635
|
+
if (error && typeof error === "object" && typeof error.message === "string") {
|
|
1636
|
+
return error.message;
|
|
1637
|
+
}
|
|
1638
|
+
return error === undefined ? "WebSocket stream error." : JSON.stringify(error);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// src/observability-client.ts
|
|
1642
|
+
var WS_OPEN2 = 1;
|
|
1643
|
+
var WS_CONNECTING2 = 0;
|
|
1644
|
+
function buildObservabilityUrl(baseUrl, project, environment, apiKey) {
|
|
1645
|
+
const wsBase = toWebSocketBaseUrl(baseUrl);
|
|
1646
|
+
return `${wsBase}/v1/${encodeURIComponent(project)}/${encodeURIComponent(environment)}/observability/ws` + `?token=${encodeURIComponent(apiKey)}`;
|
|
1647
|
+
}
|
|
1648
|
+
function parseServerMessage2(data) {
|
|
1649
|
+
if (typeof data !== "string")
|
|
1650
|
+
return null;
|
|
1651
|
+
try {
|
|
1652
|
+
const value = JSON.parse(data);
|
|
1653
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" ? value : null;
|
|
1654
|
+
} catch {
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function resolveWebSocket() {
|
|
1659
|
+
const impl = globalThis.WebSocket;
|
|
1660
|
+
if (!impl)
|
|
1661
|
+
throw new Error("WebSocket is not available in this environment.");
|
|
1662
|
+
return impl;
|
|
1663
|
+
}
|
|
1664
|
+
async function* subscribeObservabilityLogs(options, subscribeOptions = {}) {
|
|
1665
|
+
const seen = new Set;
|
|
1666
|
+
let retryMs = 500;
|
|
1667
|
+
while (!subscribeOptions.signal?.aborted) {
|
|
1668
|
+
try {
|
|
1669
|
+
for await (const entry of subscribeObservabilityLogsOnce(options, subscribeOptions)) {
|
|
1670
|
+
const key = `${entry.ts}|${entry.eventType}|${entry.message}`;
|
|
1671
|
+
if (seen.has(key))
|
|
1672
|
+
continue;
|
|
1673
|
+
seen.add(key);
|
|
1674
|
+
if (seen.size > 5000)
|
|
1675
|
+
seen.delete(seen.values().next().value);
|
|
1676
|
+
retryMs = 500;
|
|
1677
|
+
yield entry;
|
|
1678
|
+
}
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
if (subscribeOptions.signal?.aborted)
|
|
1681
|
+
return;
|
|
1682
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1683
|
+
if (/unauthorized|invalid websocket token|scope does not match/i.test(message))
|
|
1684
|
+
throw error;
|
|
1685
|
+
}
|
|
1686
|
+
await reconnectDelay(retryMs, subscribeOptions.signal);
|
|
1687
|
+
retryMs = Math.min(retryMs * 2, 5000);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
async function* subscribeObservabilityLogsOnce(options, subscribeOptions) {
|
|
1691
|
+
const { baseUrl, apiKey, project, environment } = options;
|
|
1692
|
+
const { backfill = 0, minLevel, signal } = subscribeOptions;
|
|
1693
|
+
if (signal?.aborted)
|
|
1694
|
+
return;
|
|
1695
|
+
const url = buildObservabilityUrl(baseUrl, project, environment, apiKey);
|
|
1696
|
+
const displayUrl = url.slice(0, url.indexOf("?"));
|
|
1697
|
+
const WebSocketImpl = resolveWebSocket();
|
|
1698
|
+
const entries = [];
|
|
1699
|
+
let socketError = null;
|
|
1700
|
+
let done = false;
|
|
1701
|
+
let wake = null;
|
|
1702
|
+
const notify = () => {
|
|
1703
|
+
wake?.();
|
|
1704
|
+
wake = null;
|
|
1705
|
+
};
|
|
1706
|
+
const socket = new WebSocketImpl(url);
|
|
1707
|
+
const cleanup = () => {
|
|
1708
|
+
if (socket.readyState === WS_OPEN2 || socket.readyState === WS_CONNECTING2) {
|
|
1709
|
+
socket.close(1000, "client closed");
|
|
1710
|
+
}
|
|
1711
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1712
|
+
};
|
|
1713
|
+
const onAbort = () => {
|
|
1714
|
+
done = true;
|
|
1715
|
+
notify();
|
|
1716
|
+
cleanup();
|
|
1717
|
+
};
|
|
1718
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1719
|
+
socket.onopen = () => {
|
|
1720
|
+
if (signal?.aborted) {
|
|
1721
|
+
cleanup();
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const msg = {
|
|
1725
|
+
type: "subscribe",
|
|
1726
|
+
stream: "logs",
|
|
1727
|
+
...backfill > 0 ? { backfill } : {},
|
|
1728
|
+
...minLevel !== undefined ? { minLevel } : {}
|
|
1729
|
+
};
|
|
1730
|
+
socket.send(JSON.stringify(msg));
|
|
1731
|
+
};
|
|
1732
|
+
socket.onmessage = (event) => {
|
|
1733
|
+
const msg = parseServerMessage2(event.data);
|
|
1734
|
+
if (!msg)
|
|
1735
|
+
return;
|
|
1736
|
+
switch (msg.type) {
|
|
1737
|
+
case "backfill":
|
|
1738
|
+
if (msg.stream === "logs") {
|
|
1739
|
+
for (const entry of msg.entries) {
|
|
1740
|
+
entries.push(entry);
|
|
1741
|
+
}
|
|
1742
|
+
notify();
|
|
1743
|
+
}
|
|
1744
|
+
break;
|
|
1745
|
+
case "log":
|
|
1746
|
+
entries.push(msg.entry);
|
|
1747
|
+
notify();
|
|
1748
|
+
break;
|
|
1749
|
+
case "error":
|
|
1750
|
+
socketError = new Error(`Observability gateway error: ${msg.error}`);
|
|
1751
|
+
done = true;
|
|
1752
|
+
notify();
|
|
1753
|
+
break;
|
|
1754
|
+
case "ready":
|
|
1755
|
+
break;
|
|
1756
|
+
default:
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
socket.onerror = () => {
|
|
1761
|
+
socketError = new Error(`Cannot connect to the observability gateway at ${displayUrl}.`);
|
|
1762
|
+
done = true;
|
|
1763
|
+
notify();
|
|
1764
|
+
};
|
|
1765
|
+
socket.onclose = (event) => {
|
|
1766
|
+
if (!done) {
|
|
1767
|
+
if (event.code !== 1000) {
|
|
1768
|
+
socketError = new Error(event.reason ? `Observability WebSocket closed: ${event.reason}` : `Observability WebSocket closed with code ${event.code}.`);
|
|
1769
|
+
}
|
|
1770
|
+
done = true;
|
|
1771
|
+
notify();
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
try {
|
|
1775
|
+
while (true) {
|
|
1776
|
+
if (entries.length > 0) {
|
|
1777
|
+
yield entries.shift();
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
if (socketError)
|
|
1781
|
+
throw socketError;
|
|
1782
|
+
if (done)
|
|
1783
|
+
return;
|
|
1784
|
+
await new Promise((resolve4) => {
|
|
1785
|
+
wake = resolve4;
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
} finally {
|
|
1789
|
+
cleanup();
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
function reconnectDelay(ms, signal) {
|
|
1793
|
+
return new Promise((resolve4) => {
|
|
1794
|
+
if (signal?.aborted)
|
|
1795
|
+
return resolve4();
|
|
1796
|
+
const timer = setTimeout(resolve4, ms);
|
|
1797
|
+
signal?.addEventListener("abort", () => {
|
|
1798
|
+
clearTimeout(timer);
|
|
1799
|
+
resolve4();
|
|
1800
|
+
}, { once: true });
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// src/cli/utils.ts
|
|
1805
|
+
import { createServer } from "http";
|
|
1806
|
+
import { spawn } from "child_process";
|
|
1807
|
+
import { createInterface } from "readline/promises";
|
|
1808
|
+
import { stdin as input, stdout as output } from "process";
|
|
1809
|
+
var LOGIN_TIMEOUT_MS = 3 * 60 * 1000;
|
|
1810
|
+
function optionValue(args, name) {
|
|
1811
|
+
const index = args.indexOf(name);
|
|
1812
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
1813
|
+
}
|
|
1814
|
+
function hasFlag(args, name) {
|
|
1815
|
+
return args.includes(name);
|
|
1816
|
+
}
|
|
1817
|
+
async function requireAuth(dashboardUrl) {
|
|
1818
|
+
loadBroodsRuntimeConfig();
|
|
1819
|
+
const auth = await readStoredAuth();
|
|
1820
|
+
if (!auth) {
|
|
1821
|
+
throw new Error("Run `broods login` first, or set BROODS_TOKEN and BROODS_DASHBOARD_URL.");
|
|
1822
|
+
}
|
|
1823
|
+
return dashboardUrl ? { ...auth, dashboardUrl } : auth;
|
|
1824
|
+
}
|
|
1825
|
+
async function promptSecret(label) {
|
|
1826
|
+
const rl = createInterface({ input, output });
|
|
1827
|
+
try {
|
|
1828
|
+
const value = await rl.question(`${label}: `);
|
|
1829
|
+
if (!value)
|
|
1830
|
+
throw new Error(`${label} is required`);
|
|
1831
|
+
return value;
|
|
1832
|
+
} finally {
|
|
1833
|
+
rl.close();
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
async function promptText(label, defaultValue) {
|
|
1837
|
+
const rl = createInterface({ input, output });
|
|
1838
|
+
try {
|
|
1839
|
+
const pending = rl.question(`${label}: `);
|
|
1840
|
+
if (defaultValue) {
|
|
1841
|
+
if (input.isTTY && output.isTTY)
|
|
1842
|
+
output.write("\x1B[90m");
|
|
1843
|
+
rl.write(defaultValue);
|
|
1844
|
+
if (input.isTTY && output.isTTY)
|
|
1845
|
+
output.write("\x1B[0m");
|
|
1846
|
+
}
|
|
1847
|
+
const answer = (await pending).trim();
|
|
1848
|
+
return answer || defaultValue || "";
|
|
1849
|
+
} finally {
|
|
1850
|
+
rl.close();
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
async function promptSelect(label, options, render) {
|
|
1854
|
+
if (options.length === 0)
|
|
1855
|
+
throw new Error(`${label}: no options available`);
|
|
1856
|
+
if (options.length === 1)
|
|
1857
|
+
return options[0];
|
|
1858
|
+
console.log(label);
|
|
1859
|
+
options.forEach((option, index) => {
|
|
1860
|
+
console.log(` ${index + 1}. ${render(option)}`);
|
|
1861
|
+
});
|
|
1862
|
+
const rl = createInterface({ input, output });
|
|
1863
|
+
try {
|
|
1864
|
+
while (true) {
|
|
1865
|
+
const answer = (await rl.question(`Choose 1-${options.length}: `)).trim();
|
|
1866
|
+
const index = Number(answer);
|
|
1867
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
1868
|
+
return options[index - 1];
|
|
1869
|
+
}
|
|
1870
|
+
console.log(`Enter a number from 1 to ${options.length}.`);
|
|
1871
|
+
}
|
|
1872
|
+
} finally {
|
|
1873
|
+
rl.close();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
async function promptConfirm(question) {
|
|
1877
|
+
if (!input.isTTY)
|
|
1878
|
+
return false;
|
|
1879
|
+
const rl = createInterface({ input, output });
|
|
1880
|
+
try {
|
|
1881
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
1882
|
+
return answer === "y" || answer === "yes";
|
|
1883
|
+
} finally {
|
|
1884
|
+
rl.close();
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
async function loginWithBrowser(dashboardUrl) {
|
|
1888
|
+
const state = crypto.randomUUID();
|
|
1889
|
+
const { code, close } = await waitForCallback(state);
|
|
1890
|
+
try {
|
|
1891
|
+
const callbackUrl = code.callbackUrl;
|
|
1892
|
+
const startUrl = `${stripTrailingSlash(dashboardUrl)}/cli-auth/start?` + new URLSearchParams({ callback: callbackUrl, state });
|
|
1893
|
+
await assertCliAuthRouteExists(startUrl);
|
|
1894
|
+
openBrowser(startUrl);
|
|
1895
|
+
console.log(`Opening ${startUrl}`);
|
|
1896
|
+
const loginCode = await waitWithTimeout(code.promise, LOGIN_TIMEOUT_MS);
|
|
1897
|
+
const response = await fetch(`${stripTrailingSlash(dashboardUrl)}/api/cli/auth/exchange`, {
|
|
1898
|
+
method: "POST",
|
|
1899
|
+
headers: { "Content-Type": "application/json" },
|
|
1900
|
+
body: JSON.stringify({ code: loginCode })
|
|
1901
|
+
});
|
|
1902
|
+
if (!response.ok) {
|
|
1903
|
+
throw new Error(`Login exchange failed: ${response.status} ${await response.text()}`);
|
|
1904
|
+
}
|
|
1905
|
+
const payload = await response.json();
|
|
1906
|
+
const auth = {
|
|
1907
|
+
dashboardUrl: stripTrailingSlash(dashboardUrl),
|
|
1908
|
+
token: payload.token,
|
|
1909
|
+
createdAt: new Date().toISOString(),
|
|
1910
|
+
...payload.user ? { user: payload.user } : {},
|
|
1911
|
+
...payload.org ? { org: payload.org } : {},
|
|
1912
|
+
...payload.account ? { account: payload.account } : {}
|
|
1913
|
+
};
|
|
1914
|
+
await writeStoredAuth(auth);
|
|
1915
|
+
return auth;
|
|
1916
|
+
} finally {
|
|
1917
|
+
close();
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
async function assertCliAuthRouteExists(startUrl) {
|
|
1921
|
+
const response = await fetch(startUrl, {
|
|
1922
|
+
method: "GET",
|
|
1923
|
+
redirect: "manual"
|
|
1924
|
+
});
|
|
1925
|
+
if (response.status === 404) {
|
|
1926
|
+
const url = new URL(startUrl);
|
|
1927
|
+
throw new Error(`${url.origin} does not expose /cli-auth/start yet. Deploy the dashboard changes first, ` + `or use --dashboard-url http://localhost:3000 with a local dashboard dev server.`);
|
|
1928
|
+
}
|
|
1929
|
+
if (response.status >= 500) {
|
|
1930
|
+
throw new Error(`Dashboard CLI auth route failed: ${response.status} ${await response.text()}`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
function waitForCallback(expectedState) {
|
|
1934
|
+
return new Promise((resolve4, reject) => {
|
|
1935
|
+
const server = createServer((req, res) => {
|
|
1936
|
+
try {
|
|
1937
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1938
|
+
const state = url.searchParams.get("state");
|
|
1939
|
+
const code = url.searchParams.get("code");
|
|
1940
|
+
if (state !== expectedState || !code) {
|
|
1941
|
+
res.writeHead(400).end("Invalid broods login callback.");
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
res.writeHead(200, { "Content-Type": "text/plain" }).end("broods CLI login complete. You can close this tab.");
|
|
1945
|
+
callbackResolve(code);
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
callbackReject(error);
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
let callbackResolve;
|
|
1951
|
+
let callbackReject;
|
|
1952
|
+
const promise = new Promise((res, rej) => {
|
|
1953
|
+
callbackResolve = res;
|
|
1954
|
+
callbackReject = rej;
|
|
1955
|
+
});
|
|
1956
|
+
const requestedPort = callbackPort();
|
|
1957
|
+
server.on("error", (error) => {
|
|
1958
|
+
if (!process.env.BROODS_LOGIN_PORT && requestedPort !== 0 && error.code === "EADDRINUSE") {
|
|
1959
|
+
server.listen(0, "127.0.0.1");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
reject(error);
|
|
1963
|
+
});
|
|
1964
|
+
server.listen(requestedPort, "127.0.0.1", () => {
|
|
1965
|
+
const address = server.address();
|
|
1966
|
+
if (!address || typeof address === "string") {
|
|
1967
|
+
reject(new Error("Failed to allocate callback port"));
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
resolve4({
|
|
1971
|
+
code: {
|
|
1972
|
+
callbackUrl: `http://127.0.0.1:${address.port}/callback`,
|
|
1973
|
+
promise
|
|
1974
|
+
},
|
|
1975
|
+
close: () => server.close()
|
|
1976
|
+
});
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
async function waitWithTimeout(promise, ms) {
|
|
1981
|
+
let timer;
|
|
1982
|
+
const timeout = new Promise((_, reject) => {
|
|
1983
|
+
timer = setTimeout(() => reject(new Error(`Timed out waiting for browser login to complete.
|
|
1984
|
+
` + `Check the browser tab and the dashboard logs for an error. If the browser shows
|
|
1985
|
+
` + `404 on /cli-auth/start, deploy the dashboard build that includes CLI auth or pass
|
|
1986
|
+
` + `--dashboard-url for the environment you deployed. Other common causes are missing
|
|
1987
|
+
` + "cliAuth Convex functions or no active API account (Settings -> API Access).")), ms);
|
|
1988
|
+
});
|
|
1989
|
+
try {
|
|
1990
|
+
return await Promise.race([promise, timeout]);
|
|
1991
|
+
} finally {
|
|
1992
|
+
if (timer)
|
|
1993
|
+
clearTimeout(timer);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
function callbackPort() {
|
|
1997
|
+
const raw = process.env.BROODS_LOGIN_PORT;
|
|
1998
|
+
if (raw) {
|
|
1999
|
+
const port = Number(raw);
|
|
2000
|
+
if (Number.isInteger(port) && port > 0 && port < 65536)
|
|
2001
|
+
return port;
|
|
2002
|
+
throw new Error("BROODS_LOGIN_PORT must be a TCP port number");
|
|
2003
|
+
}
|
|
2004
|
+
return 18987;
|
|
2005
|
+
}
|
|
2006
|
+
function openBrowser(url) {
|
|
2007
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
2008
|
+
const args = process.platform === "win32" ? ["/c", "start", url] : [url];
|
|
2009
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
2010
|
+
child.unref();
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// src/cli/output.ts
|
|
2014
|
+
var RESET = "\x1B[0m";
|
|
2015
|
+
var DIM = "\x1B[2m";
|
|
2016
|
+
var UNDERLINE = "\x1B[4m";
|
|
2017
|
+
var GREEN = "\x1B[32m";
|
|
2018
|
+
var YELLOW = "\x1B[33m";
|
|
2019
|
+
var RED = "\x1B[31m";
|
|
2020
|
+
var CYAN = "\x1B[36m";
|
|
2021
|
+
var LABEL_BG_GREEN = "\x1B[42m\x1B[30m\x1B[1m";
|
|
2022
|
+
function formatDeploymentTarget(target, options = {}) {
|
|
2023
|
+
const color = shouldUseColor(options);
|
|
2024
|
+
const bar = paint("\u258C", GREEN, color);
|
|
2025
|
+
const label = color ? `${LABEL_BG_GREEN} Development ${RESET}` : "[Development]";
|
|
2026
|
+
const dashboardText = color ? paint("dashboard", UNDERLINE, color) : "dashboard";
|
|
2027
|
+
const deepLink = `${target.dashboardUrl}?project=${encodeURIComponent(target.project)}&env=${encodeURIComponent(target.environment)}`;
|
|
2028
|
+
const url = paint(deepLink, `${DIM}${UNDERLINE}`, color);
|
|
2029
|
+
return [
|
|
2030
|
+
`${bar} Syncing Development: ${paint(target.project, "", color)}`,
|
|
2031
|
+
`${bar} ${label} ${target.environment} (${dashboardText})`,
|
|
2032
|
+
`${bar} ${paint("\u2514\u2500", DIM, color)} ${url}`
|
|
2033
|
+
].join(`
|
|
2034
|
+
`);
|
|
2035
|
+
}
|
|
2036
|
+
function formatReadyLine(durationMs, options = {}) {
|
|
2037
|
+
const time = (options.now ?? new Date).toTimeString().slice(0, 8);
|
|
2038
|
+
return `${paint("\u2714", GREEN, shouldUseColor(options))} ${time} Resources ready! (${formatDuration(durationMs)})`;
|
|
2039
|
+
}
|
|
2040
|
+
function formatEnvSync(names, options = {}) {
|
|
2041
|
+
const color = shouldUseColor(options);
|
|
2042
|
+
const bar = paint("\u258C", GREEN, color);
|
|
2043
|
+
const arrow = paint("\u2191", GREEN, color);
|
|
2044
|
+
return `${bar} ${arrow} Synced ${names.length} env var(s) from .env.local: ${names.join(", ")}`;
|
|
2045
|
+
}
|
|
2046
|
+
function formatDiffEntries(entries, options = {}) {
|
|
2047
|
+
const color = shouldUseColor(options);
|
|
2048
|
+
return entries.map((entry) => {
|
|
2049
|
+
const marker = formatDiffMarker(entry.operation, color);
|
|
2050
|
+
if (entry.operation === "rename" && entry.previousName) {
|
|
2051
|
+
return ` ${marker} ${entry.kind}:${entry.previousName} -> ${entry.name}`;
|
|
2052
|
+
}
|
|
2053
|
+
return ` ${marker} ${entry.kind}:${entry.name}`;
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
function formatWarning(message, options = {}) {
|
|
2057
|
+
return paint(message, YELLOW, shouldUseColor(options));
|
|
2058
|
+
}
|
|
2059
|
+
function printReadyLine(durationMs) {
|
|
2060
|
+
console.error(formatReadyLine(durationMs));
|
|
2061
|
+
}
|
|
2062
|
+
function printDeploymentTarget(target) {
|
|
2063
|
+
console.error(formatDeploymentTarget(target));
|
|
2064
|
+
}
|
|
2065
|
+
function printEnvSync(names) {
|
|
2066
|
+
console.error(formatEnvSync(names));
|
|
2067
|
+
}
|
|
2068
|
+
function printDiffEntries(entries) {
|
|
2069
|
+
for (const line of formatDiffEntries(entries))
|
|
2070
|
+
console.log(line);
|
|
2071
|
+
}
|
|
2072
|
+
function printWarning(message) {
|
|
2073
|
+
console.log(formatWarning(message));
|
|
2074
|
+
}
|
|
2075
|
+
function shouldUseColor(options) {
|
|
2076
|
+
if (options.color !== undefined)
|
|
2077
|
+
return options.color;
|
|
2078
|
+
if (Object.hasOwn(process.env, "NO_COLOR"))
|
|
2079
|
+
return false;
|
|
2080
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0")
|
|
2081
|
+
return true;
|
|
2082
|
+
return process.stderr.isTTY && process.env.TERM !== "dumb";
|
|
2083
|
+
}
|
|
2084
|
+
function paint(value, style, color) {
|
|
2085
|
+
return color ? `${style}${value}${RESET}` : value;
|
|
2086
|
+
}
|
|
2087
|
+
function formatDiffMarker(operation, color) {
|
|
2088
|
+
if (operation === "create")
|
|
2089
|
+
return `[${paint("+", GREEN, color)}]`;
|
|
2090
|
+
if (operation === "rename")
|
|
2091
|
+
return `[${paint("~", YELLOW, color)}]`;
|
|
2092
|
+
if (operation === "update")
|
|
2093
|
+
return `[${paint("*", CYAN, color)}]`;
|
|
2094
|
+
return `[${paint("-", RED, color)}]`;
|
|
2095
|
+
}
|
|
2096
|
+
function formatDuration(ms) {
|
|
2097
|
+
if (ms >= 1000)
|
|
2098
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
2099
|
+
return `${Math.max(ms, 0).toFixed(1)}ms`;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// src/cli/render.ts
|
|
2103
|
+
var RESET2 = "\x1B[0m";
|
|
2104
|
+
var DIM2 = "\x1B[90m";
|
|
2105
|
+
var GREEN2 = "\x1B[32m";
|
|
2106
|
+
var CYAN2 = "\x1B[36m";
|
|
2107
|
+
var MAGENTA = "\x1B[35m";
|
|
2108
|
+
var YELLOW2 = "\x1B[33m";
|
|
2109
|
+
var GREY = "\x1B[37m";
|
|
2110
|
+
var RED2 = "\x1B[31m";
|
|
2111
|
+
function createRenderState() {
|
|
2112
|
+
return { streamedToolInputs: new Set };
|
|
2113
|
+
}
|
|
2114
|
+
function useColor() {
|
|
2115
|
+
if (Object.hasOwn(process.env, "NO_COLOR"))
|
|
2116
|
+
return false;
|
|
2117
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0")
|
|
2118
|
+
return true;
|
|
2119
|
+
return Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb";
|
|
2120
|
+
}
|
|
2121
|
+
function paint2(value, style) {
|
|
2122
|
+
return useColor() ? `${style}${value}${RESET2}` : value;
|
|
2123
|
+
}
|
|
2124
|
+
function write(value, style) {
|
|
2125
|
+
process.stdout.write(style ? paint2(value, style) : value);
|
|
2126
|
+
}
|
|
2127
|
+
function preview(value, max = 500) {
|
|
2128
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
2129
|
+
if (text === undefined)
|
|
2130
|
+
return "";
|
|
2131
|
+
return text.length > max ? `${text.slice(0, max)}\u2026 [${text.length - max} more chars]` : text;
|
|
2132
|
+
}
|
|
2133
|
+
function formatUsage(usage) {
|
|
2134
|
+
const parts = [];
|
|
2135
|
+
if (typeof usage.inputTokens === "number")
|
|
2136
|
+
parts.push(`${usage.inputTokens} in`);
|
|
2137
|
+
if (typeof usage.outputTokens === "number")
|
|
2138
|
+
parts.push(`${usage.outputTokens} out`);
|
|
2139
|
+
if (parts.length === 0 && typeof usage.totalTokens === "number")
|
|
2140
|
+
parts.push(`${usage.totalTokens} tokens`);
|
|
2141
|
+
return parts.join(" / ");
|
|
2142
|
+
}
|
|
2143
|
+
function renderStreamPart(part, state) {
|
|
2144
|
+
switch (part.type) {
|
|
2145
|
+
case "reasoning-start":
|
|
2146
|
+
write(`
|
|
2147
|
+
\uD83D\uDCAD thinking
|
|
2148
|
+
`, DIM2);
|
|
2149
|
+
break;
|
|
2150
|
+
case "reasoning-delta":
|
|
2151
|
+
write(part.text, DIM2);
|
|
2152
|
+
break;
|
|
2153
|
+
case "reasoning-end":
|
|
2154
|
+
write(`
|
|
2155
|
+
`);
|
|
2156
|
+
break;
|
|
2157
|
+
case "text-start":
|
|
2158
|
+
write(`
|
|
2159
|
+
`);
|
|
2160
|
+
break;
|
|
2161
|
+
case "text-delta":
|
|
2162
|
+
write(part.text, GREEN2);
|
|
2163
|
+
break;
|
|
2164
|
+
case "text-end":
|
|
2165
|
+
write(`
|
|
2166
|
+
`);
|
|
2167
|
+
break;
|
|
2168
|
+
case "tool-input-start":
|
|
2169
|
+
state.streamedToolInputs.add(part.id);
|
|
2170
|
+
write(`
|
|
2171
|
+
\uD83D\uDD27 ${part.toolName}(`, CYAN2);
|
|
2172
|
+
break;
|
|
2173
|
+
case "tool-input-delta":
|
|
2174
|
+
write(part.delta, DIM2);
|
|
2175
|
+
break;
|
|
2176
|
+
case "tool-input-end":
|
|
2177
|
+
write(`)
|
|
2178
|
+
`, CYAN2);
|
|
2179
|
+
break;
|
|
2180
|
+
case "tool-call":
|
|
2181
|
+
if (!state.streamedToolInputs.has(part.toolCallId)) {
|
|
2182
|
+
write(`
|
|
2183
|
+
\uD83D\uDD27 ${part.toolName}(${preview(part.input)})
|
|
2184
|
+
`, CYAN2);
|
|
2185
|
+
}
|
|
2186
|
+
break;
|
|
2187
|
+
case "tool-result":
|
|
2188
|
+
write(`\u21B3 ${part.toolName} \u2192 ${preview(part.output)}
|
|
2189
|
+
`, MAGENTA);
|
|
2190
|
+
break;
|
|
2191
|
+
case "tool-error":
|
|
2192
|
+
write(`\u21B3 ${part.toolName} error: ${preview(part.error)}
|
|
2193
|
+
`, RED2);
|
|
2194
|
+
break;
|
|
2195
|
+
case "tool-output-denied":
|
|
2196
|
+
write(`\u21B3 ${part.toolName} output denied
|
|
2197
|
+
`, YELLOW2);
|
|
2198
|
+
break;
|
|
2199
|
+
case "tool-approval-request":
|
|
2200
|
+
write(`\u23F8 approval required: ${part.toolCall.toolName}
|
|
2201
|
+
`, YELLOW2);
|
|
2202
|
+
break;
|
|
2203
|
+
case "source":
|
|
2204
|
+
write(`\uD83D\uDD17 source: ${part.sourceType === "url" ? part.url : part.title}
|
|
2205
|
+
`, DIM2);
|
|
2206
|
+
break;
|
|
2207
|
+
case "file":
|
|
2208
|
+
write(`\uD83D\uDCCE file: ${part.file.mediaType}
|
|
2209
|
+
`, DIM2);
|
|
2210
|
+
break;
|
|
2211
|
+
case "finish":
|
|
2212
|
+
write(`
|
|
2213
|
+
[finished: ${part.finishReason}${formatUsage(part.totalUsage) ? ` \xB7 ${formatUsage(part.totalUsage)}` : ""}]
|
|
2214
|
+
`, GREY);
|
|
2215
|
+
break;
|
|
2216
|
+
case "abort":
|
|
2217
|
+
write(`
|
|
2218
|
+
[aborted${part.reason ? `: ${part.reason}` : ""}]
|
|
2219
|
+
`, RED2);
|
|
2220
|
+
break;
|
|
2221
|
+
case "error":
|
|
2222
|
+
write(`
|
|
2223
|
+
[error: ${preview(part.error)}]
|
|
2224
|
+
`, RED2);
|
|
2225
|
+
break;
|
|
2226
|
+
default:
|
|
2227
|
+
break;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// src/cli/index.ts
|
|
2232
|
+
var VERSION = "0.1.0";
|
|
2233
|
+
var DEFAULT_DASHBOARD_URL = "https://dashboard.broods.app";
|
|
2234
|
+
var HELP = `broods v${VERSION}
|
|
2235
|
+
|
|
2236
|
+
Usage: broods <command>
|
|
2237
|
+
|
|
2238
|
+
Commands:
|
|
2239
|
+
init Create a broods/ project shell
|
|
2240
|
+
login Authenticate with WorkOS through the dashboard
|
|
2241
|
+
dev Watch + sync Development AND live-tail agent logs (like \`convex dev\`);
|
|
2242
|
+
confirms before deleting; auto-pushes env.NAME values from .env.local
|
|
2243
|
+
dev --once Sync Development a single time and exit (no watch, no log stream)
|
|
2244
|
+
diff Show local desired state vs remote state
|
|
2245
|
+
deploy Sync Production once; writes BROODS_API_KEY to .env.local
|
|
2246
|
+
(--prune deletes undeclared remote resources; --rotate-key mints a fresh key)
|
|
2247
|
+
env set <name> Store an encrypted environment variable
|
|
2248
|
+
env get <name> Reveal a variable's value (audited)
|
|
2249
|
+
env list List environment variable names (values stay hidden)
|
|
2250
|
+
env rm <name> Remove an environment variable
|
|
2251
|
+
stream Stream live logs for the whole project/environment (Ctrl+C to stop)
|
|
2252
|
+
logs Backfill recent logs then live-tail; default 100 lines
|
|
2253
|
+
(--errors / --level warn filter to WARN+; -n/--limit <n> changes backfill size)
|
|
2254
|
+
agent list List the agents in the current project/environment scope
|
|
2255
|
+
agent get <name> Show an agent's resources (model, sandbox, workspaces, tools, channels)
|
|
2256
|
+
run <agent> <prompt> Run an agent once and pretty-stream the result (thinking, tool calls, text)
|
|
2257
|
+
|
|
2258
|
+
Options:
|
|
2259
|
+
--dashboard-url <url> Dashboard base URL (default: ${DEFAULT_DASHBOARD_URL})
|
|
2260
|
+
--project <name> Project name override (default: package name or folder)
|
|
2261
|
+
--env <name> Target environment override
|
|
2262
|
+
--prune Allow deploy to delete undeclared remote resources
|
|
2263
|
+
--rotate-key Mint a fresh runtime API key on deploy and write it to .env.local
|
|
2264
|
+
--errors Show WARN/ERROR only (with \`stream\` and \`logs\`)
|
|
2265
|
+
--level <lvl> Minimum log level INFO|WARN|ERROR (with \`stream\` and \`logs\`)
|
|
2266
|
+
-n, --limit <n> Backfill line count (with \`logs\`, default 100)
|
|
2267
|
+
--json Print logs as raw JSON (with \`logs\`, applies to backfill output)
|
|
2268
|
+
--force Allow init to overwrite starter files`;
|
|
2269
|
+
async function main() {
|
|
2270
|
+
const [, , command, ...args] = process.argv;
|
|
2271
|
+
switch (command) {
|
|
2272
|
+
case undefined:
|
|
2273
|
+
case "--help":
|
|
2274
|
+
case "-h":
|
|
2275
|
+
console.log(HELP);
|
|
2276
|
+
return;
|
|
2277
|
+
case "--version":
|
|
2278
|
+
case "-v":
|
|
2279
|
+
console.log(VERSION);
|
|
2280
|
+
return;
|
|
2281
|
+
case "init":
|
|
2282
|
+
await init(args);
|
|
2283
|
+
return;
|
|
2284
|
+
case "login":
|
|
2285
|
+
await login(args);
|
|
2286
|
+
return;
|
|
2287
|
+
case "diff":
|
|
2288
|
+
await diff(args);
|
|
2289
|
+
return;
|
|
2290
|
+
case "deploy":
|
|
2291
|
+
await deploy(args);
|
|
2292
|
+
return;
|
|
2293
|
+
case "dev":
|
|
2294
|
+
await dev(args);
|
|
2295
|
+
return;
|
|
2296
|
+
case "env":
|
|
2297
|
+
await envCommand(args);
|
|
2298
|
+
return;
|
|
2299
|
+
case "stream":
|
|
2300
|
+
await streamLogs(args);
|
|
2301
|
+
return;
|
|
2302
|
+
case "logs":
|
|
2303
|
+
await logs(args);
|
|
2304
|
+
return;
|
|
2305
|
+
case "agent":
|
|
2306
|
+
await agentCommand(args);
|
|
2307
|
+
return;
|
|
2308
|
+
case "run":
|
|
2309
|
+
await run(args);
|
|
2310
|
+
return;
|
|
2311
|
+
default:
|
|
2312
|
+
throw new Error(`Unknown command: ${command}
|
|
2313
|
+
|
|
2314
|
+
${HELP}`);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
async function init(args) {
|
|
2318
|
+
const force = hasFlag(args, "--force");
|
|
2319
|
+
const root = resolve4(process.cwd(), PROJECT_DIR);
|
|
2320
|
+
await mkdir3(resolve4(root, GENERATED_DIR), { recursive: true });
|
|
2321
|
+
await writeStarter(resolve4(root, "index.ts"), starterAgent(), force);
|
|
2322
|
+
await writeStarter(resolve4(root, ".gitignore"), `_generated
|
|
2323
|
+
.cache
|
|
2324
|
+
`, force);
|
|
2325
|
+
await writeLocalEnvDefaults({
|
|
2326
|
+
dashboardUrl: optionValue(args, "--dashboard-url") ?? DEFAULT_DASHBOARD_URL,
|
|
2327
|
+
project: optionValue(args, "--project") ?? inferProjectName(process.cwd()),
|
|
2328
|
+
environment: optionValue(args, "--env") ?? "development",
|
|
2329
|
+
force
|
|
2330
|
+
});
|
|
2331
|
+
console.log(`Created ${PROJECT_DIR}/`);
|
|
2332
|
+
}
|
|
2333
|
+
async function login(args) {
|
|
2334
|
+
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
|
2335
|
+
console.log("Usage: broods login [--dashboard-url <url>]");
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
const runtime = loadBroodsRuntimeConfig();
|
|
2339
|
+
const dashboardUrl = optionValue(args, "--dashboard-url") ?? runtime.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
2340
|
+
const auth = await loginWithBrowser(dashboardUrl);
|
|
2341
|
+
const project = optionValue(args, "--project") ?? process.env.BROODS_PROJECT ?? inferProjectName(process.cwd());
|
|
2342
|
+
const environment = optionValue(args, "--env") ?? process.env.BROODS_ENVIRONMENT ?? "development";
|
|
2343
|
+
await writeLocalEnvDefaults({
|
|
2344
|
+
dashboardUrl: auth.dashboardUrl,
|
|
2345
|
+
project,
|
|
2346
|
+
environment,
|
|
2347
|
+
force: false
|
|
2348
|
+
});
|
|
2349
|
+
const user = auth.user?.email || auth.user?.name || auth.user?.authId;
|
|
2350
|
+
const org = auth.org ? `${auth.org.name} (${auth.org.slug})` : undefined;
|
|
2351
|
+
const account = auth.account?.username;
|
|
2352
|
+
console.log(`Logged in to ${auth.dashboardUrl}`);
|
|
2353
|
+
if (user)
|
|
2354
|
+
console.log(`User: ${user}`);
|
|
2355
|
+
if (org)
|
|
2356
|
+
console.log(`Org: ${org}`);
|
|
2357
|
+
if (account)
|
|
2358
|
+
console.log(`Account: ${account}`);
|
|
2359
|
+
await writeRuntimeKeyForLogin(auth.dashboardUrl, auth.token, project, environment);
|
|
2360
|
+
}
|
|
2361
|
+
async function writeRuntimeKeyForLogin(dashboardUrl, token, project, environment) {
|
|
2362
|
+
try {
|
|
2363
|
+
const client = new BroodsSyncClient({ dashboardUrl, token });
|
|
2364
|
+
const key = await client.getRuntimeKey(project, environment);
|
|
2365
|
+
if (key?.apiKey) {
|
|
2366
|
+
await writeEnvValue("BROODS_API_KEY", key.apiKey);
|
|
2367
|
+
console.log(`Wrote BROODS_API_KEY (${key.keyHint}) to .env.local`);
|
|
2368
|
+
}
|
|
2369
|
+
} catch {}
|
|
2370
|
+
}
|
|
2371
|
+
async function diff(args) {
|
|
2372
|
+
const { manifest, config } = await compileProject({
|
|
2373
|
+
project: optionValue(args, "--project"),
|
|
2374
|
+
environment: optionValue(args, "--env"),
|
|
2375
|
+
command: "dev"
|
|
2376
|
+
});
|
|
2377
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2378
|
+
const client = new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token });
|
|
2379
|
+
const remote = await client.getManifest(manifest.project, manifest.environment);
|
|
2380
|
+
printDiffEntries(diffManifests(manifest, remote?.manifest ?? null));
|
|
2381
|
+
}
|
|
2382
|
+
async function deploy(args) {
|
|
2383
|
+
const { manifest, config, resourceAliases, channels } = await compileProject({
|
|
2384
|
+
project: optionValue(args, "--project"),
|
|
2385
|
+
environment: optionValue(args, "--env"),
|
|
2386
|
+
command: "deploy",
|
|
2387
|
+
useRuntimeEnvironment: false
|
|
2388
|
+
});
|
|
2389
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2390
|
+
const client = new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token });
|
|
2391
|
+
const result = await client.putManifest(manifest, hasFlag(args, "--prune"), hasFlag(args, "--rotate-key"));
|
|
2392
|
+
await writeGeneratedFiles(manifest, result.ids, process.cwd(), resourceAliases, result.deployment, channels);
|
|
2393
|
+
await ensureGitIgnore();
|
|
2394
|
+
console.log(`Synced ${result.manifest.resources.length} resources to ${manifest.project}/${manifest.environment}`);
|
|
2395
|
+
await applyDeploymentKey(result.deployment);
|
|
2396
|
+
printChannelEndpoints(channels, result);
|
|
2397
|
+
printSyncWarnings(result);
|
|
2398
|
+
}
|
|
2399
|
+
async function applyDeploymentKey(deployment) {
|
|
2400
|
+
if (!deployment)
|
|
2401
|
+
return;
|
|
2402
|
+
if (deployment.apiKey) {
|
|
2403
|
+
await writeEnvValue("BROODS_API_KEY", deployment.apiKey);
|
|
2404
|
+
console.log(`Wrote BROODS_API_KEY (${deployment.keyHint}) to .env.local`);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
function printSyncWarnings(result) {
|
|
2409
|
+
const missing = result.warnings?.missingEnv ?? [];
|
|
2410
|
+
if (missing.length === 0)
|
|
2411
|
+
return;
|
|
2412
|
+
printWarning(`\u26A0 ${missing.length} env var(s) referenced in agent config but not set: ${missing.join(", ")}`);
|
|
2413
|
+
for (const name of missing)
|
|
2414
|
+
console.log(` broods env set ${name}`);
|
|
2415
|
+
}
|
|
2416
|
+
async function dev(args) {
|
|
2417
|
+
await ensureDevOnboarding(args);
|
|
2418
|
+
if (hasFlag(args, "--once")) {
|
|
2419
|
+
if (!process.env.BROODS_SUPPRESS_DEV_TARGET) {
|
|
2420
|
+
await printDevTarget(args);
|
|
2421
|
+
}
|
|
2422
|
+
const start = performance.now();
|
|
2423
|
+
await syncDev(args);
|
|
2424
|
+
printReadyLine(performance.now() - start);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
const declinedFile = join4(tmpdir(), `broods-declined-${process.pid}.txt`);
|
|
2428
|
+
const childEnv = {
|
|
2429
|
+
...process.env,
|
|
2430
|
+
BROODS_DECLINED_FILE: declinedFile,
|
|
2431
|
+
BROODS_SUPPRESS_DEV_TARGET: "1"
|
|
2432
|
+
};
|
|
2433
|
+
await printDevTarget(args);
|
|
2434
|
+
await runSyncChild(args, childEnv);
|
|
2435
|
+
let timer;
|
|
2436
|
+
let syncing = false;
|
|
2437
|
+
let pending = false;
|
|
2438
|
+
let lastSourceSignature = await sourceSignature();
|
|
2439
|
+
const runSync = () => {
|
|
2440
|
+
if (syncing) {
|
|
2441
|
+
pending = true;
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
syncing = true;
|
|
2445
|
+
sourceSignature().then(async (signature) => {
|
|
2446
|
+
if (signature === lastSourceSignature)
|
|
2447
|
+
return;
|
|
2448
|
+
lastSourceSignature = signature;
|
|
2449
|
+
await runSyncChild(args, childEnv);
|
|
2450
|
+
}).catch((error) => console.error(error instanceof Error ? error.message : String(error))).finally(() => {
|
|
2451
|
+
syncing = false;
|
|
2452
|
+
if (pending) {
|
|
2453
|
+
pending = false;
|
|
2454
|
+
runSync();
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
};
|
|
2458
|
+
const watcher = watch(resolve4(process.cwd(), PROJECT_DIR), { recursive: true }, (_event, filename) => {
|
|
2459
|
+
if (!filename || isGeneratedPath(filename))
|
|
2460
|
+
return;
|
|
2461
|
+
clearTimeout(timer);
|
|
2462
|
+
timer = setTimeout(runSync, 150);
|
|
2463
|
+
});
|
|
2464
|
+
const logController = new AbortController;
|
|
2465
|
+
streamDevLogs(args, logController.signal);
|
|
2466
|
+
process.on("SIGINT", () => {
|
|
2467
|
+
logController.abort();
|
|
2468
|
+
watcher.close();
|
|
2469
|
+
process.exit(0);
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
async function streamDevLogs(args, signal) {
|
|
2473
|
+
let creds;
|
|
2474
|
+
try {
|
|
2475
|
+
creds = resolveObservabilityCredentials();
|
|
2476
|
+
} catch {
|
|
2477
|
+
console.log("\xB7 live logs off \u2014 set BROODS_API_KEY (run `deploy` first) to stream agent logs here");
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
let project;
|
|
2481
|
+
let environment;
|
|
2482
|
+
try {
|
|
2483
|
+
({ project, environment } = await resolveProjectEnv(args));
|
|
2484
|
+
} catch {
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
const minLevel = resolveMinLevel(args);
|
|
2488
|
+
try {
|
|
2489
|
+
for await (const entry of subscribeObservabilityLogs({ baseUrl: creds.baseUrl, apiKey: creds.apiKey, project, environment }, { backfill: 0, minLevel, signal })) {
|
|
2490
|
+
console.log(formatObservabilityEntry(entry));
|
|
2491
|
+
}
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
if (!signal.aborted) {
|
|
2494
|
+
console.error(`live logs: ${error instanceof Error ? error.message : String(error)}`);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
async function ensureDevOnboarding(args) {
|
|
2499
|
+
await ensureProjectShell();
|
|
2500
|
+
await ensureLocalDevDefaults(args);
|
|
2501
|
+
}
|
|
2502
|
+
async function ensureProjectShell() {
|
|
2503
|
+
const root = resolve4(process.cwd(), PROJECT_DIR);
|
|
2504
|
+
await mkdir3(resolve4(root, GENERATED_DIR), { recursive: true });
|
|
2505
|
+
const files = [];
|
|
2506
|
+
await collectSourceFiles(root, files);
|
|
2507
|
+
if (files.length > 0)
|
|
2508
|
+
return;
|
|
2509
|
+
await writeStarter(resolve4(root, "index.ts"), starterAgent(), false);
|
|
2510
|
+
await writeStarter(resolve4(root, ".gitignore"), `_generated
|
|
2511
|
+
.cache
|
|
2512
|
+
`, false);
|
|
2513
|
+
console.log(`Created starter ${PROJECT_DIR}/`);
|
|
2514
|
+
}
|
|
2515
|
+
async function ensureLocalDevDefaults(args) {
|
|
2516
|
+
const path = resolve4(process.cwd(), ".env.local");
|
|
2517
|
+
const current = await readTextIfExists(path);
|
|
2518
|
+
const values = parseEnv2(current);
|
|
2519
|
+
const missing = [
|
|
2520
|
+
"BROODS_DASHBOARD_URL",
|
|
2521
|
+
"BROODS_PROJECT",
|
|
2522
|
+
"BROODS_ENVIRONMENT"
|
|
2523
|
+
].filter((key) => values[key] === undefined);
|
|
2524
|
+
if (missing.length === 0)
|
|
2525
|
+
return;
|
|
2526
|
+
const needsProject = values.BROODS_PROJECT === undefined;
|
|
2527
|
+
const needsEnvironment = values.BROODS_ENVIRONMENT === undefined;
|
|
2528
|
+
const runtime = loadBroodsRuntimeConfig();
|
|
2529
|
+
const dashboardUrl = optionValue(args, "--dashboard-url") ?? runtime.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
2530
|
+
let project = optionValue(args, "--project") ?? process.env.BROODS_PROJECT ?? inferProjectName(process.cwd());
|
|
2531
|
+
let environment = optionValue(args, "--env") ?? process.env.BROODS_ENVIRONMENT ?? "development";
|
|
2532
|
+
if (process.stdin.isTTY && needsProject) {
|
|
2533
|
+
const auth = await requireAuthOrLogin(dashboardUrl);
|
|
2534
|
+
const client = new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token });
|
|
2535
|
+
const context = await getOnboardingContextOrFallback(client, auth);
|
|
2536
|
+
const selectableOrgs = context.orgs.filter((org) => org.accountStatus === "active");
|
|
2537
|
+
if (selectableOrgs.length === 0) {
|
|
2538
|
+
throw new Error("No selectable org has an active API account. Open Settings -> API Access in the dashboard first.");
|
|
2539
|
+
}
|
|
2540
|
+
const selectedOrg = await promptSelect("Select organization", selectableOrgs, formatOrgChoice);
|
|
2541
|
+
const selectedContext = selectedOrg.id === context.currentOrgId ? context : await client.selectOnboardingOrg(selectedOrg.id);
|
|
2542
|
+
project = await promptText("Project name", defaultProjectName(selectedContext, project));
|
|
2543
|
+
if (!project.trim())
|
|
2544
|
+
throw new Error("Project name is required.");
|
|
2545
|
+
}
|
|
2546
|
+
if (process.stdin.isTTY && needsEnvironment) {
|
|
2547
|
+
environment = await promptText("Environment", environment);
|
|
2548
|
+
if (!environment.trim())
|
|
2549
|
+
throw new Error("Environment is required.");
|
|
2550
|
+
}
|
|
2551
|
+
await writeLocalEnvDefaults({
|
|
2552
|
+
dashboardUrl,
|
|
2553
|
+
project,
|
|
2554
|
+
environment,
|
|
2555
|
+
force: false
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
async function requireAuthOrLogin(dashboardUrl) {
|
|
2559
|
+
try {
|
|
2560
|
+
return await requireAuth(dashboardUrl);
|
|
2561
|
+
} catch (error) {
|
|
2562
|
+
if (!process.stdin.isTTY)
|
|
2563
|
+
throw error;
|
|
2564
|
+
printWarning("No CLI login found. Starting browser login.");
|
|
2565
|
+
return await loginWithBrowser(dashboardUrl);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
async function getOnboardingContextOrFallback(client, auth) {
|
|
2569
|
+
try {
|
|
2570
|
+
return await client.getOnboarding();
|
|
2571
|
+
} catch (error) {
|
|
2572
|
+
if (!auth.org)
|
|
2573
|
+
throw error;
|
|
2574
|
+
printWarning("CLI onboarding endpoint is not available yet; using the org from the current login.");
|
|
2575
|
+
return {
|
|
2576
|
+
currentOrgId: auth.org.id,
|
|
2577
|
+
orgs: [{
|
|
2578
|
+
id: auth.org.id,
|
|
2579
|
+
name: auth.org.name,
|
|
2580
|
+
slug: auth.org.slug,
|
|
2581
|
+
role: "admin",
|
|
2582
|
+
accountStatus: "active"
|
|
2583
|
+
}],
|
|
2584
|
+
projects: []
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
function formatOrgChoice(org) {
|
|
2589
|
+
const suffix = org.role === "owner" || org.role === "admin" ? org.role : "member";
|
|
2590
|
+
return `${org.name} (${org.slug}, ${suffix})`;
|
|
2591
|
+
}
|
|
2592
|
+
function defaultProjectName(context, inferred) {
|
|
2593
|
+
const exact = context.projects.find((project) => project.name === inferred || project.slug === inferred);
|
|
2594
|
+
return exact?.name ?? inferred;
|
|
2595
|
+
}
|
|
2596
|
+
function runSyncChild(args, env3) {
|
|
2597
|
+
return new Promise((resolvePromise, reject) => {
|
|
2598
|
+
const entryPoint = process.argv[1] ?? "";
|
|
2599
|
+
const child = spawn2(process.execPath, [entryPoint, "dev", "--once", ...args], {
|
|
2600
|
+
stdio: "inherit",
|
|
2601
|
+
env: env3
|
|
2602
|
+
});
|
|
2603
|
+
child.on("error", reject);
|
|
2604
|
+
child.on("exit", (code, signal) => {
|
|
2605
|
+
if (code === 0) {
|
|
2606
|
+
resolvePromise();
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
reject(new Error(signal ? `Sync child exited from signal ${signal}` : `Sync child exited with code ${code ?? "unknown"}`));
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
async function syncDev(args) {
|
|
2614
|
+
const { manifest, config, resourceAliases, channels } = await compileProject({
|
|
2615
|
+
project: optionValue(args, "--project"),
|
|
2616
|
+
environment: optionValue(args, "--env"),
|
|
2617
|
+
command: "dev"
|
|
2618
|
+
});
|
|
2619
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2620
|
+
const client = new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token });
|
|
2621
|
+
const remote = await client.getManifest(manifest.project, manifest.environment);
|
|
2622
|
+
const diff2 = diffManifests(manifest, remote?.manifest ?? null);
|
|
2623
|
+
printDiffEntries(diff2.filter((entry) => entry.operation !== "delete"));
|
|
2624
|
+
await syncLocalEnvVars(client, manifest);
|
|
2625
|
+
let result = await client.putManifest(manifest, false);
|
|
2626
|
+
await writeGeneratedFiles(manifest, result.ids, process.cwd(), resourceAliases, result.deployment, channels);
|
|
2627
|
+
await ensureGitIgnore();
|
|
2628
|
+
const declined = await loadDeclinedDeletes();
|
|
2629
|
+
const deletes = diff2.filter((entry) => entry.operation === "delete");
|
|
2630
|
+
const undecided = deletes.filter((entry) => !declined.has(`${entry.kind}:${entry.name}`));
|
|
2631
|
+
let pruned = false;
|
|
2632
|
+
if (undecided.length > 0) {
|
|
2633
|
+
printWarning("\u26A0 These remote resources are no longer declared locally:");
|
|
2634
|
+
printDiffEntries(undecided);
|
|
2635
|
+
if (await promptConfirm(`Delete ${undecided.length} resource(s) from ${manifest.project}/${manifest.environment}?`)) {
|
|
2636
|
+
result = await client.putManifest(manifest, true);
|
|
2637
|
+
await writeGeneratedFiles(manifest, result.ids, process.cwd(), resourceAliases, result.deployment, channels);
|
|
2638
|
+
await clearDeclinedDeletes();
|
|
2639
|
+
pruned = true;
|
|
2640
|
+
} else {
|
|
2641
|
+
await rememberDeclinedDeletes(undecided.map((entry) => `${entry.kind}:${entry.name}`));
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
if (!pruned && deletes.length > 0) {
|
|
2645
|
+
const names = deletes.map((entry) => `${entry.kind}:${entry.name}`).join(", ");
|
|
2646
|
+
printWarning(`\u26A0 ${deletes.length} undeclared resource(s) kept remotely: ${names} \u2014 re-declare in code or run \`deploy --prune\` to remove.`);
|
|
2647
|
+
}
|
|
2648
|
+
await applyDeploymentKey(result.deployment);
|
|
2649
|
+
printChannelEndpoints(channels, result);
|
|
2650
|
+
printSyncWarnings(result);
|
|
2651
|
+
return result;
|
|
2652
|
+
}
|
|
2653
|
+
function printChannelEndpoints(channels, result) {
|
|
2654
|
+
const deployment = result.deployment;
|
|
2655
|
+
if (!deployment || channels.length === 0)
|
|
2656
|
+
return;
|
|
2657
|
+
const client = new BroodsClient;
|
|
2658
|
+
for (const channel of channels) {
|
|
2659
|
+
const agentId = result.ids.agents[channel.agentName];
|
|
2660
|
+
if (!agentId)
|
|
2661
|
+
continue;
|
|
2662
|
+
const webhookPath = `/webhooks/${encodeURIComponent(deployment.accountId)}/${encodeURIComponent(agentId)}/${encodeURIComponent(channel.type)}`;
|
|
2663
|
+
const url = client.channelWebhookUrl({
|
|
2664
|
+
kind: "channel",
|
|
2665
|
+
type: channel.type,
|
|
2666
|
+
agentName: channel.agentName,
|
|
2667
|
+
agentId,
|
|
2668
|
+
accountId: deployment.accountId,
|
|
2669
|
+
webhookPath
|
|
2670
|
+
});
|
|
2671
|
+
console.log(`Channel ${channel.alias} (${channel.type}): ${url}${channel.type === "pancake" ? "?secret=<PANCAKE_WEBHOOK_SECRET>" : ""}`);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
async function syncLocalEnvVars(client, manifest) {
|
|
2675
|
+
const present = collectEnvRefNames(manifest).filter((name) => {
|
|
2676
|
+
const value = process.env[name];
|
|
2677
|
+
return !name.startsWith("BROODS_") && value !== undefined && value !== "";
|
|
2678
|
+
});
|
|
2679
|
+
if (present.length === 0)
|
|
2680
|
+
return;
|
|
2681
|
+
const cache = await loadEnvSyncCache();
|
|
2682
|
+
const known = cache[envCacheKey(manifest.project, manifest.environment)] ?? {};
|
|
2683
|
+
const changed = present.filter((name) => known[name] !== hashEnvValue(process.env[name]));
|
|
2684
|
+
if (changed.length === 0)
|
|
2685
|
+
return;
|
|
2686
|
+
await Promise.all(changed.map((name) => client.setEnv(manifest.project, manifest.environment, name, process.env[name])));
|
|
2687
|
+
for (const name of changed)
|
|
2688
|
+
known[name] = hashEnvValue(process.env[name]);
|
|
2689
|
+
cache[envCacheKey(manifest.project, manifest.environment)] = known;
|
|
2690
|
+
await saveEnvSyncCache(cache);
|
|
2691
|
+
printEnvSync(changed);
|
|
2692
|
+
}
|
|
2693
|
+
function envCacheKey(project, environment) {
|
|
2694
|
+
return `${project}:${environment}`;
|
|
2695
|
+
}
|
|
2696
|
+
function envSyncCachePath() {
|
|
2697
|
+
return resolve4(dirname2(USER_CONFIG_PATH), "env-sync.json");
|
|
2698
|
+
}
|
|
2699
|
+
function hashEnvValue(value) {
|
|
2700
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
2701
|
+
}
|
|
2702
|
+
async function loadEnvSyncCache() {
|
|
2703
|
+
const text = await readTextIfExists(envSyncCachePath());
|
|
2704
|
+
if (!text)
|
|
2705
|
+
return {};
|
|
2706
|
+
try {
|
|
2707
|
+
const parsed = JSON.parse(text);
|
|
2708
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
2709
|
+
} catch {
|
|
2710
|
+
return {};
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
async function saveEnvSyncCache(cache) {
|
|
2714
|
+
const path = envSyncCachePath();
|
|
2715
|
+
await mkdir3(resolve4(path, ".."), { recursive: true });
|
|
2716
|
+
await writeFile3(path, `${JSON.stringify(cache, null, 2)}
|
|
2717
|
+
`, "utf8");
|
|
2718
|
+
}
|
|
2719
|
+
async function rememberEnvSyncValue(project, environment, name, value) {
|
|
2720
|
+
const cache = await loadEnvSyncCache();
|
|
2721
|
+
const key = envCacheKey(project, environment);
|
|
2722
|
+
cache[key] = { ...cache[key] ?? {}, [name]: hashEnvValue(value) };
|
|
2723
|
+
await saveEnvSyncCache(cache);
|
|
2724
|
+
}
|
|
2725
|
+
async function forgetEnvSyncValue(project, environment, name) {
|
|
2726
|
+
const cache = await loadEnvSyncCache();
|
|
2727
|
+
const key = envCacheKey(project, environment);
|
|
2728
|
+
if (!cache[key] || !(name in cache[key]))
|
|
2729
|
+
return;
|
|
2730
|
+
delete cache[key][name];
|
|
2731
|
+
await saveEnvSyncCache(cache);
|
|
2732
|
+
}
|
|
2733
|
+
async function printDevTarget(args) {
|
|
2734
|
+
const { manifest, config } = await compileProject({
|
|
2735
|
+
project: optionValue(args, "--project"),
|
|
2736
|
+
environment: optionValue(args, "--env"),
|
|
2737
|
+
command: "dev"
|
|
2738
|
+
});
|
|
2739
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2740
|
+
printDeploymentTarget({
|
|
2741
|
+
project: manifest.project,
|
|
2742
|
+
environment: manifest.environment,
|
|
2743
|
+
dashboardUrl: auth.dashboardUrl
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
async function loadDeclinedDeletes() {
|
|
2747
|
+
const path = process.env.BROODS_DECLINED_FILE;
|
|
2748
|
+
if (!path)
|
|
2749
|
+
return new Set;
|
|
2750
|
+
const text = await readTextIfExists(path);
|
|
2751
|
+
return new Set(text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
|
|
2752
|
+
}
|
|
2753
|
+
async function rememberDeclinedDeletes(keys) {
|
|
2754
|
+
const path = process.env.BROODS_DECLINED_FILE;
|
|
2755
|
+
if (!path || keys.length === 0)
|
|
2756
|
+
return;
|
|
2757
|
+
await writeFile3(path, `${keys.join(`
|
|
2758
|
+
`)}
|
|
2759
|
+
`, { flag: "a" });
|
|
2760
|
+
}
|
|
2761
|
+
async function clearDeclinedDeletes() {
|
|
2762
|
+
const path = process.env.BROODS_DECLINED_FILE;
|
|
2763
|
+
if (!path)
|
|
2764
|
+
return;
|
|
2765
|
+
await writeFile3(path, "", "utf8");
|
|
2766
|
+
}
|
|
2767
|
+
async function envCommand(args) {
|
|
2768
|
+
const subcommand = args[0];
|
|
2769
|
+
const name = args[1];
|
|
2770
|
+
const isList = subcommand === "list" || subcommand === "ls";
|
|
2771
|
+
const isRemove = subcommand === "rm" || subcommand === "remove";
|
|
2772
|
+
const isGet = subcommand === "get";
|
|
2773
|
+
const needsName = subcommand === "set" || isRemove || isGet;
|
|
2774
|
+
if (needsName && !name || !isList && !needsName) {
|
|
2775
|
+
throw new Error("Usage: broods env <set|get|list|rm> [name]");
|
|
2776
|
+
}
|
|
2777
|
+
const { manifest, config } = await compileProject({
|
|
2778
|
+
project: optionValue(args, "--project"),
|
|
2779
|
+
environment: optionValue(args, "--env"),
|
|
2780
|
+
command: "dev"
|
|
2781
|
+
});
|
|
2782
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2783
|
+
const client = new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token });
|
|
2784
|
+
const target = `${manifest.project}/${manifest.environment}`;
|
|
2785
|
+
if (isList) {
|
|
2786
|
+
const variables = await client.listEnv(manifest.project, manifest.environment);
|
|
2787
|
+
if (variables.length === 0) {
|
|
2788
|
+
console.log(`No environment variables set for ${target}.`);
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
console.log(`Environment variables for ${target} (values hidden):`);
|
|
2792
|
+
for (const variable of variables)
|
|
2793
|
+
console.log(` ${variable.name}`);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (isGet) {
|
|
2797
|
+
const value2 = await client.getEnv(manifest.project, manifest.environment, name);
|
|
2798
|
+
if (value2 === null) {
|
|
2799
|
+
console.error(`${name} is not set for ${target}`);
|
|
2800
|
+
process.exitCode = 1;
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
console.log(value2);
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (isRemove) {
|
|
2807
|
+
await client.removeEnv(manifest.project, manifest.environment, name);
|
|
2808
|
+
await forgetEnvSyncValue(manifest.project, manifest.environment, name);
|
|
2809
|
+
console.log(`Removed ${name} from ${target}`);
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
const value = await promptSecret(name);
|
|
2813
|
+
await client.setEnv(manifest.project, manifest.environment, name, value);
|
|
2814
|
+
await rememberEnvSyncValue(manifest.project, manifest.environment, name, value);
|
|
2815
|
+
console.log(`Stored ${name} for ${target}`);
|
|
2816
|
+
}
|
|
2817
|
+
function resolveObservabilityCredentials() {
|
|
2818
|
+
loadBroodsRuntimeConfig();
|
|
2819
|
+
const apiKey = process.env.BROODS_API_KEY ?? "";
|
|
2820
|
+
if (!apiKey) {
|
|
2821
|
+
throw new Error("BROODS_API_KEY is not set. Run `broods deploy` first, or set the key in .env.local.");
|
|
2822
|
+
}
|
|
2823
|
+
const baseUrl = process.env.BROODS_BASE_URL ?? process.env.BROODS_HOST ?? DEFAULT_CORE_BASE_URL;
|
|
2824
|
+
return { apiKey, baseUrl };
|
|
2825
|
+
}
|
|
2826
|
+
function resolveMinLevel(args) {
|
|
2827
|
+
if (hasFlag(args, "--errors"))
|
|
2828
|
+
return "WARN";
|
|
2829
|
+
const raw = optionValue(args, "--level");
|
|
2830
|
+
if (!raw)
|
|
2831
|
+
return;
|
|
2832
|
+
const upper = raw.toUpperCase();
|
|
2833
|
+
if (upper === "WARN" || upper === "WARNING")
|
|
2834
|
+
return "WARN";
|
|
2835
|
+
if (upper === "ERROR")
|
|
2836
|
+
return "ERROR";
|
|
2837
|
+
if (upper === "INFO")
|
|
2838
|
+
return "INFO";
|
|
2839
|
+
throw new Error(`Unknown log level: ${raw}. Use INFO, WARN, or ERROR.`);
|
|
2840
|
+
}
|
|
2841
|
+
async function resolveProjectEnv(args) {
|
|
2842
|
+
loadBroodsRuntimeConfig();
|
|
2843
|
+
const project = optionValue(args, "--project") ?? process.env.BROODS_PROJECT;
|
|
2844
|
+
const environment = optionValue(args, "--env") ?? process.env.BROODS_ENVIRONMENT;
|
|
2845
|
+
if (!project) {
|
|
2846
|
+
throw new Error("Project name is required. Pass --project <name> or set BROODS_PROJECT in .env.local.");
|
|
2847
|
+
}
|
|
2848
|
+
if (!environment) {
|
|
2849
|
+
throw new Error("Environment name is required. Pass --env <name> or set BROODS_ENVIRONMENT in .env.local.");
|
|
2850
|
+
}
|
|
2851
|
+
return { project, environment };
|
|
2852
|
+
}
|
|
2853
|
+
function formatObservabilityEntry(entry) {
|
|
2854
|
+
const time = new Date(entry.ts).toISOString().slice(11, 23);
|
|
2855
|
+
const level = entry.level.padEnd(5);
|
|
2856
|
+
return `${time} ${level} ${entry.eventType} ${entry.message}`;
|
|
2857
|
+
}
|
|
2858
|
+
async function streamLogs(args) {
|
|
2859
|
+
const { apiKey, baseUrl } = resolveObservabilityCredentials();
|
|
2860
|
+
const { project, environment } = await resolveProjectEnv(args);
|
|
2861
|
+
const minLevel = resolveMinLevel(args);
|
|
2862
|
+
const controller = new AbortController;
|
|
2863
|
+
const onSigint = () => controller.abort();
|
|
2864
|
+
process.on("SIGINT", onSigint);
|
|
2865
|
+
console.log(`Streaming live logs for ${project}/${environment}` + (minLevel ? ` [${minLevel}+]` : "") + " \u2014 Ctrl+C to stop");
|
|
2866
|
+
try {
|
|
2867
|
+
for await (const entry of subscribeObservabilityLogs({ baseUrl, apiKey, project, environment }, { backfill: 0, minLevel, signal: controller.signal })) {
|
|
2868
|
+
console.log(formatObservabilityEntry(entry));
|
|
2869
|
+
}
|
|
2870
|
+
} catch (error) {
|
|
2871
|
+
if (!controller.signal.aborted) {
|
|
2872
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2873
|
+
process.exitCode = 1;
|
|
2874
|
+
}
|
|
2875
|
+
} finally {
|
|
2876
|
+
process.off("SIGINT", onSigint);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
async function logs(args) {
|
|
2880
|
+
const { apiKey, baseUrl } = resolveObservabilityCredentials();
|
|
2881
|
+
const { project, environment } = await resolveProjectEnv(args);
|
|
2882
|
+
const minLevel = resolveMinLevel(args);
|
|
2883
|
+
const limit = Number(optionValue(args, "--limit") ?? optionValue(args, "-n") ?? 100);
|
|
2884
|
+
const jsonMode = hasFlag(args, "--json");
|
|
2885
|
+
const controller = new AbortController;
|
|
2886
|
+
const onSigint = () => controller.abort();
|
|
2887
|
+
process.on("SIGINT", onSigint);
|
|
2888
|
+
console.log(`Logs for ${project}/${environment}` + (minLevel ? ` [${minLevel}+]` : "") + ` (backfill ${limit}) \u2014 Ctrl+C to stop`);
|
|
2889
|
+
try {
|
|
2890
|
+
for await (const entry of subscribeObservabilityLogs({ baseUrl, apiKey, project, environment }, { backfill: limit, minLevel, signal: controller.signal })) {
|
|
2891
|
+
if (jsonMode) {
|
|
2892
|
+
console.log(JSON.stringify(entry));
|
|
2893
|
+
} else {
|
|
2894
|
+
console.log(formatObservabilityEntry(entry));
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
} catch (error) {
|
|
2898
|
+
if (!controller.signal.aborted) {
|
|
2899
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2900
|
+
process.exitCode = 1;
|
|
2901
|
+
}
|
|
2902
|
+
} finally {
|
|
2903
|
+
process.off("SIGINT", onSigint);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
async function agentCommand(args) {
|
|
2907
|
+
const subcommand = args[0];
|
|
2908
|
+
if (subcommand === "list" || subcommand === "ls") {
|
|
2909
|
+
await agentList(args);
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (subcommand === "get") {
|
|
2913
|
+
await agentGet(args[1], args);
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
throw new Error("Usage: broods agent <list|get> [name]");
|
|
2917
|
+
}
|
|
2918
|
+
async function loadAgentsWithIds(args) {
|
|
2919
|
+
const { manifest, config } = await compileProject({
|
|
2920
|
+
project: optionValue(args, "--project"),
|
|
2921
|
+
environment: optionValue(args, "--env"),
|
|
2922
|
+
command: "dev"
|
|
2923
|
+
});
|
|
2924
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
2925
|
+
const remote = await new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token }).getManifest(manifest.project, manifest.environment);
|
|
2926
|
+
const agents = manifest.resources.filter((resource) => resource.kind === "agent").map((resource) => ({
|
|
2927
|
+
name: resource.name,
|
|
2928
|
+
config: resource.config ?? {},
|
|
2929
|
+
agentId: remote?.ids.agents[resource.name]
|
|
2930
|
+
}));
|
|
2931
|
+
return { manifest, agents };
|
|
2932
|
+
}
|
|
2933
|
+
async function agentList(args) {
|
|
2934
|
+
const { manifest, agents } = await loadAgentsWithIds(args);
|
|
2935
|
+
if (agents.length === 0) {
|
|
2936
|
+
console.log(`No agents declared in ${manifest.project}/${manifest.environment}.`);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
console.log(`Agents in ${manifest.project}/${manifest.environment}:`);
|
|
2940
|
+
for (const agent of agents) {
|
|
2941
|
+
const model = agentModelLabel(agent.config);
|
|
2942
|
+
const access = agent.config.publicAccess === true ? "public" : "private";
|
|
2943
|
+
const status = agent.agentId ? agent.agentId : "not deployed";
|
|
2944
|
+
console.log(` ${agent.name} [${access}] ${model} (${status})`);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
async function agentGet(name, args) {
|
|
2948
|
+
if (!name)
|
|
2949
|
+
throw new Error("Usage: broods agent get <name>");
|
|
2950
|
+
const { manifest, agents } = await loadAgentsWithIds(args);
|
|
2951
|
+
const agent = agents.find((entry) => entry.name === name);
|
|
2952
|
+
if (!agent)
|
|
2953
|
+
throw new Error(`Unknown local agent: ${name}`);
|
|
2954
|
+
const config = agent.config;
|
|
2955
|
+
const sandbox = typeof config.sandbox === "string" ? config.sandbox : undefined;
|
|
2956
|
+
const workspaces = Array.isArray(config.workspaces) ? config.workspaces.map((ref) => typeof ref === "string" ? ref : ref.name).filter(Boolean) : [];
|
|
2957
|
+
const tools = isRecord(config.tools) ? Object.keys(config.tools) : [];
|
|
2958
|
+
const channels = isRecord(config.channels) ? Object.keys(config.channels) : [];
|
|
2959
|
+
const subagents = isRecord(config.subagent) && Array.isArray(config.subagent.allowed) ? config.subagent.allowed.map((entry) => typeof entry === "string" ? entry : entry.name).filter(Boolean) : [];
|
|
2960
|
+
const webhooks = isRecord(config.hooks) && Array.isArray(config.hooks.webhooks) ? config.hooks.webhooks.filter(isRecord) : [];
|
|
2961
|
+
console.log(`Agent: ${agent.name}`);
|
|
2962
|
+
console.log(` Project/Env: ${manifest.project}/${manifest.environment}`);
|
|
2963
|
+
console.log(` Deployed id: ${agent.agentId ?? "not deployed"}`);
|
|
2964
|
+
console.log(` Public access: ${config.publicAccess === true ? "public (SSE/WebSocket enabled)" : "private (secured by default)"}`);
|
|
2965
|
+
console.log(` Model: ${agentModelLabel(config)}`);
|
|
2966
|
+
console.log(` Sandbox: ${sandbox ?? "\u2014"}`);
|
|
2967
|
+
console.log(` Workspaces: ${workspaces.length > 0 ? workspaces.join(", ") : "\u2014"}`);
|
|
2968
|
+
console.log(` Tools: ${tools.length > 0 ? tools.join(", ") : "\u2014"}`);
|
|
2969
|
+
console.log(` Subagents: ${subagents.length > 0 ? subagents.join(", ") : "\u2014"}`);
|
|
2970
|
+
console.log(` Channels: ${channels.length > 0 ? channels.join(", ") : "\u2014"}`);
|
|
2971
|
+
if (webhooks.length > 0) {
|
|
2972
|
+
console.log(` Webhooks:`);
|
|
2973
|
+
webhooks.forEach((webhook, index) => {
|
|
2974
|
+
const events = Array.isArray(webhook.events) && webhook.events.length > 0 ? webhook.events.join(", ") : "all events";
|
|
2975
|
+
const state = webhook.enabled === false ? "disabled" : "enabled";
|
|
2976
|
+
console.log(` [${index}] ${state} \u2192 ${webhook.url ?? "\u2014"} (${events})`);
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
function agentModelLabel(config) {
|
|
2981
|
+
const model = isRecord(config.model) ? config.model : {};
|
|
2982
|
+
const provider = typeof model.provider === "string" ? model.provider : undefined;
|
|
2983
|
+
const modelId = typeof model.modelId === "string" ? model.modelId : undefined;
|
|
2984
|
+
if (provider && modelId)
|
|
2985
|
+
return `${provider}/${modelId}`;
|
|
2986
|
+
return modelId ?? provider ?? "unconfigured model";
|
|
2987
|
+
}
|
|
2988
|
+
function isRecord(value) {
|
|
2989
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2990
|
+
}
|
|
2991
|
+
async function run(args) {
|
|
2992
|
+
const [agentName, ...promptParts] = args.filter((arg) => !arg.startsWith("--"));
|
|
2993
|
+
if (!agentName || promptParts.length === 0) {
|
|
2994
|
+
throw new Error("Usage: broods run <agent> <prompt>");
|
|
2995
|
+
}
|
|
2996
|
+
const { manifest, config } = await compileProject({
|
|
2997
|
+
project: optionValue(args, "--project"),
|
|
2998
|
+
environment: optionValue(args, "--env"),
|
|
2999
|
+
command: "dev"
|
|
3000
|
+
});
|
|
3001
|
+
const auth = await requireAuth(optionValue(args, "--dashboard-url") ?? config.dashboardUrl);
|
|
3002
|
+
const agent = manifest.resources.find((resource) => resource.kind === "agent" && resource.name === agentName);
|
|
3003
|
+
if (!agent)
|
|
3004
|
+
throw new Error(`Unknown local agent: ${agentName}`);
|
|
3005
|
+
const remote = await new BroodsSyncClient({ dashboardUrl: auth.dashboardUrl, token: auth.token }).getManifest(manifest.project, manifest.environment);
|
|
3006
|
+
const agentId = remote?.ids.agents[agentName];
|
|
3007
|
+
if (!agentId)
|
|
3008
|
+
throw new Error(`Agent ${agentName} is not deployed. Run broods deploy first.`);
|
|
3009
|
+
if (agent.config.publicAccess !== true) {
|
|
3010
|
+
printWarning(`\u26A0 Agent "${agentName}" does not set publicAccess: true. The public endpoint is secured by default; ` + "if the deployed agent has not enabled it, this run will be refused.");
|
|
3011
|
+
}
|
|
3012
|
+
const client = new BroodsClient;
|
|
3013
|
+
const state = createRenderState();
|
|
3014
|
+
try {
|
|
3015
|
+
for await (const part of client.stream({
|
|
3016
|
+
kind: "agent",
|
|
3017
|
+
name: agentName,
|
|
3018
|
+
id: agentId,
|
|
3019
|
+
project: manifest.project,
|
|
3020
|
+
environment: manifest.environment
|
|
3021
|
+
}, { input: promptParts.join(" ") })) {
|
|
3022
|
+
renderStreamPart(part, state);
|
|
3023
|
+
}
|
|
3024
|
+
process.stdout.write(`
|
|
3025
|
+
`);
|
|
3026
|
+
} catch (error) {
|
|
3027
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3028
|
+
if (message.includes("public_access_disabled")) {
|
|
3029
|
+
throw new Error(`Agent "${agentName}" is not publicly accessible (secured by default). ` + "Set publicAccess: true in its config and redeploy, or enable Public access in the dashboard.");
|
|
3030
|
+
}
|
|
3031
|
+
throw error;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
async function writeStarter(path, contents, force) {
|
|
3035
|
+
try {
|
|
3036
|
+
await writeFile3(path, contents, { flag: force ? "w" : "wx" });
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
if (error.code === "EEXIST")
|
|
3039
|
+
return;
|
|
3040
|
+
throw error;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
async function ensureGitIgnore() {
|
|
3044
|
+
const path = resolve4(process.cwd(), PROJECT_DIR, ".gitignore");
|
|
3045
|
+
const existing = await readTextIfExists(path);
|
|
3046
|
+
const needed = ["_generated", ".cache"];
|
|
3047
|
+
const missing = needed.filter((line) => !existing.split(/\r?\n/).some((l) => l.trim() === line));
|
|
3048
|
+
if (missing.length === 0)
|
|
3049
|
+
return;
|
|
3050
|
+
const body = existing ? existing.trimEnd() + `
|
|
3051
|
+
` + missing.join(`
|
|
3052
|
+
`) + `
|
|
3053
|
+
` : missing.join(`
|
|
3054
|
+
`) + `
|
|
3055
|
+
`;
|
|
3056
|
+
await writeFile3(path, body, "utf8");
|
|
3057
|
+
}
|
|
3058
|
+
async function writeLocalEnvDefaults(options) {
|
|
3059
|
+
const path = resolve4(process.cwd(), ".env.local");
|
|
3060
|
+
const current = await readTextIfExists(path);
|
|
3061
|
+
const values = parseEnv2(current);
|
|
3062
|
+
const nextValues = {
|
|
3063
|
+
BROODS_DASHBOARD_URL: options.dashboardUrl,
|
|
3064
|
+
BROODS_PROJECT: options.project,
|
|
3065
|
+
BROODS_ENVIRONMENT: options.environment
|
|
3066
|
+
};
|
|
3067
|
+
const lines = current ? current.replace(/\n?$/, `
|
|
3068
|
+
`).split(/\n/) : [
|
|
3069
|
+
"# Local broods CLI settings. Tokens are stored outside the repo."
|
|
3070
|
+
];
|
|
3071
|
+
let changed = false;
|
|
3072
|
+
for (const [key, value] of Object.entries(nextValues)) {
|
|
3073
|
+
if (values[key] !== undefined && !options.force)
|
|
3074
|
+
continue;
|
|
3075
|
+
const index = lines.findIndex((line) => line.trim().startsWith(`${key}=`));
|
|
3076
|
+
if (index >= 0)
|
|
3077
|
+
lines[index] = `${key}=${quoteEnv(value)}`;
|
|
3078
|
+
else
|
|
3079
|
+
lines.push(`${key}=${quoteEnv(value)}`);
|
|
3080
|
+
if (process.env[key] === undefined)
|
|
3081
|
+
process.env[key] = value;
|
|
3082
|
+
changed = true;
|
|
3083
|
+
}
|
|
3084
|
+
if (!changed && current)
|
|
3085
|
+
return;
|
|
3086
|
+
const body = `${lines.filter((line, index, all) => !(line === "" && index === all.length - 1)).join(`
|
|
3087
|
+
`)}
|
|
3088
|
+
`;
|
|
3089
|
+
await writeFile3(path, body, "utf8");
|
|
3090
|
+
}
|
|
3091
|
+
async function writeEnvValue(key, value) {
|
|
3092
|
+
const path = resolve4(process.cwd(), ".env.local");
|
|
3093
|
+
const current = await readTextIfExists(path);
|
|
3094
|
+
const lines = current ? current.replace(/\n?$/, `
|
|
3095
|
+
`).split(/\n/) : ["# Local broods CLI settings. Tokens are stored outside the repo."];
|
|
3096
|
+
const index = lines.findIndex((line) => line.trim().startsWith(`${key}=`));
|
|
3097
|
+
if (index >= 0)
|
|
3098
|
+
lines[index] = `${key}=${quoteEnv(value)}`;
|
|
3099
|
+
else
|
|
3100
|
+
lines.push(`${key}=${quoteEnv(value)}`);
|
|
3101
|
+
process.env[key] = value;
|
|
3102
|
+
const body = `${lines.filter((line, i, all) => !(line === "" && i === all.length - 1)).join(`
|
|
3103
|
+
`)}
|
|
3104
|
+
`;
|
|
3105
|
+
await writeFile3(path, body, "utf8");
|
|
3106
|
+
}
|
|
3107
|
+
async function readTextIfExists(path) {
|
|
3108
|
+
try {
|
|
3109
|
+
return await readFile3(path, "utf8");
|
|
3110
|
+
} catch (error) {
|
|
3111
|
+
if (error.code === "ENOENT")
|
|
3112
|
+
return "";
|
|
3113
|
+
throw error;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
function parseEnv2(source) {
|
|
3117
|
+
const values = {};
|
|
3118
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
3119
|
+
const line = rawLine.trim();
|
|
3120
|
+
if (!line || line.startsWith("#"))
|
|
3121
|
+
continue;
|
|
3122
|
+
const index = line.indexOf("=");
|
|
3123
|
+
if (index <= 0)
|
|
3124
|
+
continue;
|
|
3125
|
+
values[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
|
3126
|
+
}
|
|
3127
|
+
return values;
|
|
3128
|
+
}
|
|
3129
|
+
function quoteEnv(value) {
|
|
3130
|
+
return JSON.stringify(value);
|
|
3131
|
+
}
|
|
3132
|
+
function inferProjectName(cwd) {
|
|
3133
|
+
return basename2(resolve4(cwd)).replace(/^@/, "").replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "broods-app";
|
|
3134
|
+
}
|
|
3135
|
+
async function sourceSignature() {
|
|
3136
|
+
const root = resolve4(process.cwd(), PROJECT_DIR);
|
|
3137
|
+
const files = [];
|
|
3138
|
+
await collectSourceFiles(root, files);
|
|
3139
|
+
const hash = createHash2("sha256");
|
|
3140
|
+
for (const file of files.sort()) {
|
|
3141
|
+
hash.update(relative2(root, file));
|
|
3142
|
+
hash.update("\x00");
|
|
3143
|
+
hash.update(await readFile3(file));
|
|
3144
|
+
hash.update("\x00");
|
|
3145
|
+
}
|
|
3146
|
+
return hash.digest("hex");
|
|
3147
|
+
}
|
|
3148
|
+
async function collectSourceFiles(dir, files) {
|
|
3149
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
3150
|
+
for (const entry of entries) {
|
|
3151
|
+
const full = join4(dir, entry.name);
|
|
3152
|
+
if (entry.isDirectory()) {
|
|
3153
|
+
if (entry.name === GENERATED_DIR || entry.name === "generated" || entry.name === ".cache")
|
|
3154
|
+
continue;
|
|
3155
|
+
await collectSourceFiles(full, files);
|
|
3156
|
+
continue;
|
|
3157
|
+
}
|
|
3158
|
+
if (entry.isFile() && entry.name.endsWith(".ts"))
|
|
3159
|
+
files.push(full);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
function isGeneratedPath(path) {
|
|
3163
|
+
return path.split(/[\\/]/).some((part) => part === GENERATED_DIR || part === "generated" || part === ".cache");
|
|
3164
|
+
}
|
|
3165
|
+
function starterAgent() {
|
|
3166
|
+
return `import { defineAgent, defineSandbox, env } from "broods";
|
|
3167
|
+
|
|
3168
|
+
` + `// A Lambda sandbox: a fresh, ephemeral bash environment created per run.
|
|
3169
|
+
` + `export const lambdaSandbox = defineSandbox({
|
|
3170
|
+
` + ` name: "lambda-sandbox",
|
|
3171
|
+
` + ` config: {
|
|
3172
|
+
` + ` provider: "lambda",
|
|
3173
|
+
` + ` network: { mode: "deny-all" },
|
|
3174
|
+
` + ` permissionMode: "bypass",
|
|
3175
|
+
` + ` timeout: 60,
|
|
3176
|
+
` + ` },
|
|
3177
|
+
` + `});
|
|
3178
|
+
|
|
3179
|
+
` + `export const myAgent = defineAgent({
|
|
3180
|
+
` + ` name: "my-agent",
|
|
3181
|
+
` + ` config: {
|
|
3182
|
+
` + ` provider: {
|
|
3183
|
+
` + ` openai: { apiKey: env.OPENAI_API_KEY },
|
|
3184
|
+
` + ` },
|
|
3185
|
+
` + ` model: {
|
|
3186
|
+
` + ` provider: "openai",
|
|
3187
|
+
` + ` modelId: "gpt-5.5",
|
|
3188
|
+
` + ` },
|
|
3189
|
+
` + ` agent: {
|
|
3190
|
+
` + ` system: "You are a helpful assistant.",
|
|
3191
|
+
` + ` },
|
|
3192
|
+
` + ` sandbox: lambdaSandbox,
|
|
3193
|
+
` + ` // Expose the public runtime endpoint (SSE/WebSocket) so the API key and
|
|
3194
|
+
` + ` // \`broods run\` can reach this agent. Off by default \u2014 secured: a
|
|
3195
|
+
` + ` // private agent is only reachable via internal endpoints or channel webhooks.
|
|
3196
|
+
` + ` publicAccess: true,
|
|
3197
|
+
` + ` },
|
|
3198
|
+
` + `});
|
|
3199
|
+
`;
|
|
3200
|
+
}
|
|
3201
|
+
main().catch((error) => {
|
|
3202
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
3203
|
+
process.exit(1);
|
|
3204
|
+
});
|