UNPKG

@botpress/adk-cli

Version:

Command-line interface for the Botpress Agent Development Kit (ADK)

677 lines (673 loc) 20.2 kB
// @bun import { require_adm_zip } from "./chunk-8n02v208.js"; import { ensureJsonOnlyFormat } from "./chunk-7sfagm12.js"; import { AdkError } from "./chunk-wzj4dc7n.js"; import { __require, __toESM } from "./chunk-dhs2bg35.js"; // src/commands/adk-fleet.ts import path2 from "path"; // src/utils/fleet-upload.ts var import_adm_zip = __toESM(require_adm_zip(), 1); import { Buffer } from "buffer"; import { createHash, randomBytes } from "crypto"; import fs from "fs/promises"; import path from "path"; var DEFAULT_FLEET_LABEL = "App"; var FLEET_FILE_PREFIX = "fleet"; var FLEET_APP_PACKAGE_NAME = "app.zip"; var FLEET_APP_CONTENT_TYPE = "application/zip"; var FLEET_APP_MANIFEST_PATH = ".fleetapp/manifest.json"; var FLEET_BOT_TAGS = { visible: "fleet", app: "fleetapp", embedUrl: "fleetembedurl", embedLabel: "fleetembedlabel", embedBuildId: "fleetembedbuildid", embedSecret: "fleetembedsecret" }; var FLEET_FILE_TAGS = { marker: "fleet", buildId: "fleetbuildid", path: "fleetpath" }; function parseFleetUploadTarget(raw) { const target = raw ?? "dev"; if (target === "dev" || target === "prod") { return target; } throw new AdkError({ code: "INVALID_FLEET_TARGET", message: `Invalid --target value '${target}'. Expected 'dev' or 'prod'.`, expected: true, details: { target } }); } function normalizeFleetLabel(label) { const trimmed = label?.trim(); return trimmed ? trimmed : DEFAULT_FLEET_LABEL; } async function validateFleetDist(distPath) { const root = path.resolve(distPath); let stats; try { stats = await fs.stat(root); } catch (error) { if (error.code === "ENOENT") { throw new AdkError({ code: "FLEET_DIST_NOT_FOUND", message: `Fleet dist directory does not exist: ${root}`, expected: true, details: { dist: root } }); } throw error; } if (!stats.isDirectory()) { throw new AdkError({ code: "FLEET_DIST_NOT_DIRECTORY", message: `Fleet dist path must be a directory: ${root}`, expected: true, details: { dist: root } }); } const paths = await scanFiles(root); if (paths.length === 0) { throw new AdkError({ code: "FLEET_DIST_EMPTY", message: `Fleet dist directory is empty: ${root}`, expected: true, details: { dist: root } }); } const files = await Promise.all(paths.map(async (absolutePath) => { const content = await fs.readFile(absolutePath); const relativePath = toPosixPath(path.relative(root, absolutePath)); return { relativePath, absolutePath, content, contentType: getFleetMimeType(relativePath), hash: createHash("sha256").update(content).digest("hex"), size: content.byteLength }; })); files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); const reserved = files.find((file) => isFleetReservedPath(file.relativePath)); if (reserved) { throw new AdkError({ code: "FLEET_RESERVED_PATH", message: `Fleet dist directory contains a reserved Fleet package path: ${reserved.relativePath}`, expected: true, details: { dist: root, path: reserved.relativePath } }); } const index = files.find((file) => file.relativePath === "index.html"); if (!index) { throw new AdkError({ code: "FLEET_INDEX_NOT_FOUND", message: `Fleet dist directory must contain index.html at its root: ${root}`, expected: true, details: { dist: root } }); } return { root, files, index, totalBytes: files.reduce((sum, file) => sum + file.size, 0) }; } function createFleetUploadPlan(dist) { const buildId = computeFleetBuildId(dist.files); const prefix = FLEET_FILE_PREFIX; const sourceFiles = dist.files.map((file) => ({ relativePath: file.relativePath, contentType: file.contentType, hash: file.hash, size: file.size })); const packageContent = createFleetAppPackage({ buildId, sourceFiles, distFiles: dist.files }); return { buildId, prefix, packageKey: `${prefix}/${FLEET_APP_PACKAGE_NAME}`, packageName: FLEET_APP_PACKAGE_NAME, packageContentType: FLEET_APP_CONTENT_TYPE, packageContent, packageSize: packageContent.byteLength, sourceFiles, sourceBytes: dist.totalBytes }; } function resolveFleetEmbedSecret(currentTags, rotateSecret, generateSecret = generateFleetEmbedSecret) { const existing = currentTags[FLEET_BOT_TAGS.embedSecret]; if (existing && !rotateSecret) { return { secret: existing, status: "preserved" }; } return { secret: generateSecret(), status: existing ? "rotated" : "created" }; } function createFleetBotTags(input) { return { ...input.currentTags, [FLEET_BOT_TAGS.visible]: "visible", [FLEET_BOT_TAGS.app]: "true", [FLEET_BOT_TAGS.embedUrl]: input.embedUrl, [FLEET_BOT_TAGS.embedLabel]: input.label, [FLEET_BOT_TAGS.embedBuildId]: input.buildId, [FLEET_BOT_TAGS.embedSecret]: input.secret }; } function clearFleetBotTags(currentTags) { return { ...currentTags, [FLEET_BOT_TAGS.visible]: "", [FLEET_BOT_TAGS.app]: "", [FLEET_BOT_TAGS.embedUrl]: "", [FLEET_BOT_TAGS.embedLabel]: "", [FLEET_BOT_TAGS.embedBuildId]: "", [FLEET_BOT_TAGS.embedSecret]: "" }; } function getFleetMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { ".html": "text/html", ".htm": "text/html", ".css": "text/css", ".js": "application/javascript", ".mjs": "application/javascript", ".cjs": "application/javascript", ".json": "application/json", ".map": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon", ".txt": "text/plain", ".xml": "application/xml", ".wasm": "application/wasm", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject", ".pdf": "application/pdf", ".zip": "application/zip", ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav" }; return mimeTypes[ext] ?? "application/octet-stream"; } function createFleetAppPackage(input) { const zip = new import_adm_zip.default; const manifest = { format: "botpress.fleetapp", version: 1, buildId: input.buildId, entrypoint: "index.html", files: input.sourceFiles.map((file) => ({ path: file.relativePath, contentType: file.contentType, size: file.size, sha256: file.hash })) }; zip.addFile(FLEET_APP_MANIFEST_PATH, Buffer.from(`${JSON.stringify(manifest, null, 2)} `)); for (const file of [...input.distFiles].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { zip.addFile(file.relativePath, file.content); } return zip.toBuffer(); } async function uploadFleetEmbedDist(input) { const plan = createFleetUploadPlan(input.dist); const label = normalizeFleetLabel(input.label); const { bot } = await input.client.getBot({ id: input.botId }); const currentTags = bot.tags ?? {}; const previousBuildId = resolveFleetBuildId(currentTags); const secret = resolveFleetEmbedSecret(currentTags, input.rotateSecret === true, input.generateSecret); if (input.dryRun) { return createResult({ target: input.target, botId: input.botId, dist: input.dist.root, label, plan, dryRun: true, secretStatus: toDryRunSecretStatus(secret.status), tags: redactFleetTags(createFleetBotTags({ currentTags, embedUrl: "<uploaded app.zip URL>", label, buildId: plan.buildId, secret: "<redacted>" })) }); } let embedUrl; let uploadedFileId; try { const uploaded = await input.client.uploadFile({ key: plan.packageKey, content: plan.packageContent, contentType: plan.packageContentType, tags: { [FLEET_FILE_TAGS.marker]: "embed", [FLEET_FILE_TAGS.buildId]: plan.buildId, [FLEET_FILE_TAGS.path]: plan.packageName }, index: false, accessPolicies: ["public_content"], publicContentImmediatelyAccessible: true }); embedUrl = uploaded.file?.url; uploadedFileId = uploaded.file?.id; } catch (error) { throw new AdkError({ code: "FLEET_UPLOAD_FAILED", message: `Failed to upload Fleet app package. Bot tags were not updated. File key: ${plan.packageKey}`, expected: true, cause: error, details: { target: input.target, botId: input.botId, prefix: plan.prefix, packageKey: plan.packageKey } }); } if (!embedUrl || !isAbsoluteHttpUrl(embedUrl)) { throw new AdkError({ code: "FLEET_PACKAGE_URL_MISSING", message: `Botpress did not return an absolute URL for uploaded Fleet app package. Bot tags were not updated. File key: ${plan.packageKey}`, expected: true, details: { target: input.target, botId: input.botId, prefix: plan.prefix, packageKey: plan.packageKey } }); } const nextTags = createFleetBotTags({ currentTags, embedUrl, label, buildId: plan.buildId, secret: secret.secret }); try { await input.client.updateBot({ id: input.botId, tags: nextTags }); } catch (error) { throw new AdkError({ code: "FLEET_TAG_UPDATE_FAILED", message: `Uploaded Fleet app package but failed to update bot tags. File key: ${plan.packageKey}`, expected: true, cause: error, details: { target: input.target, botId: input.botId, prefix: plan.prefix, packageKey: plan.packageKey, embedUrl } }); } try { await deleteReplacedFleetUploadFiles(input.client, previousBuildId, { id: uploadedFileId, key: plan.packageKey }); } catch (error) { throw new AdkError({ code: "FLEET_REPLACE_FAILED", message: `Uploaded Fleet app package and updated bot tags but failed to remove previous Fleet package files. File key: ${plan.packageKey}`, expected: true, cause: error, details: { target: input.target, botId: input.botId, ...previousBuildId ? { previousBuildId } : {}, prefix: plan.prefix, packageKey: plan.packageKey, embedUrl } }); } return createResult({ target: input.target, botId: input.botId, dist: input.dist.root, label, plan, dryRun: false, embedUrl, secretStatus: secret.status, tags: redactFleetTags(nextTags) }); } async function removeFleetEmbedUpload(input) { const { bot } = await input.client.getBot({ id: input.botId }); const currentTags = bot.tags ?? {}; const embedUrl = currentTags[FLEET_BOT_TAGS.embedUrl]; const buildId = resolveFleetBuildId(currentTags); const prefix = buildId ? FLEET_FILE_PREFIX : undefined; const files = await listFleetUploadFiles(input.client, buildId); const nextTags = clearFleetBotTags(currentTags); if (input.dryRun) { return createRemoveResult({ target: input.target, botId: input.botId, dryRun: true, buildId, prefix, embedUrl, files, deleted: 0, tags: redactFleetTags(nextTags) }); } const deleted = []; try { for (const file of files) { await input.client.deleteFile({ id: file.id }); deleted.push(file); } } catch (error) { throw new AdkError({ code: "FLEET_REMOVE_FAILED", message: `Failed to remove Fleet upload files. Bot tags were not updated.${prefix ? ` File prefix: ${prefix}/` : ""}`, expected: true, cause: error, details: { target: input.target, botId: input.botId, ...buildId ? { buildId, prefix } : {}, deleted: deleted.length, fileCount: files.length } }); } try { await input.client.updateBot({ id: input.botId, tags: nextTags }); } catch (error) { throw new AdkError({ code: "FLEET_UNTAG_FAILED", message: `Removed Fleet upload files but failed to clear bot tags.${prefix ? ` File prefix: ${prefix}/` : ""}`, expected: true, cause: error, details: { target: input.target, botId: input.botId, ...buildId ? { buildId, prefix } : {}, deleted: deleted.length } }); } return createRemoveResult({ target: input.target, botId: input.botId, dryRun: false, buildId, prefix, embedUrl, files, deleted: deleted.length, tags: redactFleetTags(nextTags) }); } function formatFleetUploadResult(result) { const lines = [result.dryRun ? "Fleet upload dry run:" : "Fleet app package uploaded:"]; lines.push(` Target: ${result.target}`); lines.push(` Bot: ${result.botId}`); lines.push(` Dist: ${result.dist}`); lines.push(` Source files: ${result.fileCount}`); lines.push(` Package: ${result.packageKey}`); lines.push(` Package size: ${result.packageSize} bytes`); lines.push(` Prefix: ${result.prefix}/`); lines.push(` Label: ${result.label}`); lines.push(` Secret: ${result.secretStatus}`); lines.push(` Fleet app URL: ${result.embedUrl ?? "(not uploaded in dry run)"}`); return lines.join(` `); } function formatFleetRemoveResult(result) { const lines = [result.dryRun ? "Fleet remove dry run:" : "Fleet upload removed:"]; lines.push(` Target: ${result.target}`); lines.push(` Bot: ${result.botId}`); lines.push(` Files: ${result.dryRun ? result.files.length : result.deleted}`); lines.push(` Prefix: ${result.prefix ? `${result.prefix}/` : "(no build tag)"}`); lines.push(` Embed URL: ${result.embedUrl ?? "(none)"}`); return lines.join(` `); } function createResult(input) { return { success: true, dryRun: input.dryRun, target: input.target, botId: input.botId, dist: input.dist, label: input.label, buildId: input.plan.buildId, prefix: input.plan.prefix, packageKey: input.plan.packageKey, packageContentType: input.plan.packageContentType, packageSize: input.plan.packageSize, ...input.embedUrl ? { embedUrl: input.embedUrl } : {}, secretStatus: input.secretStatus, fileCount: input.plan.sourceFiles.length, sourceBytes: input.plan.sourceBytes, files: input.plan.sourceFiles.map((file) => ({ path: file.relativePath, contentType: file.contentType, size: file.size })), tags: input.tags }; } function createRemoveResult(input) { return { success: true, dryRun: input.dryRun, target: input.target, botId: input.botId, ...input.buildId ? { buildId: input.buildId } : {}, ...input.prefix ? { prefix: input.prefix } : {}, ...input.embedUrl ? { embedUrl: input.embedUrl } : {}, deleted: input.deleted, files: input.files, tags: input.tags }; } function redactFleetTags(tags) { const redacted = { ...tags }; delete redacted[FLEET_BOT_TAGS.embedSecret]; return redacted; } async function listFleetUploadFiles(client, buildId) { if (!buildId) { return []; } const tags = { [FLEET_FILE_TAGS.marker]: "embed", [FLEET_FILE_TAGS.buildId]: buildId }; const files = []; let nextToken; do { const response = await client.listFiles({ tags, nextToken }); files.push(...response.files.map((file) => ({ id: file.id, ...file.key ? { key: file.key } : {} }))); nextToken = response.meta?.nextToken; } while (nextToken); return files; } async function deleteReplacedFleetUploadFiles(client, buildId, currentFile) { const files = await listFleetUploadFiles(client, buildId); const staleFiles = files.filter((file) => file.id !== currentFile.id && file.key !== currentFile.key); const deleted = []; for (const file of staleFiles) { await client.deleteFile({ id: file.id }); deleted.push(file); } return deleted; } function generateFleetEmbedSecret() { return randomBytes(32).toString("hex"); } function toDryRunSecretStatus(status) { switch (status) { case "created": return "would-create"; case "preserved": return "would-preserve"; case "rotated": return "would-rotate"; } } async function scanFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const absolutePath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...await scanFiles(absolutePath)); } else if (entry.isFile()) { files.push(absolutePath); } } return files; } function computeFleetBuildId(files) { const hash = createHash("sha256"); for (const file of [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { hash.update(file.relativePath); hash.update("\x00"); hash.update(file.hash); hash.update("\x00"); } return hash.digest("hex").slice(0, 16); } function isAbsoluteHttpUrl(value) { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } function resolveFleetBuildId(currentTags) { const taggedBuildId = currentTags[FLEET_BOT_TAGS.embedBuildId]; if (taggedBuildId && isFleetBuildId(taggedBuildId)) { return taggedBuildId; } return; } function isFleetBuildId(value) { return /^[a-f0-9]{16}$/.test(value); } function isFleetReservedPath(relativePath) { return relativePath === ".fleetapp" || relativePath.startsWith(".fleetapp/"); } function toPosixPath(filePath) { return filePath.split(path.sep).join("/"); } // src/commands/adk-fleet.ts async function adkFleetUpload(options, deps = {}) { ensureJsonOnlyFormat(options.format); if (!options.dist) { throw new AdkError({ code: "FLEET_DIST_REQUIRED", message: "--dist is required.", expected: true, suggestion: "Run `adk fleet upload --dist ./frontend/dist`." }); } const target = parseFleetUploadTarget(options.target); const cwd = deps.cwd ?? process.cwd(); const dist = await validateFleetDist(path2.resolve(cwd, options.dist)); const resolve = deps.resolveCommandContext ?? (await import("./chunk-q764sw5x.js")).resolveCommandContext; const context = await resolve({ cwd, target, createClient: true, require: ["project", "credentials", "workspace", "bot"] }); const client = requireFleetClient(context); const result = await uploadFleetEmbedDist({ target, botId: context.botId, client, dist, label: options.label, rotateSecret: options.rotateSecret, dryRun: options.dryRun, generateSecret: deps.generateSecret }); const logger = deps.logger ?? (await import("./chunk-wctc100c.js")).createCliLogger({ format: options.format }); logger.info(formatFleetUploadResult(result)).result(result); } async function adkFleetRemove(options, deps = {}) { ensureJsonOnlyFormat(options.format); const target = parseFleetUploadTarget(options.target); const cwd = deps.cwd ?? process.cwd(); const resolve = deps.resolveCommandContext ?? (await import("./chunk-q764sw5x.js")).resolveCommandContext; const context = await resolve({ cwd, target, createClient: true, require: ["project", "credentials", "workspace", "bot"] }); const client = requireFleetClient(context); const result = await removeFleetEmbedUpload({ target, botId: context.botId, client, dryRun: options.dryRun }); const logger = deps.logger ?? (await import("./chunk-wctc100c.js")).createCliLogger({ format: options.format }); logger.info(formatFleetRemoveResult(result)).result(result); } function requireFleetClient(context) { if (!context.client || !context.botId) { throw new AdkError({ code: "FLEET_CONTEXT_INCOMPLETE", message: "Fleet upload requires a resolved Botpress client and bot ID.", expected: true }); } return context.client; } export { adkFleetUpload, adkFleetRemove };