decocms
Version:
CLI for managing deco.chat apps & projects
262 lines • 9.49 kB
JavaScript
/**
* This file is responsible for reading and writing the config file.
* Config for Deco workers is stored in the wrangler.toml file, so
* we're a superset of the wrangler config.
*/
import { parse, stringify } from "smol-toml";
import { promises as fs, statSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
import { readSession } from "./session.js";
import { createHash } from "crypto";
import process from "node:process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// MD5 hash function using Node.js crypto
function md5Hash(input) {
const hash = createHash("sha1");
hash.update(input);
return hash.digest("hex");
}
export const CONFIG_FILE = "wrangler.toml";
const requiredErrorForProp = (prop) => `Property ${prop} is required. Please provide an inline value using --${prop} or configure it using 'deco configure'.`;
const DecoBindingSchema = z.union([
z.object({
name: z.string().min(1),
type: z.string().min(1),
integration_id: z.string().min(1),
}),
z.object({
name: z.string().min(1),
type: z.string().min(1),
integration_name: z.string().min(1),
}),
z.object({
name: z.string().min(1),
type: z.literal("contract"),
contract: z.object({
body: z.string().min(1),
clauses: z
.array(z.object({
id: z.string().min(1),
price: z.union([z.string(), z.number()]),
}))
.min(1),
}),
}),
]);
const decoConfigSchema = z.object({
workspace: z.string({
required_error: requiredErrorForProp("workspace"),
}),
bindings: z.array(DecoBindingSchema).optional().default([]),
local: z.boolean().optional().default(false),
enable_workflows: z.boolean().optional().default(true),
});
let local;
export const setLocal = (l) => {
local = l;
};
export const getLocal = () => {
return local;
};
export const readWranglerConfig = async (cwd) => {
const configPath = getConfigFilePath(cwd || process.cwd());
if (!configPath) {
return {};
}
try {
const config = await fs.readFile(configPath, "utf-8");
return parse(config);
}
catch (_error) {
return {};
}
};
/**
* Read the config file from the current directory or any parent directory.
* If no config file is found, returns an empty object, so we can still merge with inline options
* and work without a config file.
*
* @param cwd - The current working directory to read config from.
* @returns The partial config.
*/
const readConfigFile = async (cwd) => {
const wranglerConfig = await readWranglerConfig(cwd);
const decoConfig = wranglerConfig.deco ?? {};
return decoConfig;
};
const DECO_CHAT_WORKFLOW_BINDING = {
name: "DECO_CHAT_WORKFLOW_DO",
class_name: "Workflow",
};
const addSchemaNotation = (stringified) => {
return `#:schema node_modules/@deco/workers-runtime/config-schema.json\n${stringified}`;
};
/**
* Write the entire wrangler config to the config file.
* @param config - The wrangler config to write.
* @param cwd - The current working directory to write config to.
* @param merge - Whether to merge with existing config or replace it.
*/
export const writeWranglerConfig = async (config, cwd) => {
const targetCwd = cwd || process.cwd();
const currentConfig = await readWranglerConfig(targetCwd);
const mergedConfig = { ...currentConfig, ...config };
mergedConfig.scope ??= mergedConfig.scope ?? mergedConfig?.deco?.workspace;
const configPath = getConfigFilePath(targetCwd) ?? join(targetCwd, CONFIG_FILE);
await fs.writeFile(configPath, addSchemaNotation(stringify(mergedConfig)));
console.log(`✅ Wrangler configuration written to: ${configPath}`);
};
export const addWorkflowDO = async () => {
const wranglerConfig = await readWranglerConfig(process.cwd());
const currentDOs = wranglerConfig.durable_objects?.bindings ?? [];
const workflowsBindings = {
migrations: [
...(wranglerConfig.migrations ?? []).filter((m) => !m.new_classes?.includes(DECO_CHAT_WORKFLOW_BINDING.class_name)),
{
tag: "v1",
new_classes: [DECO_CHAT_WORKFLOW_BINDING.class_name],
},
],
durable_objects: {
bindings: [
...currentDOs.filter((b) => b.name !== DECO_CHAT_WORKFLOW_BINDING.name),
DECO_CHAT_WORKFLOW_BINDING,
],
},
};
await writeWranglerConfig(wranglerConfig.deco?.enable_workflows ? workflowsBindings : {});
};
/**
* Write the config to the current directory or any parent directory.
* @param config - The config to write.
* @param cwd - The current working directory to write config to.
*/
export const writeConfigFile = async (config, cwd, merge = true) => {
const targetCwd = cwd || process.cwd();
const wranglerConfig = await readWranglerConfig(targetCwd);
const current = wranglerConfig.deco ?? {};
const mergedConfig = merge ? { ...current, ...config } : config;
const configPath = getConfigFilePath(targetCwd) ?? join(targetCwd, CONFIG_FILE);
await fs.writeFile(configPath, addSchemaNotation(stringify({
...wranglerConfig,
deco: mergedConfig,
})));
console.log(`✅ Deco configuration written to: ${configPath}`);
};
/**
* Get the config for the current project considering the passed root directory and inline options.
* @param rootDir - The root directory to read the config from.
* @param inlineOptions - The inline options to merge with the config.
* @param cwd - The current working directory to read config from.
* @returns The config.
*/
export const getConfig = async ({ inlineOptions = {}, cwd, } = {}) => {
const config = await readConfigFile(cwd);
const merged = {
...config,
...Object.fromEntries(Object.entries(inlineOptions).filter(([_key, value]) => value !== undefined)),
};
if (!merged.workspace) {
const session = await readSession();
merged.workspace = session?.workspace;
}
merged.local = getLocal() ?? merged.local;
return decoConfigSchema.parse(merged);
};
/**
* Get the path to the config file in the current directory or any parent directory.
* Useful for finding the config file when the current directory is not the root directory of the project.
* @param cwd - The current working directory.
* @returns The path to the config file or null if not found.
*/
export const getConfigFilePath = (cwd) => {
// First, try the direct path
const directPath = join(cwd, CONFIG_FILE);
try {
const stat = statSync(directPath);
if (stat.isFile()) {
return directPath;
}
}
catch {
// File doesn't exist, continue searching
}
// If direct path fails, search parent directories
const dirs = cwd.split(/[/\\]/); // Handle both Unix and Windows path separators
const maxDepth = dirs.length;
for (let i = maxDepth; i >= 1; i--) {
const path = dirs.slice(0, i).join("/") || "/";
const configPath = join(path, CONFIG_FILE);
try {
const stat = statSync(configPath);
if (stat.isFile()) {
return configPath;
}
}
catch {
// File doesn't exist, continue searching
}
}
return null;
};
/**
* Generate a unique app UUID based on workspace and app name.
* Uses MD5 hash of workspace+app to ensure consistent UUIDs for the same project.
* @param workspace - The workspace name
* @param app - The app name
* @returns A unique UUID string based on the workspace and app name.
*/
export const getAppUUID = (workspace = "default", app = "my-app") => {
try {
const combined = `${workspace}-${app}`;
const hash = md5Hash(combined);
return hash.slice(0, 8); // Use first 8 characters for shorter, readable UUID
}
catch (_error) {
// Fallback to random UUID if hash generation fails
console.warn("Could not generate hash for UUID, using random fallback:", _error);
return crypto.randomUUID().slice(0, 8);
}
};
/**
* Generate a domain for the app based on workspace and app name.
* Uses the app UUID to create a consistent domain for the same project.
* @param workspace - The workspace name
* @param app - The app name
* @returns A domain string for the app.
*/
export const getAppDomain = (workspace, app) => {
const appUUID = getAppUUID(workspace, app);
return `localhost-${appUUID}.deco.host`;
};
export function getMCPConfig(workspace, app) {
const appDomain = getAppDomain(workspace, app);
return {
mcpServers: {
[app]: {
type: "http",
url: `https://${appDomain}/mcp`,
},
},
};
}
export const getMCPConfigVersion = () => md5Hash(getMCPConfig.toString());
export const getRulesConfig = async () => {
const rulesPath = join(__dirname, "../rules/deco-chat.mdc");
try {
const content = await fs.readFile(rulesPath, "utf-8");
return {
"deco-chat.mdc": content,
};
}
catch (error) {
console.warn("Could not read rules file:", error);
return {
"deco-chat.mdc": "",
};
}
};
//# sourceMappingURL=config.js.map