bopodev-api 0.1.14 → 0.1.16

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.
@@ -0,0 +1,439 @@
1
+ import { Router } from "express";
2
+ import {
3
+ TemplateApplyRequestSchema,
4
+ TemplateCreateRequestSchema,
5
+ TemplateManifestDefault,
6
+ TemplateImportRequestSchema,
7
+ TemplateManifestSchema,
8
+ TemplatePreviewRequestSchema,
9
+ TemplateUpdateRequestSchema
10
+ } from "bopodev-contracts";
11
+ import {
12
+ appendAuditEvent,
13
+ createApprovalRequest,
14
+ createTemplate,
15
+ createTemplateVersion,
16
+ deleteTemplate,
17
+ getCurrentTemplateVersion,
18
+ getTemplate,
19
+ getTemplateBySlug,
20
+ getTemplateVersionByVersion,
21
+ listTemplates,
22
+ updateTemplate
23
+ } from "bopodev-db";
24
+ import type { AppContext } from "../context";
25
+ import { sendError, sendOk } from "../http";
26
+ import { requireCompanyScope } from "../middleware/company-scope";
27
+ import { requirePermission } from "../middleware/request-actor";
28
+ import { applyTemplateManifest } from "../services/template-apply-service";
29
+ import { buildTemplatePreview } from "../services/template-preview-service";
30
+
31
+ export function createTemplatesRouter(ctx: AppContext) {
32
+ const router = Router();
33
+ router.use(requireCompanyScope);
34
+
35
+ router.get("/", async (req, res) => {
36
+ const rows = await listTemplates(ctx.db, req.companyId!);
37
+ const hydrated = await Promise.all(rows.map((row) => hydrateTemplate(ctx, req.companyId!, row.id)));
38
+ return sendOk(res, hydrated.filter((row): row is NonNullable<typeof row> => Boolean(row)));
39
+ });
40
+
41
+ router.post("/", async (req, res) => {
42
+ requirePermission("templates:write")(req, res, () => {});
43
+ if (res.headersSent) {
44
+ return;
45
+ }
46
+ const parsed = TemplateCreateRequestSchema.safeParse(req.body);
47
+ if (!parsed.success) {
48
+ return sendError(res, parsed.error.message, 422);
49
+ }
50
+ const existing = await getTemplateBySlug(ctx.db, req.companyId!, parsed.data.slug);
51
+ if (existing) {
52
+ return sendError(res, `Template slug '${parsed.data.slug}' already exists.`, 409);
53
+ }
54
+ const created = await createTemplate(ctx.db, {
55
+ companyId: req.companyId!,
56
+ slug: parsed.data.slug,
57
+ name: parsed.data.name,
58
+ description: parsed.data.description,
59
+ currentVersion: parsed.data.currentVersion,
60
+ status: parsed.data.status,
61
+ visibility: parsed.data.visibility,
62
+ variablesJson: JSON.stringify(parsed.data.variables)
63
+ });
64
+ if (!created) {
65
+ return sendError(res, "Template creation failed.", 500);
66
+ }
67
+ await createTemplateVersion(ctx.db, {
68
+ companyId: req.companyId!,
69
+ templateId: created.id,
70
+ version: parsed.data.currentVersion,
71
+ manifestJson: JSON.stringify(parsed.data.manifest)
72
+ });
73
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, created.id);
74
+ await appendAuditEvent(ctx.db, {
75
+ companyId: req.companyId!,
76
+ actorType: "human",
77
+ eventType: "template.created",
78
+ entityType: "template",
79
+ entityId: created.id,
80
+ payload: hydrated ?? created
81
+ });
82
+ return sendOk(res, hydrated ?? created);
83
+ });
84
+
85
+ router.put("/:templateId", async (req, res) => {
86
+ requirePermission("templates:write")(req, res, () => {});
87
+ if (res.headersSent) {
88
+ return;
89
+ }
90
+ const parsed = TemplateUpdateRequestSchema.safeParse(req.body);
91
+ if (!parsed.success) {
92
+ return sendError(res, parsed.error.message, 422);
93
+ }
94
+ const existing = await getTemplate(ctx.db, req.companyId!, req.params.templateId);
95
+ if (!existing) {
96
+ return sendError(res, "Template not found.", 404);
97
+ }
98
+ const nextVersion = parsed.data.currentVersion ?? existing.currentVersion;
99
+ const updated = await updateTemplate(ctx.db, {
100
+ companyId: req.companyId!,
101
+ id: req.params.templateId,
102
+ slug: parsed.data.slug,
103
+ name: parsed.data.name,
104
+ description: parsed.data.description,
105
+ currentVersion: parsed.data.currentVersion,
106
+ status: parsed.data.status,
107
+ visibility: parsed.data.visibility,
108
+ variablesJson: parsed.data.variables ? JSON.stringify(parsed.data.variables) : undefined
109
+ });
110
+ if (!updated) {
111
+ return sendError(res, "Template not found.", 404);
112
+ }
113
+ if (parsed.data.manifest) {
114
+ const existingVersion = await getTemplateVersionByVersion(ctx.db, {
115
+ companyId: req.companyId!,
116
+ templateId: req.params.templateId,
117
+ version: nextVersion
118
+ });
119
+ if (!existingVersion) {
120
+ await createTemplateVersion(ctx.db, {
121
+ companyId: req.companyId!,
122
+ templateId: req.params.templateId,
123
+ version: nextVersion,
124
+ manifestJson: JSON.stringify(parsed.data.manifest)
125
+ });
126
+ }
127
+ }
128
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, req.params.templateId);
129
+ await appendAuditEvent(ctx.db, {
130
+ companyId: req.companyId!,
131
+ actorType: "human",
132
+ eventType: "template.updated",
133
+ entityType: "template",
134
+ entityId: req.params.templateId,
135
+ payload: hydrated ?? updated
136
+ });
137
+ return sendOk(res, hydrated ?? updated);
138
+ });
139
+
140
+ router.delete("/:templateId", async (req, res) => {
141
+ requirePermission("templates:write")(req, res, () => {});
142
+ if (res.headersSent) {
143
+ return;
144
+ }
145
+ const deleted = await deleteTemplate(ctx.db, req.companyId!, req.params.templateId);
146
+ if (!deleted) {
147
+ return sendError(res, "Template not found.", 404);
148
+ }
149
+ await appendAuditEvent(ctx.db, {
150
+ companyId: req.companyId!,
151
+ actorType: "human",
152
+ eventType: "template.deleted",
153
+ entityType: "template",
154
+ entityId: req.params.templateId,
155
+ payload: { id: req.params.templateId }
156
+ });
157
+ return sendOk(res, { deleted: true });
158
+ });
159
+
160
+ router.post("/:templateId/preview", async (req, res) => {
161
+ const parsed = TemplatePreviewRequestSchema.safeParse(req.body);
162
+ if (!parsed.success) {
163
+ return sendError(res, parsed.error.message, 422);
164
+ }
165
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, req.params.templateId);
166
+ if (!hydrated) {
167
+ return sendError(res, "Template not found.", 404);
168
+ }
169
+ const preview = buildTemplatePreview({
170
+ templateId: hydrated.id,
171
+ templateVersion: hydrated.currentVersion,
172
+ manifest: hydrated.manifest,
173
+ variables: parsed.data.variables
174
+ });
175
+ await appendAuditEvent(ctx.db, {
176
+ companyId: req.companyId!,
177
+ actorType: "human",
178
+ eventType: "template.previewed",
179
+ entityType: "template",
180
+ entityId: hydrated.id,
181
+ payload: {
182
+ mode: parsed.data.mode,
183
+ targetCompanyName: parsed.data.targetCompanyName ?? null,
184
+ summary: preview.summary
185
+ }
186
+ });
187
+ return sendOk(res, preview);
188
+ });
189
+
190
+ router.post("/:templateId/apply", async (req, res) => {
191
+ requirePermission("templates:write")(req, res, () => {});
192
+ if (res.headersSent) {
193
+ return;
194
+ }
195
+ const parsed = TemplateApplyRequestSchema.safeParse(req.body);
196
+ if (!parsed.success) {
197
+ return sendError(res, parsed.error.message, 422);
198
+ }
199
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, req.params.templateId);
200
+ if (!hydrated) {
201
+ return sendError(res, "Template not found.", 404);
202
+ }
203
+ if (parsed.data.requestApproval) {
204
+ const approvalId = await createApprovalRequest(ctx.db, {
205
+ companyId: req.companyId!,
206
+ requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
207
+ action: "apply_template",
208
+ payload: {
209
+ templateId: hydrated.id,
210
+ templateVersion: hydrated.currentVersion,
211
+ variables: parsed.data.variables,
212
+ mode: parsed.data.mode,
213
+ targetCompanyName: parsed.data.targetCompanyName ?? null
214
+ }
215
+ });
216
+ await appendAuditEvent(ctx.db, {
217
+ companyId: req.companyId!,
218
+ actorType: "human",
219
+ eventType: "template.apply_queued",
220
+ entityType: "template",
221
+ entityId: hydrated.id,
222
+ payload: { approvalId }
223
+ });
224
+ return sendOk(res, {
225
+ applied: false,
226
+ queuedForApproval: true,
227
+ approvalId,
228
+ summary: {
229
+ projects: 0,
230
+ goals: 0,
231
+ agents: 0,
232
+ issues: 0,
233
+ plugins: 0,
234
+ recurrence: 0
235
+ },
236
+ warnings: []
237
+ });
238
+ }
239
+ const currentVersion = await getCurrentTemplateVersion(ctx.db, req.companyId!, hydrated.id);
240
+ const applied = await applyTemplateManifest(ctx.db, {
241
+ companyId: req.companyId!,
242
+ templateId: hydrated.id,
243
+ templateVersion: hydrated.currentVersion,
244
+ templateVersionId: currentVersion?.id ?? null,
245
+ manifest: hydrated.manifest,
246
+ variables: parsed.data.variables
247
+ });
248
+ await appendAuditEvent(ctx.db, {
249
+ companyId: req.companyId!,
250
+ actorType: "human",
251
+ eventType: "template.applied",
252
+ entityType: "template",
253
+ entityId: hydrated.id,
254
+ payload: {
255
+ mode: parsed.data.mode,
256
+ targetCompanyName: parsed.data.targetCompanyName ?? null,
257
+ summary: applied.summary
258
+ }
259
+ });
260
+ return sendOk(res, applied);
261
+ });
262
+
263
+ router.post("/import", async (req, res) => {
264
+ requirePermission("templates:write")(req, res, () => {});
265
+ if (res.headersSent) {
266
+ return;
267
+ }
268
+ const parsed = TemplateImportRequestSchema.safeParse(req.body);
269
+ if (!parsed.success) {
270
+ return sendError(res, parsed.error.message, 422);
271
+ }
272
+ const payload = parsed.data.template.template;
273
+ const existing = await getTemplateBySlug(ctx.db, req.companyId!, payload.slug);
274
+ if (existing && !parsed.data.overwrite) {
275
+ return sendError(res, `Template slug '${payload.slug}' already exists. Use overwrite=true to replace.`, 409);
276
+ }
277
+ let targetId = existing?.id ?? null;
278
+ if (!existing) {
279
+ const created = await createTemplate(ctx.db, {
280
+ companyId: req.companyId!,
281
+ slug: payload.slug,
282
+ name: payload.name,
283
+ description: payload.description,
284
+ currentVersion: payload.currentVersion,
285
+ status: payload.status,
286
+ visibility: payload.visibility,
287
+ variablesJson: JSON.stringify(payload.variables)
288
+ });
289
+ targetId = created?.id ?? null;
290
+ } else {
291
+ await updateTemplate(ctx.db, {
292
+ companyId: req.companyId!,
293
+ id: existing.id,
294
+ name: payload.name,
295
+ description: payload.description,
296
+ currentVersion: payload.currentVersion,
297
+ status: payload.status,
298
+ visibility: payload.visibility,
299
+ variablesJson: JSON.stringify(payload.variables)
300
+ });
301
+ targetId = existing.id;
302
+ }
303
+ if (!targetId) {
304
+ return sendError(res, "Template import failed.", 500);
305
+ }
306
+ const existingVersion = await getTemplateVersionByVersion(ctx.db, {
307
+ companyId: req.companyId!,
308
+ templateId: targetId,
309
+ version: payload.currentVersion
310
+ });
311
+ if (!existingVersion) {
312
+ await createTemplateVersion(ctx.db, {
313
+ companyId: req.companyId!,
314
+ templateId: targetId,
315
+ version: payload.currentVersion,
316
+ manifestJson: JSON.stringify(payload.manifest)
317
+ });
318
+ }
319
+ await appendAuditEvent(ctx.db, {
320
+ companyId: req.companyId!,
321
+ actorType: "human",
322
+ eventType: "template.imported",
323
+ entityType: "template",
324
+ entityId: targetId,
325
+ payload: {
326
+ slug: payload.slug,
327
+ version: payload.currentVersion
328
+ }
329
+ });
330
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, targetId);
331
+ return sendOk(res, hydrated);
332
+ });
333
+
334
+ router.get("/:templateId/export", async (req, res) => {
335
+ const hydrated = await hydrateTemplate(ctx, req.companyId!, req.params.templateId);
336
+ if (!hydrated) {
337
+ return sendError(res, "Template not found.", 404);
338
+ }
339
+ await appendAuditEvent(ctx.db, {
340
+ companyId: req.companyId!,
341
+ actorType: "human",
342
+ eventType: "template.exported",
343
+ entityType: "template",
344
+ entityId: hydrated.id,
345
+ payload: {
346
+ version: hydrated.currentVersion
347
+ }
348
+ });
349
+ return sendOk(res, {
350
+ schemaVersion: "bopo.template.v1",
351
+ template: {
352
+ slug: hydrated.slug,
353
+ name: hydrated.name,
354
+ description: hydrated.description ?? undefined,
355
+ currentVersion: hydrated.currentVersion,
356
+ status: hydrated.status,
357
+ visibility: hydrated.visibility,
358
+ variables: hydrated.variables,
359
+ manifest: sanitizeManifestForExport(hydrated.manifest)
360
+ }
361
+ });
362
+ });
363
+
364
+ return router;
365
+ }
366
+
367
+ async function hydrateTemplate(ctx: AppContext, companyId: string, templateId: string) {
368
+ const template = await getTemplate(ctx.db, companyId, templateId);
369
+ if (!template) {
370
+ return null;
371
+ }
372
+ const version =
373
+ (await getTemplateVersionByVersion(ctx.db, {
374
+ companyId,
375
+ templateId,
376
+ version: template.currentVersion
377
+ })) ?? (await getCurrentTemplateVersion(ctx.db, companyId, templateId));
378
+ const variables = safeParseJsonArray(template.variablesJson);
379
+ const manifestRaw = safeParseJsonObject(version?.manifestJson ?? "{}");
380
+ const parsedManifest = TemplateManifestSchema.safeParse(manifestRaw);
381
+ const manifest = parsedManifest.success ? parsedManifest.data : TemplateManifestSchema.parse(TemplateManifestDefault);
382
+ return {
383
+ ...template,
384
+ variables,
385
+ manifest
386
+ };
387
+ }
388
+
389
+ function safeParseJsonArray(value: string | null | undefined) {
390
+ if (!value) {
391
+ return [];
392
+ }
393
+ try {
394
+ const parsed = JSON.parse(value) as unknown;
395
+ return Array.isArray(parsed) ? parsed : [];
396
+ } catch {
397
+ return [];
398
+ }
399
+ }
400
+
401
+ function safeParseJsonObject(value: string | null | undefined) {
402
+ if (!value) {
403
+ return {};
404
+ }
405
+ try {
406
+ const parsed = JSON.parse(value) as unknown;
407
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
408
+ } catch {
409
+ return {};
410
+ }
411
+ }
412
+
413
+ function sanitizeManifestForExport(manifest: Record<string, unknown>) {
414
+ const root = structuredClone(manifest) as Record<string, unknown>;
415
+ if (!Array.isArray(root.agents)) {
416
+ return root;
417
+ }
418
+ root.agents = root.agents.map((agent) => {
419
+ if (!agent || typeof agent !== "object") {
420
+ return agent;
421
+ }
422
+ const agentRecord = { ...(agent as Record<string, unknown>) };
423
+ const runtimeConfig = agentRecord.runtimeConfig;
424
+ if (runtimeConfig && typeof runtimeConfig === "object" && runtimeConfig !== null) {
425
+ const runtime = { ...(runtimeConfig as Record<string, unknown>) };
426
+ if (runtime.runtimeEnv && typeof runtime.runtimeEnv === "object" && runtime.runtimeEnv !== null) {
427
+ const env = runtime.runtimeEnv as Record<string, unknown>;
428
+ runtime.runtimeEnv = Object.fromEntries(
429
+ Object.entries(env).map(([key, value]) =>
430
+ /token|secret|password|key/i.test(key) ? [key, "<redacted>"] : [key, value]
431
+ )
432
+ );
433
+ }
434
+ agentRecord.runtimeConfig = runtime;
435
+ }
436
+ return agentRecord;
437
+ });
438
+ return root;
439
+ }
@@ -1,11 +1,26 @@
1
1
  import { access, constants, mkdir } from "node:fs/promises";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { bootstrapDatabase, listCompanies, listProjects, updateProject } from "bopodev-db";
5
- import { normalizeAbsolutePath, resolveBopoInstanceRoot, resolveProjectWorkspacePath, resolveStorageRoot } from "../lib/instance-paths";
4
+ import {
5
+ bootstrapDatabase,
6
+ createProjectWorkspace,
7
+ listCompanies,
8
+ listProjects,
9
+ listProjectWorkspaces,
10
+ updateProjectWorkspace
11
+ } from "bopodev-db";
12
+ import {
13
+ normalizeCompanyWorkspacePath,
14
+ resolveBopoInstanceRoot,
15
+ resolveProjectWorkspacePath,
16
+ resolveStorageRoot
17
+ } from "../lib/instance-paths";
6
18
 
7
19
  export interface ProjectWorkspaceBackfillSummary {
8
20
  scannedProjects: number;
21
+ createdWorkspaces: number;
22
+ normalizedWorkspaceCwds: number;
23
+ updatedWorkspaces: number;
9
24
  missingWorkspaceLocalPath: number;
10
25
  relativeWorkspaceLocalPath: number;
11
26
  updatedProjects: number;
@@ -20,9 +35,11 @@ export async function backfillProjectWorkspaces(input: { dbPath?: string; dryRun
20
35
  const instanceRoot = resolveBopoInstanceRoot();
21
36
  const storageRoot = resolveStorageRoot();
22
37
  let scannedProjects = 0;
23
- let missingWorkspaceLocalPath = 0;
24
- let relativeWorkspaceLocalPath = 0;
25
- let updatedProjects = 0;
38
+ let createdWorkspaces = 0;
39
+ let normalizedWorkspaceCwds = 0;
40
+ let updatedWorkspaces = 0;
41
+ let missingWorkspaceCount = 0;
42
+ let relativeWorkspaceCount = 0;
26
43
  let createdDirectories = 0;
27
44
 
28
45
  try {
@@ -31,38 +48,49 @@ export async function backfillProjectWorkspaces(input: { dbPath?: string; dryRun
31
48
  const projects = await listProjects(db, company.id);
32
49
  for (const project of projects) {
33
50
  scannedProjects += 1;
34
- const workspaceLocalPath = project.workspaceLocalPath?.trim() ?? "";
35
- if (!workspaceLocalPath) {
36
- missingWorkspaceLocalPath += 1;
51
+ const workspaces = await listProjectWorkspaces(db, company.id, project.id);
52
+ if (workspaces.length === 0) {
53
+ missingWorkspaceCount += 1;
37
54
  const nextPath = resolveProjectWorkspacePath(company.id, project.id);
38
55
  if (!input.dryRun) {
39
56
  await mkdir(nextPath, { recursive: true });
40
57
  createdDirectories += 1;
41
- const updated = await updateProject(db, {
58
+ const created = await createProjectWorkspace(db, {
42
59
  companyId: company.id,
43
- id: project.id,
44
- workspaceLocalPath: nextPath
60
+ projectId: project.id,
61
+ name: project.name,
62
+ cwd: nextPath,
63
+ isPrimary: true
45
64
  });
46
- if (updated) {
47
- updatedProjects += 1;
65
+ if (created) {
66
+ createdWorkspaces += 1;
48
67
  }
49
68
  }
50
69
  continue;
51
70
  }
52
71
 
53
- if (!isAbsolute(workspaceLocalPath)) {
54
- relativeWorkspaceLocalPath += 1;
55
- const nextPath = normalizeAbsolutePath(workspaceLocalPath);
72
+ for (const workspace of workspaces) {
73
+ const cwd = workspace.cwd?.trim() ?? "";
74
+ if (!cwd) {
75
+ continue;
76
+ }
77
+ if (isAbsolute(cwd)) {
78
+ continue;
79
+ }
80
+ relativeWorkspaceCount += 1;
81
+ normalizedWorkspaceCwds += 1;
82
+ const nextPath = normalizeCompanyWorkspacePath(company.id, cwd);
56
83
  if (!input.dryRun) {
57
84
  await mkdir(nextPath, { recursive: true });
58
85
  createdDirectories += 1;
59
- const updated = await updateProject(db, {
86
+ const updated = await updateProjectWorkspace(db, {
60
87
  companyId: company.id,
61
- id: project.id,
62
- workspaceLocalPath: nextPath
88
+ projectId: project.id,
89
+ id: workspace.id,
90
+ cwd: nextPath
63
91
  });
64
92
  if (updated) {
65
- updatedProjects += 1;
93
+ updatedWorkspaces += 1;
66
94
  }
67
95
  }
68
96
  }
@@ -78,9 +106,12 @@ export async function backfillProjectWorkspaces(input: { dbPath?: string; dryRun
78
106
 
79
107
  return {
80
108
  scannedProjects,
81
- missingWorkspaceLocalPath,
82
- relativeWorkspaceLocalPath,
83
- updatedProjects,
109
+ createdWorkspaces,
110
+ normalizedWorkspaceCwds,
111
+ updatedWorkspaces,
112
+ missingWorkspaceLocalPath: missingWorkspaceCount,
113
+ relativeWorkspaceLocalPath: relativeWorkspaceCount,
114
+ updatedProjects: updatedWorkspaces + createdWorkspaces,
84
115
  createdDirectories,
85
116
  writableInstanceRoot,
86
117
  writableStorageRoot,
@@ -105,8 +136,9 @@ async function isDirectoryWritable(path: string) {
105
136
  }
106
137
 
107
138
  async function main() {
139
+ const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
108
140
  const summary = await backfillProjectWorkspaces({
109
- dbPath: process.env.BOPO_DB_PATH,
141
+ dbPath,
110
142
  dryRun: process.env.BOPO_BACKFILL_DRY_RUN !== "0"
111
143
  });
112
144
  // eslint-disable-next-line no-console
@@ -116,3 +148,8 @@ async function main() {
116
148
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
149
  void main();
118
150
  }
151
+
152
+ function normalizeOptionalDbPath(value: string | undefined) {
153
+ const normalized = value?.trim();
154
+ return normalized && normalized.length > 0 ? normalized : undefined;
155
+ }
@@ -1,7 +1,8 @@
1
1
  import { bootstrapDatabase } from "bopodev-db";
2
2
 
3
3
  async function main() {
4
- const { client } = await bootstrapDatabase(process.env.BOPO_DB_PATH);
4
+ const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
5
+ const { client } = await bootstrapDatabase(dbPath);
5
6
  const maybeClose = (client as { close?: () => Promise<void> }).close;
6
7
  if (maybeClose) {
7
8
  await maybeClose.call(client);
@@ -11,3 +12,8 @@ async function main() {
11
12
  }
12
13
 
13
14
  void main();
15
+
16
+ function normalizeOptionalDbPath(value: string | undefined) {
17
+ const normalized = value?.trim();
18
+ return normalized && normalized.length > 0 ? normalized : undefined;
19
+ }