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.
- package/package.json +4 -4
- package/src/app.ts +57 -1
- package/src/context.ts +3 -0
- package/src/lib/agent-config.ts +10 -1
- package/src/lib/git-runtime.ts +447 -0
- package/src/lib/instance-paths.ts +75 -10
- package/src/lib/workspace-policy.ts +153 -10
- package/src/middleware/request-actor.ts +67 -2
- package/src/realtime/hub.ts +31 -2
- package/src/routes/agents.ts +146 -107
- package/src/routes/auth.ts +54 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +8 -0
- package/src/routes/issues.ts +23 -10
- package/src/routes/projects.ts +361 -63
- package/src/routes/templates.ts +439 -0
- package/src/scripts/backfill-project-workspaces.ts +61 -24
- package/src/scripts/db-init.ts +7 -1
- package/src/scripts/onboard-seed.ts +140 -12
- package/src/security/actor-token.ts +133 -0
- package/src/security/deployment-mode.ts +73 -0
- package/src/server.ts +72 -4
- package/src/services/governance-service.ts +122 -15
- package/src/services/heartbeat-service.ts +136 -36
- package/src/services/plugin-runtime.ts +2 -2
- package/src/services/template-apply-service.ts +138 -0
- package/src/services/template-catalog.ts +325 -0
- package/src/services/template-preview-service.ts +78 -0
|
@@ -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 {
|
|
5
|
-
|
|
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
|
|
24
|
-
let
|
|
25
|
-
let
|
|
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
|
|
35
|
-
if (
|
|
36
|
-
|
|
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
|
|
58
|
+
const created = await createProjectWorkspace(db, {
|
|
42
59
|
companyId: company.id,
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
projectId: project.id,
|
|
61
|
+
name: project.name,
|
|
62
|
+
cwd: nextPath,
|
|
63
|
+
isPrimary: true
|
|
45
64
|
});
|
|
46
|
-
if (
|
|
47
|
-
|
|
65
|
+
if (created) {
|
|
66
|
+
createdWorkspaces += 1;
|
|
48
67
|
}
|
|
49
68
|
}
|
|
50
69
|
continue;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
86
|
+
const updated = await updateProjectWorkspace(db, {
|
|
60
87
|
companyId: company.id,
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
projectId: project.id,
|
|
89
|
+
id: workspace.id,
|
|
90
|
+
cwd: nextPath
|
|
63
91
|
});
|
|
64
92
|
if (updated) {
|
|
65
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
+
}
|
package/src/scripts/db-init.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { bootstrapDatabase } from "bopodev-db";
|
|
2
2
|
|
|
3
3
|
async function main() {
|
|
4
|
-
const
|
|
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
|
+
}
|