@botpress/adk-cli
Version:
Command-line interface for the Botpress Agent Development Kit (ADK)
677 lines (673 loc) • 20.2 kB
JavaScript
// @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
};