UNPKG

decocms

Version:

CLI for managing deco.chat apps & projects

262 lines 9.49 kB
/** * 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