@bonnard/cli
Advanced tools
| import { i as put, n as get, r as post, t as del } from "./api-dRafBjyt.mjs"; | ||
| import "./config-_EMZ9qeI.mjs"; | ||
| export { del, get }; |
| import { i as loadConfig, r as isSelfHosted, t as getBaseUrl } from "./config-_EMZ9qeI.mjs"; | ||
| import { createRequire } from "node:module"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import os from "node:os"; | ||
| import pc from "picocolors"; | ||
| //#region src/lib/credentials.ts | ||
| const CREDENTIALS_DIR = path.join(os.homedir(), ".config", "bon"); | ||
| const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); | ||
| function saveCredentials(credentials) { | ||
| fs.mkdirSync(CREDENTIALS_DIR, { | ||
| recursive: true, | ||
| mode: 448 | ||
| }); | ||
| fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 384 }); | ||
| } | ||
| function loadCredentials() { | ||
| try { | ||
| const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.token && parsed.email) return parsed; | ||
| return null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function clearCredentials() { | ||
| try { | ||
| fs.unlinkSync(CREDENTIALS_FILE); | ||
| } catch {} | ||
| } | ||
| //#endregion | ||
| //#region src/lib/api.ts | ||
| const { version } = createRequire(import.meta.url)("../../package.json"); | ||
| const USER_AGENT = `bon-cli/${version} node-${process.version} ${os.platform()} (${os.arch()})`; | ||
| const VERCEL_BYPASS = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; | ||
| function getToken() { | ||
| const config = loadConfig(); | ||
| if (isSelfHosted(config)) return config?.admin_token || process.env.ADMIN_TOKEN || null; | ||
| const creds = loadCredentials(); | ||
| if (!creds) { | ||
| console.error(pc.red("Not logged in. Run `bon login` first.")); | ||
| process.exit(1); | ||
| } | ||
| return creds.token; | ||
| } | ||
| async function request(method, path, body) { | ||
| const config = loadConfig(); | ||
| const token = getToken(); | ||
| const url = `${getBaseUrl(config)}${path}`; | ||
| const headers = { | ||
| "Content-Type": "application/json", | ||
| "User-Agent": USER_AGENT | ||
| }; | ||
| if (token) headers.Authorization = `Bearer ${token}`; | ||
| if (VERCEL_BYPASS && !isSelfHosted(config)) headers["x-vercel-protection-bypass"] = VERCEL_BYPASS; | ||
| const res = await fetch(url, { | ||
| method, | ||
| headers, | ||
| body: body ? JSON.stringify(body) : void 0, | ||
| signal: AbortSignal.timeout(3e4) | ||
| }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { | ||
| data = JSON.parse(text); | ||
| } catch { | ||
| data = {}; | ||
| } | ||
| if (!res.ok) { | ||
| const message = data.error || `HTTP ${res.status}: ${res.statusText}`; | ||
| throw new Error(message); | ||
| } | ||
| return data; | ||
| } | ||
| function get(path) { | ||
| return request("GET", path); | ||
| } | ||
| function post(path, body) { | ||
| return request("POST", path, body); | ||
| } | ||
| function put(path, body) { | ||
| return request("PUT", path, body); | ||
| } | ||
| function del(path) { | ||
| return request("DELETE", path); | ||
| } | ||
| //#endregion | ||
| export { clearCredentials as a, put as i, get as n, loadCredentials as o, post as r, saveCredentials as s, del as t }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.mjs"; | ||
| import fs from "node:fs"; | ||
| import { parse } from "yaml"; | ||
| //#region src/lib/config.ts | ||
| let cachedConfig; | ||
| /** | ||
| * Parse bon.yaml from the working directory. | ||
| * Returns null if the file doesn't exist or can't be parsed. | ||
| */ | ||
| function loadConfig(cwd) { | ||
| if (cachedConfig !== void 0) return cachedConfig; | ||
| const paths = getProjectPaths(cwd || process.cwd()); | ||
| try { | ||
| cachedConfig = parse(fs.readFileSync(paths.config, "utf-8")) || null; | ||
| } catch { | ||
| cachedConfig = null; | ||
| } | ||
| return cachedConfig; | ||
| } | ||
| /** | ||
| * Check if the project is running in self-hosted mode. | ||
| * Checks BON_MODE env var first, then bon.yaml. | ||
| */ | ||
| function isSelfHosted(config) { | ||
| if (process.env.BON_MODE === "self-hosted") return true; | ||
| return config?.mode === "self-hosted"; | ||
| } | ||
| /** | ||
| * Resolve the base URL for API requests. | ||
| * Priority: BON_APP_URL env var > bon.yaml `url` > default. | ||
| */ | ||
| function getBaseUrl(config) { | ||
| if (isSelfHosted(config)) return process.env.BON_APP_URL || config?.url || "http://localhost:3000"; | ||
| return process.env.BON_APP_URL || "https://app.bonnard.dev"; | ||
| } | ||
| /** | ||
| * Resolve the MCP server URL. | ||
| * Self-hosted: {baseUrl}/mcp. | ||
| * Cloud: https://mcp.bonnard.dev/mcp. | ||
| */ | ||
| function getMcpUrl(config) { | ||
| if (isSelfHosted(config)) return `${getBaseUrl(config)}/mcp`; | ||
| return "https://mcp.bonnard.dev/mcp"; | ||
| } | ||
| //#endregion | ||
| export { loadConfig as i, getMcpUrl as n, isSelfHosted as r, getBaseUrl as t }; |
| import { i as loadConfig, n as getMcpUrl, r as isSelfHosted, t as getBaseUrl } from "./config-_EMZ9qeI.mjs"; | ||
| export { getBaseUrl, isSelfHosted, loadConfig }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| //#region src/lib/cubes/datasources.ts | ||
| /** | ||
| * Extract datasource references from cube and view files | ||
| */ | ||
| /** | ||
| * Collect all YAML files from a directory recursively | ||
| */ | ||
| function collectYamlFiles(dir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push(fullPath); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| /** | ||
| * Parse a single model file and extract datasource references | ||
| */ | ||
| function extractFromFile(filePath) { | ||
| const datasourceToCubes = /* @__PURE__ */ new Map(); | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| const parsed = YAML.parse(content); | ||
| if (!parsed) return datasourceToCubes; | ||
| if (parsed.cubes) for (const cube of parsed.cubes) { | ||
| const ds = cube.data_source || "default"; | ||
| const existing = datasourceToCubes.get(ds) || []; | ||
| existing.push(cube.name); | ||
| datasourceToCubes.set(ds, existing); | ||
| } | ||
| if (parsed.views) { | ||
| for (const view of parsed.views) if (view.data_source) { | ||
| const existing = datasourceToCubes.get(view.data_source) || []; | ||
| existing.push(view.name); | ||
| datasourceToCubes.set(view.data_source, existing); | ||
| } | ||
| } | ||
| } catch {} | ||
| return datasourceToCubes; | ||
| } | ||
| /** | ||
| * Extract all unique datasource references from bonnard/cubes/ and bonnard/views/ directories | ||
| * Returns datasource names mapped to the cubes that use them | ||
| */ | ||
| function extractDatasourcesFromCubes(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const cubesDir = paths.cubes; | ||
| const viewsDir = paths.views; | ||
| const allFiles = [...collectYamlFiles(cubesDir), ...collectYamlFiles(viewsDir)]; | ||
| const aggregated = /* @__PURE__ */ new Map(); | ||
| for (const file of allFiles) { | ||
| const fileRefs = extractFromFile(file); | ||
| for (const [ds, cubes] of fileRefs) { | ||
| const existing = aggregated.get(ds) || []; | ||
| existing.push(...cubes); | ||
| aggregated.set(ds, existing); | ||
| } | ||
| } | ||
| const results = []; | ||
| for (const [name, cubes] of aggregated) if (name !== "default") results.push({ | ||
| name, | ||
| cubes | ||
| }); | ||
| return results; | ||
| } | ||
| //#endregion | ||
| export { extractDatasourcesFromCubes }; |
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials, i as ensureBonDir, l as saveLocalDatasources, n as addLocalDatasource, o as loadLocalDatasources, r as datasourceExists, s as removeLocalDatasource, t as isDatasourcesTrackedByGit } from "./local-CQt-MJr-.mjs"; | ||
| export { loadLocalDatasources }; |
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { execFileSync } from "node:child_process"; | ||
| //#region src/lib/local/datasources.ts | ||
| /** | ||
| * Local datasource storage (.bon/datasources.yaml) | ||
| * | ||
| * Single file containing both config and credentials. | ||
| * Credentials may contain: | ||
| * - Plain values: "my_password" | ||
| * - dbt env var syntax: "{{ env_var('MY_PASSWORD') }}" | ||
| * | ||
| * Env vars are resolved at deploy time, not import time. | ||
| */ | ||
| const BON_DIR$1 = ".bon"; | ||
| const DATASOURCES_FILE$1 = "datasources.yaml"; | ||
| function getBonDir(cwd = process.cwd()) { | ||
| return path.join(cwd, BON_DIR$1); | ||
| } | ||
| function getDatasourcesPath$1(cwd = process.cwd()) { | ||
| return path.join(getBonDir(cwd), DATASOURCES_FILE$1); | ||
| } | ||
| /** | ||
| * Ensure .bon directory exists | ||
| */ | ||
| function ensureBonDir(cwd = process.cwd()) { | ||
| const bonDir = getBonDir(cwd); | ||
| if (!fs.existsSync(bonDir)) fs.mkdirSync(bonDir, { recursive: true }); | ||
| } | ||
| /** | ||
| * Load all local datasources | ||
| */ | ||
| function loadLocalDatasources(cwd = process.cwd()) { | ||
| const filePath = getDatasourcesPath$1(cwd); | ||
| if (!fs.existsSync(filePath)) return []; | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| return YAML.parse(content)?.datasources ?? []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** | ||
| * Save all local datasources (with secure permissions since it contains credentials) | ||
| */ | ||
| function saveLocalDatasources(datasources, cwd = process.cwd()) { | ||
| ensureBonDir(cwd); | ||
| const filePath = getDatasourcesPath$1(cwd); | ||
| const file = { datasources }; | ||
| const content = `# Bonnard datasources configuration | ||
| # This file contains credentials - add to .gitignore | ||
| # Env vars like {{ env_var('PASSWORD') }} are resolved at deploy time | ||
| ` + YAML.stringify(file, { indent: 2 }); | ||
| fs.writeFileSync(filePath, content, { mode: 384 }); | ||
| } | ||
| /** | ||
| * Add a single datasource (updates existing or appends new) | ||
| */ | ||
| function addLocalDatasource(datasource, cwd = process.cwd()) { | ||
| const existing = loadLocalDatasources(cwd); | ||
| const index = existing.findIndex((ds) => ds.name === datasource.name); | ||
| if (index >= 0) existing[index] = datasource; | ||
| else existing.push(datasource); | ||
| saveLocalDatasources(existing, cwd); | ||
| } | ||
| /** | ||
| * Remove a datasource by name | ||
| */ | ||
| function removeLocalDatasource(name, cwd = process.cwd()) { | ||
| const existing = loadLocalDatasources(cwd); | ||
| const filtered = existing.filter((ds) => ds.name !== name); | ||
| if (filtered.length === existing.length) return false; | ||
| saveLocalDatasources(filtered, cwd); | ||
| return true; | ||
| } | ||
| /** | ||
| * Get a single datasource by name | ||
| */ | ||
| function getLocalDatasource(name, cwd = process.cwd()) { | ||
| return loadLocalDatasources(cwd).find((ds) => ds.name === name) ?? null; | ||
| } | ||
| /** | ||
| * Check if a datasource name already exists locally | ||
| */ | ||
| function datasourceExists(name, cwd = process.cwd()) { | ||
| return getLocalDatasource(name, cwd) !== null; | ||
| } | ||
| /** | ||
| * Resolve {{ env_var('VAR_NAME') }} patterns in credentials | ||
| * Used at deploy time to resolve env vars before uploading | ||
| */ | ||
| function resolveEnvVarsInCredentials(credentials) { | ||
| const resolved = {}; | ||
| const missing = []; | ||
| const envVarPattern = /\{\{\s*env_var\(['"]([\w_]+)['"]\)\s*\}\}/; | ||
| for (const [key, value] of Object.entries(credentials)) { | ||
| const match = value.match(envVarPattern); | ||
| if (match) { | ||
| const varName = match[1]; | ||
| const envValue = process.env[varName]; | ||
| if (envValue !== void 0) resolved[key] = envValue; | ||
| else { | ||
| missing.push(varName); | ||
| resolved[key] = value; | ||
| } | ||
| } else resolved[key] = value; | ||
| } | ||
| return { | ||
| resolved, | ||
| missing | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/local/credentials.ts | ||
| /** | ||
| * Credential utilities (git tracking check) | ||
| */ | ||
| const BON_DIR = ".bon"; | ||
| const DATASOURCES_FILE = "datasources.yaml"; | ||
| function getDatasourcesPath(cwd = process.cwd()) { | ||
| return path.join(cwd, BON_DIR, DATASOURCES_FILE); | ||
| } | ||
| /** | ||
| * Check if datasources file is tracked by git (it shouldn't be - contains credentials) | ||
| */ | ||
| function isDatasourcesTrackedByGit(cwd = process.cwd()) { | ||
| const filePath = getDatasourcesPath(cwd); | ||
| if (!fs.existsSync(filePath)) return false; | ||
| try { | ||
| execFileSync("git", [ | ||
| "ls-files", | ||
| "--error-unmatch", | ||
| filePath | ||
| ], { | ||
| cwd, | ||
| stdio: "pipe" | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { getLocalDatasource as a, resolveEnvVarsInCredentials as c, ensureBonDir as i, saveLocalDatasources as l, addLocalDatasource as n, loadLocalDatasources as o, datasourceExists as r, removeLocalDatasource as s, isDatasourcesTrackedByGit as t }; |
| import "./api-dRafBjyt.mjs"; | ||
| import "./config-_EMZ9qeI.mjs"; | ||
| import "./local-CQt-MJr-.mjs"; | ||
| import { t as pushDatasource } from "./push-CSaZ-pXY.mjs"; | ||
| export { pushDatasource }; |
| import { r as post } from "./api-dRafBjyt.mjs"; | ||
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials } from "./local-CQt-MJr-.mjs"; | ||
| import pc from "picocolors"; | ||
| import "@inquirer/prompts"; | ||
| //#region src/commands/datasource/push.ts | ||
| /** | ||
| * Push a datasource programmatically (for use by deploy command) | ||
| * Returns true on success, false on failure | ||
| */ | ||
| async function pushDatasource(name, options = {}) { | ||
| const datasource = getLocalDatasource(name); | ||
| if (!datasource) { | ||
| if (!options.silent) console.error(pc.red(`Datasource "${name}" not found locally`)); | ||
| return false; | ||
| } | ||
| const { resolved, missing } = resolveEnvVarsInCredentials(datasource.credentials); | ||
| if (missing.length > 0) { | ||
| if (!options.silent) console.error(pc.red(`Missing env vars for "${name}": ${missing.join(", ")}`)); | ||
| return false; | ||
| } | ||
| try { | ||
| await post("/api/datasources", { | ||
| name: datasource.name, | ||
| warehouse_type: datasource.type, | ||
| config: datasource.config, | ||
| credentials: resolved | ||
| }); | ||
| return true; | ||
| } catch (err) { | ||
| if (!options.silent) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(pc.red(`Failed to push "${name}": ${message}`)); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { pushDatasource as t }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { z } from "zod"; | ||
| //#region src/lib/schema.ts | ||
| const identifier = z.string().regex(/^[_a-zA-Z][_a-zA-Z0-9]*$/, "must be a valid identifier (letters, numbers, underscores; cannot start with a number)"); | ||
| const refreshKeySchema = z.object({ | ||
| every: z.string().regex(/^\d+\s+(second|minute|hour|day|week)s?$/, { message: "must be a time interval like \"1 hour\", \"30 minute\", \"1 day\"" }).optional(), | ||
| sql: z.string().optional() | ||
| }); | ||
| const measureTypes = [ | ||
| "count", | ||
| "count_distinct", | ||
| "count_distinct_approx", | ||
| "sum", | ||
| "avg", | ||
| "min", | ||
| "max", | ||
| "number", | ||
| "string", | ||
| "time", | ||
| "boolean", | ||
| "running_total", | ||
| "number_agg" | ||
| ]; | ||
| const dimensionTypes = [ | ||
| "string", | ||
| "number", | ||
| "boolean", | ||
| "time", | ||
| "geo", | ||
| "switch" | ||
| ]; | ||
| const relationshipTypes = [ | ||
| "many_to_one", | ||
| "one_to_many", | ||
| "one_to_one" | ||
| ]; | ||
| const granularities = [ | ||
| "second", | ||
| "minute", | ||
| "hour", | ||
| "day", | ||
| "week", | ||
| "month", | ||
| "quarter", | ||
| "year" | ||
| ]; | ||
| const preAggTypes = [ | ||
| "rollup", | ||
| "original_sql", | ||
| "rollup_join", | ||
| "rollup_lambda" | ||
| ]; | ||
| const formats = [ | ||
| "percent", | ||
| "currency", | ||
| "number", | ||
| "imageUrl", | ||
| "link", | ||
| "id" | ||
| ]; | ||
| const measureSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(measureTypes), | ||
| sql: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: z.enum(formats).optional(), | ||
| public: z.boolean().optional(), | ||
| filters: z.array(z.object({ sql: z.string() })).optional(), | ||
| rolling_window: z.object({ | ||
| trailing: z.string().optional(), | ||
| leading: z.string().optional(), | ||
| offset: z.string().optional() | ||
| }).optional(), | ||
| drill_members: z.array(z.string()).optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const dimensionFormatSchema = z.union([z.enum(formats), z.string().startsWith("%")]); | ||
| const dimensionSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(dimensionTypes), | ||
| sql: z.string().optional(), | ||
| primary_key: z.boolean().optional(), | ||
| sub_query: z.boolean().optional(), | ||
| propagate_filters_to_sub_query: z.boolean().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: dimensionFormatSchema.optional(), | ||
| public: z.boolean().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional(), | ||
| latitude: z.object({ sql: z.string() }).optional(), | ||
| longitude: z.object({ sql: z.string() }).optional(), | ||
| case: z.object({ | ||
| when: z.array(z.object({ | ||
| sql: z.string(), | ||
| label: z.string() | ||
| })), | ||
| else: z.object({ label: z.string() }).optional() | ||
| }).optional() | ||
| }); | ||
| const joinSchema = z.object({ | ||
| name: identifier, | ||
| relationship: z.enum(relationshipTypes), | ||
| sql: z.string() | ||
| }); | ||
| const segmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const preAggregationSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(preAggTypes).optional(), | ||
| measures: z.array(z.string()).optional(), | ||
| dimensions: z.array(z.string()).optional(), | ||
| time_dimension: z.string().optional(), | ||
| granularity: z.enum(granularities).optional(), | ||
| partition_granularity: z.enum(granularities).optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| scheduled_refresh: z.boolean().optional() | ||
| }); | ||
| const hierarchySchema = z.object({ | ||
| name: identifier, | ||
| levels: z.array(z.string()), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const cubeSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string().optional(), | ||
| sql_table: z.string().optional(), | ||
| data_source: z.string().optional(), | ||
| extends: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| measures: z.array(measureSchema).optional(), | ||
| dimensions: z.array(dimensionSchema).optional(), | ||
| joins: z.array(joinSchema).optional(), | ||
| segments: z.array(segmentSchema).optional(), | ||
| pre_aggregations: z.array(preAggregationSchema).optional(), | ||
| hierarchies: z.array(hierarchySchema).optional() | ||
| }).refine((data) => data.sql != null || data.sql_table != null || data.extends != null, { message: "sql, sql_table, or extends is required" }); | ||
| const viewIncludeItemSchema = z.union([z.string(), z.object({ | ||
| name: z.string(), | ||
| alias: z.string().optional(), | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| format: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| })]); | ||
| const viewCubeRefSchema = z.object({ | ||
| join_path: z.string(), | ||
| includes: z.union([z.literal("*"), z.array(viewIncludeItemSchema)]).optional(), | ||
| excludes: z.array(z.string()).optional(), | ||
| prefix: z.boolean().optional() | ||
| }); | ||
| const folderSchema = z.lazy(() => z.object({ | ||
| name: z.string(), | ||
| members: z.array(z.string()).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| })); | ||
| const viewMeasureSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| format: z.string().optional(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewDimensionSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewSegmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional() | ||
| }); | ||
| const viewSchema = z.object({ | ||
| name: identifier, | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| cubes: z.array(viewCubeRefSchema).optional(), | ||
| measures: z.array(viewMeasureSchema).optional(), | ||
| dimensions: z.array(viewDimensionSchema).optional(), | ||
| segments: z.array(viewSegmentSchema).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| }); | ||
| const fileSchema = z.object({ | ||
| cubes: z.array(cubeSchema).optional(), | ||
| views: z.array(viewSchema).optional() | ||
| }).refine((data) => data.cubes && data.cubes.length > 0 || data.views && data.views.length > 0, "File must contain at least one cube or view"); | ||
| function formatZodError(error, fileName, parsed) { | ||
| return error.issues.map((issue) => { | ||
| const pathParts = issue.path; | ||
| let entityContext = ""; | ||
| if (pathParts.length >= 2) { | ||
| const collection = pathParts[0]; | ||
| const index = pathParts[1]; | ||
| const entity = parsed?.[collection]?.[index]; | ||
| if (entity?.name) entityContext = ` (${entity.name})`; | ||
| } | ||
| const pathStr = pathParts.join("."); | ||
| const location = pathStr ? `${pathStr}${entityContext}` : ""; | ||
| if (issue.code === "invalid_value" && "values" in issue) return `${fileName}: ${location} — invalid value, expected one of: ${issue.values.join(", ")}`; | ||
| return `${fileName}: ${location ? `${location} — ` : ""}${issue.message}`; | ||
| }); | ||
| } | ||
| function checkViewMemberConflicts(parsedFiles, cubeMap) { | ||
| const errors = []; | ||
| for (const { fileName, parsed } of parsedFiles) for (const view of parsed.views ?? []) { | ||
| if (!view.name || !view.cubes) continue; | ||
| const seen = /* @__PURE__ */ new Map(); | ||
| for (const m of view.measures ?? []) if (m.name) seen.set(m.name, `${view.name} (direct)`); | ||
| for (const d of view.dimensions ?? []) if (d.name) seen.set(d.name, `${view.name} (direct)`); | ||
| for (const s of view.segments ?? []) if (s.name) seen.set(s.name, `${view.name} (direct)`); | ||
| for (const cubeRef of view.cubes) { | ||
| const joinPath = cubeRef.join_path; | ||
| if (!joinPath) continue; | ||
| const segments = joinPath.split("."); | ||
| const targetCubeName = segments[segments.length - 1]; | ||
| let memberNames = []; | ||
| if (cubeRef.includes === "*") { | ||
| const cube = cubeMap.get(targetCubeName); | ||
| if (!cube) continue; | ||
| memberNames = [ | ||
| ...cube.measures, | ||
| ...cube.dimensions, | ||
| ...cube.segments | ||
| ]; | ||
| } else if (Array.isArray(cubeRef.includes)) { | ||
| for (const item of cubeRef.includes) if (typeof item === "string") memberNames.push(item); | ||
| else if (item && typeof item === "object" && item.name) memberNames.push(item.alias || item.name); | ||
| } else continue; | ||
| if (Array.isArray(cubeRef.excludes)) { | ||
| const excludeSet = new Set(cubeRef.excludes); | ||
| memberNames = memberNames.filter((n) => !excludeSet.has(n)); | ||
| } | ||
| for (const rawName of memberNames) { | ||
| const finalName = cubeRef.prefix ? `${targetCubeName}_${rawName}` : rawName; | ||
| const existingSource = seen.get(finalName); | ||
| if (existingSource) errors.push(`${fileName}: view '${view.name}' — member '${finalName}' from '${joinPath}' conflicts with '${existingSource}'. Use prefix: true or an alias.`); | ||
| else seen.set(finalName, joinPath); | ||
| } | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| const parsedFiles = []; | ||
| const cubeMap = /* @__PURE__ */ new Map(); | ||
| for (const file of files) { | ||
| let parsed; | ||
| try { | ||
| parsed = YAML.parse(file.content); | ||
| } catch (err) { | ||
| errors.push(`${file.fileName}: YAML parse error — ${err.message}`); | ||
| continue; | ||
| } | ||
| if (!parsed || typeof parsed !== "object") { | ||
| errors.push(`${file.fileName}: file is empty or not a YAML object`); | ||
| continue; | ||
| } | ||
| const result = fileSchema.safeParse(parsed); | ||
| if (!result.success) { | ||
| errors.push(...formatZodError(result.error, file.fileName, parsed)); | ||
| continue; | ||
| } | ||
| parsedFiles.push({ | ||
| fileName: file.fileName, | ||
| parsed | ||
| }); | ||
| for (const cube of parsed.cubes ?? []) if (cube.name) { | ||
| const existing = allNames.get(cube.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${cube.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(cube.name, file.fileName); | ||
| cubes.push(cube.name); | ||
| cubeMap.set(cube.name, { | ||
| measures: (cube.measures ?? []).map((m) => m.name).filter(Boolean), | ||
| dimensions: (cube.dimensions ?? []).map((d) => d.name).filter(Boolean), | ||
| segments: (cube.segments ?? []).map((s) => s.name).filter(Boolean) | ||
| }); | ||
| } | ||
| } | ||
| for (const view of parsed.views ?? []) if (view.name) { | ||
| const existing = allNames.get(view.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${view.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(view.name, file.fileName); | ||
| views.push(view.name); | ||
| } | ||
| } | ||
| } | ||
| if (errors.length === 0) errors.push(...checkViewMemberConflicts(parsedFiles, cubeMap)); | ||
| return { | ||
| errors, | ||
| cubes, | ||
| views | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/validate.ts | ||
| function collectYamlFiles(dir, rootDir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push({ | ||
| fileName: path.relative(rootDir, fullPath), | ||
| content: fs.readFileSync(fullPath, "utf-8") | ||
| }); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| function checkMissingDescriptions(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| const cubes = parsed.cubes || []; | ||
| for (const cube of cubes) { | ||
| if (!cube.name) continue; | ||
| if (!cube.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "cube", | ||
| name: cube.name | ||
| }); | ||
| const measures = cube.measures || []; | ||
| for (const measure of measures) if (measure.name && !measure.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "measure", | ||
| name: measure.name | ||
| }); | ||
| const dimensions = cube.dimensions || []; | ||
| for (const dimension of dimensions) if (dimension.name && !dimension.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "dimension", | ||
| name: dimension.name | ||
| }); | ||
| } | ||
| const views = parsed.views || []; | ||
| for (const view of views) { | ||
| if (!view.name) continue; | ||
| if (!view.description) missing.push({ | ||
| parent: view.name, | ||
| type: "view", | ||
| name: view.name | ||
| }); | ||
| } | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkSuspectPrimaryKeys(files) { | ||
| const suspects = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) { | ||
| if (!cube.name) continue; | ||
| for (const dim of cube.dimensions || []) if (dim.primary_key && dim.type === "time") suspects.push({ | ||
| cube: cube.name, | ||
| dimension: dim.name, | ||
| type: dim.type | ||
| }); | ||
| } | ||
| } catch {} | ||
| return suspects; | ||
| } | ||
| function checkMissingDataSource(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) if (cube.name && !cube.data_source) missing.push(cube.name); | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkUnjoinedViewCubes(files) { | ||
| const results = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const view of parsed.views || []) { | ||
| if (!view.name || !view.cubes || view.cubes.length < 2) continue; | ||
| const roots = /* @__PURE__ */ new Set(); | ||
| for (const cubeRef of view.cubes) { | ||
| if (!cubeRef.join_path) continue; | ||
| const firstSegment = cubeRef.join_path.split(".")[0]; | ||
| roots.add(firstSegment); | ||
| } | ||
| if (roots.size > 1) results.push({ | ||
| view: view.name, | ||
| roots: [...roots] | ||
| }); | ||
| } | ||
| } catch {} | ||
| return results; | ||
| } | ||
| async function validate(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const files = [...collectYamlFiles(paths.cubes, projectPath), ...collectYamlFiles(paths.views, projectPath)]; | ||
| if (files.length === 0) return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| const suspectPrimaryKeys = checkSuspectPrimaryKeys(files); | ||
| const unjoinedViewCubes = checkUnjoinedViewCubes(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource, | ||
| suspectPrimaryKeys, | ||
| unjoinedViewCubes | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
| # AI Agent Tools | ||
| > Give your AI agents direct access to your semantic layer — pre-built tools for Vercel AI SDK, LangChain, and any framework that accepts Zod schemas. | ||
| Instead of writing boilerplate tool definitions for each AI framework, import `createTools` from the SDK and get four production-ready tools that handle schema discovery, querying, SQL execution, and field metadata. | ||
| ```typescript | ||
| import { createClient } from '@bonnard/sdk'; | ||
| import { createTools } from '@bonnard/sdk/ai/vercel'; | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); | ||
| const result = await generateText({ | ||
| model: anthropic('claude-sonnet-4-5-20250514'), | ||
| tools, | ||
| prompt: 'What were our top 5 products by revenue last quarter?', | ||
| }); | ||
| ``` | ||
| ## Install | ||
| The AI tools are included in `@bonnard/sdk` — no extra packages. You only need your AI framework as a peer dependency: | ||
| ```bash | ||
| # Vercel AI SDK | ||
| npm install @bonnard/sdk ai @ai-sdk/anthropic | ||
| # LangChain / LangGraph | ||
| npm install @bonnard/sdk @langchain/core @langchain/anthropic @langchain/langgraph | ||
| ``` | ||
| ## Framework adapters | ||
| ### Vercel AI SDK | ||
| ```typescript | ||
| import { createClient } from '@bonnard/sdk'; | ||
| import { createTools } from '@bonnard/sdk/ai/vercel'; | ||
| import { generateText } from 'ai'; | ||
| import { anthropic } from '@ai-sdk/anthropic'; | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); | ||
| // tools is Record<string, CoreTool> — spread with your own tools | ||
| const result = await generateText({ | ||
| model: anthropic('claude-sonnet-4-5-20250514'), | ||
| tools: { ...tools, ...myOtherTools }, | ||
| maxSteps: 10, | ||
| prompt: 'Show me revenue by region for the last 6 months', | ||
| }); | ||
| ``` | ||
| ### LangChain / LangGraph | ||
| ```typescript | ||
| import { createClient } from '@bonnard/sdk'; | ||
| import { createTools } from '@bonnard/sdk/ai/langchain'; | ||
| import { ChatAnthropic } from '@langchain/anthropic'; | ||
| import { createReactAgent } from '@langchain/langgraph/prebuilt'; | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); | ||
| // tools is DynamicStructuredTool[] — spread with your own tools | ||
| const agent = createReactAgent({ | ||
| llm: new ChatAnthropic({ model: 'claude-sonnet-4-5-20250514' }), | ||
| tools: [...tools, ...myOtherTools], | ||
| }); | ||
| const result = await agent.invoke({ | ||
| messages: [{ role: 'user', content: 'What data sources are available?' }], | ||
| }); | ||
| ``` | ||
| ### Framework-agnostic | ||
| If your framework isn't listed above, import the base tools and wrap them yourself. Each tool has a `name`, `description`, `schema` (Zod), and `execute` function: | ||
| ```typescript | ||
| import { createClient } from '@bonnard/sdk'; | ||
| import { createTools } from '@bonnard/sdk/ai'; | ||
| import type { BonnardTool } from '@bonnard/sdk/ai'; | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); // BonnardTool[] | ||
| // Wrap for your framework | ||
| for (const tool of tools) { | ||
| console.log(tool.name); // "explore_schema" | ||
| console.log(tool.description); // "Discover available data sources..." | ||
| console.log(tool.schema); // Zod schema — convert to JSON Schema with zod-to-json-schema | ||
| await tool.execute({ ... }); // Call directly | ||
| } | ||
| ``` | ||
| This also works for MCP servers — convert the Zod schemas to JSON Schema with `zod-to-json-schema`. | ||
| ## Tools included | ||
| Every `createTools(client)` call returns four tools: | ||
| ### explore_schema | ||
| Discover available data sources, their measures, dimensions, and segments. | ||
| | Parameter | Type | Description | | ||
| |-----------|------|-------------| | ||
| | `name` | `string?` | Source name to get full field listings (e.g. `"orders"`) | | ||
| | `search` | `string?` | Keyword to search across all field names and descriptions | | ||
| ```typescript | ||
| // No args — list all sources with summary counts | ||
| await tools.explore_schema.execute({}); | ||
| // → [{ name: "orders", type: "view", measures: 5, dimensions: 8, segments: 1 }] | ||
| // By name — full field details for one source | ||
| await tools.explore_schema.execute({ name: "orders" }); | ||
| // → { name: "orders", measures: [...], dimensions: [...], segments: [...] } | ||
| // Search — find fields by keyword across all sources | ||
| await tools.explore_schema.execute({ search: "revenue" }); | ||
| // → [{ source: "orders", field: "orders.revenue", kind: "measure", type: "number" }] | ||
| ``` | ||
| ### query | ||
| Run structured queries with measures, dimensions, filters, and time dimensions. All field names must be fully qualified (e.g. `"orders.revenue"`). | ||
| | Parameter | Type | Description | | ||
| |-----------|------|-------------| | ||
| | `measures` | `string[]?` | Measures to aggregate (e.g. `["orders.revenue"]`) | | ||
| | `dimensions` | `string[]?` | Dimensions to group by | | ||
| | `timeDimensions` | `array?` | Time dimensions with `dimension`, `granularity`, `dateRange` | | ||
| | `filters` | `array?` | Filters with `member`, `operator`, `values` | | ||
| | `segments` | `string[]?` | Pre-defined filter segments | | ||
| | `order` | `object?` | Sort order (e.g. `{ "orders.revenue": "desc" }`) | | ||
| | `limit` | `number?` | Max rows (default 250) | | ||
| | `offset` | `number?` | Pagination offset | | ||
| ### sql_query | ||
| Execute raw SQL when structured queries can't express what you need — CTEs, UNIONs, custom arithmetic. | ||
| | Parameter | Type | Description | | ||
| |-----------|------|-------------| | ||
| | `sql` | `string` | SQL using Cube syntax with `MEASURE()` for aggregations | | ||
| ### describe_field | ||
| Get metadata for a specific field — its type, description, and which source it belongs to. | ||
| | Parameter | Type | Description | | ||
| |-----------|------|-------------| | ||
| | `field` | `string` | Fully qualified field name (e.g. `"orders.revenue"`) | | ||
| ## Authentication for agents | ||
| The tools use whatever auth you configure on the client. All three patterns work: | ||
| ### Simple: publishable key | ||
| Best for internal tools or prototypes — all queries run with your org's full access. | ||
| ```typescript | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); | ||
| ``` | ||
| ### Multi-tenant: token exchange | ||
| For B2B apps where each customer's agent should only see their own data. Your backend exchanges a secret key for a scoped token: | ||
| ```typescript | ||
| // Server-side: create client with token exchange | ||
| const bon = createClient({ | ||
| fetchToken: async () => { | ||
| const res = await fetch('https://app.bonnard.dev/api/sdk/token', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Authorization': `Bearer ${process.env.BONNARD_SECRET_KEY}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ | ||
| security_context: { tenant_id: currentUser.tenantId }, | ||
| }), | ||
| }); | ||
| const { token } = await res.json(); | ||
| return token; | ||
| }, | ||
| }); | ||
| const tools = createTools(bon); | ||
| // Agent queries are automatically scoped to the tenant's data | ||
| ``` | ||
| See [sdk.authentication](sdk.authentication) for details on token refresh and security context. | ||
| ### Frontend: Clerk session | ||
| For apps where users authenticate through Clerk: | ||
| ```typescript | ||
| import { useAuth } from '@clerk/nextjs'; | ||
| const { getToken } = useAuth(); | ||
| const bon = createClient({ | ||
| fetchToken: () => getToken(), | ||
| }); | ||
| const tools = createTools(bon); | ||
| ``` | ||
| ## Tips for better agent responses | ||
| ### Add a system prompt with context | ||
| The tools give the agent the ability to query, but a good system prompt tells it how to approach your data: | ||
| ```typescript | ||
| const result = await generateText({ | ||
| model: anthropic('claude-sonnet-4-5-20250514'), | ||
| tools, | ||
| maxSteps: 10, | ||
| system: `You are a data analyst. Always start by calling explore_schema to understand | ||
| what data is available before querying. Use describe_field to understand metrics | ||
| before presenting results. Format numbers with appropriate units.`, | ||
| prompt: userMessage, | ||
| }); | ||
| ``` | ||
| ### Set maxSteps high enough | ||
| Agents typically need 3–5 steps: explore schema → understand fields → query → maybe refine. Set `maxSteps: 10` to give the agent room to work. | ||
| ### Combine with your own tools | ||
| The adapters return spreadable formats so you can mix Bonnard tools with your own: | ||
| ```typescript | ||
| // Vercel AI SDK — Record<string, CoreTool> | ||
| const result = await generateText({ | ||
| tools: { ...bonnardTools, saveReport, sendEmail }, | ||
| }); | ||
| // LangChain — StructuredTool[] | ||
| const agent = createReactAgent({ | ||
| tools: [...bonnardTools, saveReport, sendEmail], | ||
| }); | ||
| ``` | ||
| ## See also | ||
| - [sdk](sdk) — SDK overview and installation | ||
| - [sdk.authentication](sdk.authentication) — Auth patterns (publishable keys, token exchange) | ||
| - [sdk.query-reference](sdk.query-reference) — Full query API reference | ||
| - [mcp](mcp) — MCP server setup (alternative to SDK tools for Claude, ChatGPT, etc.) | ||
| - [access-control.security-context](access-control.security-context) — Row-level security for multi-tenant apps |
| # ============================================================ | ||
| # DATA SOURCE — configure for your database | ||
| # ============================================================ | ||
| # DuckDB example: | ||
| CUBEJS_DB_TYPE=duckdb | ||
| CUBEJS_DB_DUCKDB_DATABASE_PATH=/data/my-database.duckdb | ||
| # Postgres example (uncomment and replace DuckDB lines above): | ||
| # CUBEJS_DB_TYPE=postgres | ||
| # CUBEJS_DB_HOST=host.docker.internal | ||
| # CUBEJS_DB_PORT=5432 | ||
| # CUBEJS_DB_NAME=mydb | ||
| # CUBEJS_DB_USER=myuser | ||
| # CUBEJS_DB_PASS=mypassword | ||
| # Multi-datasource example (uncomment): | ||
| # If your cubes use `data_source: my_source`, list all sources here | ||
| # including "default". Cube maps my_source to CUBEJS_DS_MY_SOURCE_* vars. | ||
| # CUBEJS_DATASOURCES=default,my_source | ||
| # CUBEJS_DS_MY_SOURCE_DB_TYPE=postgres | ||
| # CUBEJS_DS_MY_SOURCE_DB_HOST=host.docker.internal | ||
| # CUBEJS_DS_MY_SOURCE_DB_PORT=5432 | ||
| # CUBEJS_DS_MY_SOURCE_DB_NAME=mydb | ||
| # CUBEJS_DS_MY_SOURCE_DB_USER=myuser | ||
| # CUBEJS_DS_MY_SOURCE_DB_PASS=mypassword | ||
| # ============================================================ | ||
| # CUBE CONFIGURATION | ||
| # ============================================================ | ||
| # HS256 secret for signing Cube JWTs (auto-generated). | ||
| # Both Cube and Bonnard use this for authenticated communication. | ||
| CUBEJS_API_SECRET=REPLACE_ME | ||
| # ============================================================ | ||
| # BONNARD CONFIGURATION | ||
| # ============================================================ | ||
| # Optional: protect the admin UI and API with a bearer token. | ||
| # ADMIN_TOKEN= | ||
| # Optional: custom ports (defaults: Cube=4000, Bonnard=3000). | ||
| # CUBE_PORT=4000 | ||
| # BONNARD_PORT=3000 | ||
| # ============================================================ | ||
| # PRODUCTION CONFIGURATION (optional) | ||
| # ============================================================ | ||
| # CORS: allowed origins (* = any, or a specific URL like https://myapp.com). | ||
| # CORS_ORIGIN=* | ||
| # Pin Cube/Bonnard image versions (defaults: CUBE_VERSION=v1.6, BONNARD_VERSION=latest). | ||
| # CUBE_VERSION=v1.6 | ||
| # BONNARD_VERSION=latest |
| // Cube configuration for self-hosted Bonnard. | ||
| // Model files are loaded from the shared Docker volume. | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const MODEL_DIR = process.env.CUBEJS_SCHEMA_PATH || "/cube/conf/model"; | ||
| const VERSION_FILE = path.join(MODEL_DIR, ".version"); | ||
| module.exports = { | ||
| // Check for model changes every 30 seconds. | ||
| // When schemaVersion changes, Cube recompiles all models. | ||
| scheduledRefreshTimer: 30, | ||
| schemaVersion: () => { | ||
| try { | ||
| return fs.readFileSync(VERSION_FILE, "utf-8").trim(); | ||
| } catch { | ||
| return "initial"; | ||
| } | ||
| }, | ||
| }; |
| volumes: | ||
| models: {} | ||
| services: | ||
| cubestore: | ||
| image: cubejs/cubestore:${CUBE_VERSION:-v1.6} | ||
| restart: unless-stopped | ||
| cube: | ||
| image: cubejs/cube:${CUBE_VERSION:-v1.6} | ||
| restart: unless-stopped | ||
| ports: | ||
| - "${CUBE_PORT:-4000}:4000" | ||
| volumes: | ||
| - models:/cube/conf/model:ro | ||
| - ./cube.js:/cube/conf/cube.js:ro | ||
| - ./data:/data | ||
| env_file: .env | ||
| environment: | ||
| CUBEJS_CUBESTORE_HOST: cubestore | ||
| CUBEJS_SQL_PORT: "15432" | ||
| depends_on: | ||
| cubestore: | ||
| condition: service_started | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "node -e \"fetch('http://localhost:4000/livez').then(()=>process.exit(0)).catch(()=>process.exit(1))\""] | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 15 | ||
| start_period: 30s | ||
| bonnard: | ||
| image: ghcr.io/bonnard-data/bonnard:${BONNARD_VERSION:-latest} | ||
| restart: unless-stopped | ||
| ports: | ||
| - "${BONNARD_PORT:-3000}:3000" | ||
| volumes: | ||
| - models:/app/models | ||
| environment: | ||
| CUBE_API_URL: http://cube:4000 | ||
| CUBE_API_SECRET: ${CUBEJS_API_SECRET:-} | ||
| ADMIN_TOKEN: ${ADMIN_TOKEN:-} | ||
| CORS_ORIGIN: ${CORS_ORIGIN:-*} | ||
| MODEL_DIR: /app/models | ||
| depends_on: | ||
| cube: | ||
| condition: service_healthy |
| # Bonnard (Self-Hosted) | ||
| Self-hosted semantic layer for AI agents. Full documentation at [github.com/bonnard-data/bonnard](https://github.com/bonnard-data/bonnard). | ||
| ## Quick Start | ||
| ```bash | ||
| # 1. Configure your data source | ||
| # Edit .env with your database credentials | ||
| # 2. Start the server | ||
| docker compose up -d | ||
| # 3. Define your semantic layer | ||
| # Add cube/view YAML files to bonnard/cubes/ and bonnard/views/ | ||
| # 4. Deploy models to the server | ||
| bon deploy | ||
| # 5. Verify your semantic layer | ||
| bon schema | ||
| # 6. Connect AI agents | ||
| bon mcp | ||
| ``` | ||
| ## Connecting AI Agents | ||
| Run `bon mcp` to see connection config for your setup. Examples below. | ||
| ### Claude Desktop / Cursor | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "bonnard": { | ||
| "url": "https://bonnard.example.com/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer your-secret-token-here" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### Claude Code | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "bonnard": { | ||
| "type": "url", | ||
| "url": "https://bonnard.example.com/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer your-secret-token-here" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### CrewAI (Python) | ||
| ```python | ||
| from crewai import MCPServerAdapter | ||
| mcp = MCPServerAdapter( | ||
| url="https://bonnard.example.com/mcp", | ||
| transport="streamable-http", | ||
| headers={"Authorization": "Bearer your-secret-token-here"} | ||
| ) | ||
| ``` | ||
| ## Authentication | ||
| Protect your endpoints by setting `ADMIN_TOKEN` in `.env`: | ||
| ```env | ||
| ADMIN_TOKEN=your-secret-token-here | ||
| ``` | ||
| All API and MCP endpoints will require `Authorization: Bearer <token>`. The `/health` endpoint remains open for monitoring. | ||
| Restart after changing `.env`: | ||
| ```bash | ||
| docker compose up -d | ||
| ``` | ||
| ## TLS with Caddy | ||
| [Caddy](https://caddyserver.com) provides automatic HTTPS via Let's Encrypt. | ||
| Create a `Caddyfile` next to your `docker-compose.yml`: | ||
| ``` | ||
| bonnard.example.com { | ||
| reverse_proxy localhost:3000 | ||
| } | ||
| ``` | ||
| Add Caddy to your `docker-compose.yml`: | ||
| ```yaml | ||
| caddy: | ||
| image: caddy:2 | ||
| ports: | ||
| - "80:80" | ||
| - "443:443" | ||
| volumes: | ||
| - ./Caddyfile:/etc/caddy/Caddyfile:ro | ||
| - caddy_data:/data | ||
| restart: unless-stopped | ||
| ``` | ||
| Add the volume at the top level: | ||
| ```yaml | ||
| volumes: | ||
| models: {} | ||
| caddy_data: {} | ||
| ``` | ||
| Then remove the Bonnard port mapping (`ports: - "3000:3000"`) since Caddy handles external traffic. | ||
| ## Deploy to a VM | ||
| ```bash | ||
| scp -r . user@your-server:~/bonnard/ | ||
| ssh user@your-server | ||
| cd ~/bonnard | ||
| docker compose up -d | ||
| ``` | ||
| ## Configuration | ||
| | Variable | Description | Default | | ||
| |----------|-------------|---------| | ||
| | `CUBEJS_DB_TYPE` | Database driver (`postgres`, `duckdb`, `snowflake`, `bigquery`, `databricks`, `redshift`, `clickhouse`) | `duckdb` | | ||
| | `CUBEJS_DB_*` | Database connection settings (host, port, name, user, pass) | — | | ||
| | `CUBEJS_API_SECRET` | HS256 secret for Cube JWT auth (auto-generated) | — | | ||
| | `ADMIN_TOKEN` | Bearer token for API/MCP authentication | — (open) | | ||
| | `CUBE_PORT` | Cube API port | `4000` | | ||
| | `BONNARD_PORT` | Bonnard server port | `3000` | | ||
| | `CORS_ORIGIN` | Allowed CORS origins | `*` | | ||
| | `CUBE_VERSION` | Cube Docker image tag | `v1.6` | | ||
| | `BONNARD_VERSION` | Bonnard Docker image tag | `latest` | | ||
| ## Monitoring | ||
| ```bash | ||
| # Health check | ||
| curl http://localhost:3000/health | ||
| # View logs | ||
| docker compose logs -f | ||
| # View active MCP sessions | ||
| curl -H "Authorization: Bearer <token>" http://localhost:3000/api/mcp/sessions | ||
| ``` | ||
| ## Deploying Schema Updates | ||
| ```bash | ||
| bon deploy | ||
| ``` | ||
| Pushes cube/view YAML files to the running server. No restart needed — Cube picks up changes automatically. |
@@ -7,2 +7,32 @@ # SDK | ||
| ## Before you start | ||
| The SDK queries your **deployed** semantic layer — you need these in place first: | ||
| 1. **A deployed semantic layer** — at least one cube and view, deployed with `bon deploy`. See [getting-started](getting-started) if you haven't done this yet. | ||
| 2. **A publishable key** — go to **Settings > API Keys** in the Bonnard dashboard and create one. It starts with `bon_pk_...`. | ||
| Once you have a key, use `explore()` to discover what's available before writing your first query: | ||
| ```typescript | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| // Step 1: see what views exist | ||
| const meta = await bon.explore(); | ||
| console.log(meta.cubes.map(v => v.name)); // ['orders', 'customers', ...] | ||
| // Step 2: see what fields a view has | ||
| const orders = meta.cubes.find(v => v.name === 'orders'); | ||
| console.log(orders.measures.map(m => m.name)); // ['orders.revenue', 'orders.count'] | ||
| console.log(orders.dimensions.map(d => d.name)); // ['orders.city', 'orders.status'] | ||
| // Step 3: query using those field names | ||
| const { data } = await bon.query({ | ||
| measures: ['orders.revenue'], | ||
| dimensions: ['orders.city'], | ||
| }); | ||
| ``` | ||
| **Reading order for new SDK users:** this page → [sdk.authentication](sdk.authentication) → [sdk.query-reference](sdk.query-reference) → then whichever integration guide fits your use case (React, browser, AI agents, chart libraries). | ||
| ## Two ways to use it | ||
@@ -75,2 +105,17 @@ | ||
| ## Building AI agents | ||
| Give your AI agents direct access to your semantic layer. Import `createTools` with a framework adapter and get four production-ready tools — schema discovery, querying, SQL, and field metadata. | ||
| ```typescript | ||
| import { createClient } from '@bonnard/sdk'; | ||
| import { createTools } from '@bonnard/sdk/ai/vercel'; | ||
| const bon = createClient({ apiKey: 'bon_pk_...' }); | ||
| const tools = createTools(bon); | ||
| // → { explore_schema, query, sql_query, describe_field } | ||
| ``` | ||
| Adapters for Vercel AI SDK and LangChain/LangGraph included. See [sdk.ai-agents](sdk.ai-agents) for the full guide. | ||
| ## Building with React | ||
@@ -104,2 +149,3 @@ | ||
| - [sdk.ai-agents](sdk.ai-agents) — AI agent tools (Vercel AI, LangChain, framework-agnostic) | ||
| - [sdk.react](sdk.react) — React components (BigValue, charts, DataTable, hooks) | ||
@@ -106,0 +152,0 @@ - [sdk.browser](sdk.browser) — Browser / CDN quickstart |
+1
-1
| MIT License | ||
| Copyright (c) 2025-present Bonnard (meal-inc) | ||
| Copyright (c) 2025-present Bonnard (bonnard-data) | ||
@@ -5,0 +5,0 @@ Permission is hereby granted, free of charge, to any person obtaining a copy |
+3
-3
| { | ||
| "name": "@bonnard/cli", | ||
| "version": "0.3.11", | ||
| "version": "0.3.12", | ||
| "type": "module", | ||
@@ -27,3 +27,3 @@ "bin": { | ||
| "@bonnard/react": "^0.4.5", | ||
| "@bonnard/sdk": "^0.4.2", | ||
| "@bonnard/sdk": "^0.4.3", | ||
| "@types/node": "^20.0.0", | ||
@@ -43,3 +43,3 @@ "@types/react": "^19.0.0", | ||
| "type": "git", | ||
| "url": "https://github.com/meal-inc/bonnard-cli.git" | ||
| "url": "https://github.com/bonnard-data/bonnard-cli.git" | ||
| }, | ||
@@ -46,0 +46,0 @@ "license": "MIT", |
+2
-2
@@ -17,3 +17,3 @@ <p align="center"> | ||
| <a href="https://www.npmjs.com/package/@bonnard/cli"><img src="https://img.shields.io/npm/v/@bonnard/cli?style=flat-square&color=0891b2" alt="npm version" /></a> | ||
| <a href="https://github.com/meal-inc/bonnard-cli/blob/main/LICENSE"><img src="https://img.shields.io/github/license/meal-inc/bonnard-cli?style=flat-square" alt="MIT License" /></a> | ||
| <a href="https://github.com/bonnard-data/bonnard-cli/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bonnard-data/bonnard-cli?style=flat-square" alt="MIT License" /></a> | ||
| <a href="https://discord.com/invite/RQuvjGRz"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a> | ||
@@ -131,3 +131,3 @@ </p> | ||
| - [Discord](https://discord.com/invite/RQuvjGRz): ask questions, share feedback, connect with the team | ||
| - [GitHub Issues](https://github.com/meal-inc/bonnard-cli/issues): bug reports and feature requests | ||
| - [GitHub Issues](https://github.com/bonnard-data/bonnard-cli/issues): bug reports and feature requests | ||
| - [LinkedIn](https://www.linkedin.com/company/bonnarddev/): follow for updates | ||
@@ -134,0 +134,0 @@ - [Website](https://www.bonnard.dev): learn more about Bonnard |
| import { createRequire } from "node:module"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import os from "node:os"; | ||
| import pc from "picocolors"; | ||
| //#region src/lib/credentials.ts | ||
| const CREDENTIALS_DIR = path.join(os.homedir(), ".config", "bon"); | ||
| const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); | ||
| function saveCredentials(credentials) { | ||
| fs.mkdirSync(CREDENTIALS_DIR, { | ||
| recursive: true, | ||
| mode: 448 | ||
| }); | ||
| fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 384 }); | ||
| } | ||
| function loadCredentials() { | ||
| try { | ||
| const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.token && parsed.email) return parsed; | ||
| return null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function clearCredentials() { | ||
| try { | ||
| fs.unlinkSync(CREDENTIALS_FILE); | ||
| } catch {} | ||
| } | ||
| //#endregion | ||
| //#region src/lib/api.ts | ||
| const { version } = createRequire(import.meta.url)("../../package.json"); | ||
| const USER_AGENT = `bon-cli/${version} node-${process.version} ${os.platform()} (${os.arch()})`; | ||
| const APP_URL = process.env.BON_APP_URL || "https://app.bonnard.dev"; | ||
| const VERCEL_BYPASS = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; | ||
| function getToken() { | ||
| const creds = loadCredentials(); | ||
| if (!creds) { | ||
| console.error(pc.red("Not logged in. Run `bon login` first.")); | ||
| process.exit(1); | ||
| } | ||
| return creds.token; | ||
| } | ||
| async function request(method, path, body) { | ||
| const token = getToken(); | ||
| const url = `${APP_URL}${path}`; | ||
| const headers = { | ||
| Authorization: `Bearer ${token}`, | ||
| "Content-Type": "application/json", | ||
| "User-Agent": USER_AGENT | ||
| }; | ||
| if (VERCEL_BYPASS) headers["x-vercel-protection-bypass"] = VERCEL_BYPASS; | ||
| const res = await fetch(url, { | ||
| method, | ||
| headers, | ||
| body: body ? JSON.stringify(body) : void 0, | ||
| signal: AbortSignal.timeout(3e4) | ||
| }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { | ||
| data = JSON.parse(text); | ||
| } catch { | ||
| data = {}; | ||
| } | ||
| if (!res.ok) { | ||
| const message = data.error || `HTTP ${res.status}: ${res.statusText}`; | ||
| throw new Error(message); | ||
| } | ||
| return data; | ||
| } | ||
| function get(path) { | ||
| return request("GET", path); | ||
| } | ||
| function post(path, body) { | ||
| return request("POST", path, body); | ||
| } | ||
| function put(path, body) { | ||
| return request("PUT", path, body); | ||
| } | ||
| function del(path) { | ||
| return request("DELETE", path); | ||
| } | ||
| //#endregion | ||
| export { clearCredentials as a, put as i, get as n, loadCredentials as o, post as r, saveCredentials as s, del as t }; |
| import { i as put, n as get, r as post, t as del } from "./api-BZ9eHZdy.mjs"; | ||
| export { del, get }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| //#region src/lib/cubes/datasources.ts | ||
| /** | ||
| * Extract datasource references from cube and view files | ||
| */ | ||
| /** | ||
| * Collect all YAML files from a directory recursively | ||
| */ | ||
| function collectYamlFiles(dir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push(fullPath); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| /** | ||
| * Parse a single model file and extract datasource references | ||
| */ | ||
| function extractFromFile(filePath) { | ||
| const datasourceToCubes = /* @__PURE__ */ new Map(); | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| const parsed = YAML.parse(content); | ||
| if (!parsed) return datasourceToCubes; | ||
| if (parsed.cubes) for (const cube of parsed.cubes) { | ||
| const ds = cube.data_source || "default"; | ||
| const existing = datasourceToCubes.get(ds) || []; | ||
| existing.push(cube.name); | ||
| datasourceToCubes.set(ds, existing); | ||
| } | ||
| if (parsed.views) { | ||
| for (const view of parsed.views) if (view.data_source) { | ||
| const existing = datasourceToCubes.get(view.data_source) || []; | ||
| existing.push(view.name); | ||
| datasourceToCubes.set(view.data_source, existing); | ||
| } | ||
| } | ||
| } catch {} | ||
| return datasourceToCubes; | ||
| } | ||
| /** | ||
| * Extract all unique datasource references from bonnard/cubes/ and bonnard/views/ directories | ||
| * Returns datasource names mapped to the cubes that use them | ||
| */ | ||
| function extractDatasourcesFromCubes(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const cubesDir = paths.cubes; | ||
| const viewsDir = paths.views; | ||
| const allFiles = [...collectYamlFiles(cubesDir), ...collectYamlFiles(viewsDir)]; | ||
| const aggregated = /* @__PURE__ */ new Map(); | ||
| for (const file of allFiles) { | ||
| const fileRefs = extractFromFile(file); | ||
| for (const [ds, cubes] of fileRefs) { | ||
| const existing = aggregated.get(ds) || []; | ||
| existing.push(...cubes); | ||
| aggregated.set(ds, existing); | ||
| } | ||
| } | ||
| const results = []; | ||
| for (const [name, cubes] of aggregated) if (name !== "default") results.push({ | ||
| name, | ||
| cubes | ||
| }); | ||
| return results; | ||
| } | ||
| //#endregion | ||
| export { extractDatasourcesFromCubes }; |
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials, i as ensureBonDir, l as saveLocalDatasources, n as addLocalDatasource, o as loadLocalDatasources, r as datasourceExists, s as removeLocalDatasource, t as isDatasourcesTrackedByGit } from "./local-ByvuW3eV.mjs"; | ||
| export { loadLocalDatasources }; |
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { execFileSync } from "node:child_process"; | ||
| //#region src/lib/local/datasources.ts | ||
| /** | ||
| * Local datasource storage (.bon/datasources.yaml) | ||
| * | ||
| * Single file containing both config and credentials. | ||
| * Credentials may contain: | ||
| * - Plain values: "my_password" | ||
| * - dbt env var syntax: "{{ env_var('MY_PASSWORD') }}" | ||
| * | ||
| * Env vars are resolved at deploy time, not import time. | ||
| */ | ||
| const BON_DIR$1 = ".bon"; | ||
| const DATASOURCES_FILE$1 = "datasources.yaml"; | ||
| function getBonDir(cwd = process.cwd()) { | ||
| return path.join(cwd, BON_DIR$1); | ||
| } | ||
| function getDatasourcesPath$1(cwd = process.cwd()) { | ||
| return path.join(getBonDir(cwd), DATASOURCES_FILE$1); | ||
| } | ||
| /** | ||
| * Ensure .bon directory exists | ||
| */ | ||
| function ensureBonDir(cwd = process.cwd()) { | ||
| const bonDir = getBonDir(cwd); | ||
| if (!fs.existsSync(bonDir)) fs.mkdirSync(bonDir, { recursive: true }); | ||
| } | ||
| /** | ||
| * Load all local datasources | ||
| */ | ||
| function loadLocalDatasources(cwd = process.cwd()) { | ||
| const filePath = getDatasourcesPath$1(cwd); | ||
| if (!fs.existsSync(filePath)) return []; | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| return YAML.parse(content)?.datasources ?? []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** | ||
| * Save all local datasources (with secure permissions since it contains credentials) | ||
| */ | ||
| function saveLocalDatasources(datasources, cwd = process.cwd()) { | ||
| ensureBonDir(cwd); | ||
| const filePath = getDatasourcesPath$1(cwd); | ||
| const file = { datasources }; | ||
| const content = `# Bonnard datasources configuration | ||
| # This file contains credentials - add to .gitignore | ||
| # Env vars like {{ env_var('PASSWORD') }} are resolved at deploy time | ||
| ` + YAML.stringify(file, { indent: 2 }); | ||
| fs.writeFileSync(filePath, content, { mode: 384 }); | ||
| } | ||
| /** | ||
| * Add a single datasource (updates existing or appends new) | ||
| */ | ||
| function addLocalDatasource(datasource, cwd = process.cwd()) { | ||
| const existing = loadLocalDatasources(cwd); | ||
| const index = existing.findIndex((ds) => ds.name === datasource.name); | ||
| if (index >= 0) existing[index] = datasource; | ||
| else existing.push(datasource); | ||
| saveLocalDatasources(existing, cwd); | ||
| } | ||
| /** | ||
| * Remove a datasource by name | ||
| */ | ||
| function removeLocalDatasource(name, cwd = process.cwd()) { | ||
| const existing = loadLocalDatasources(cwd); | ||
| const filtered = existing.filter((ds) => ds.name !== name); | ||
| if (filtered.length === existing.length) return false; | ||
| saveLocalDatasources(filtered, cwd); | ||
| return true; | ||
| } | ||
| /** | ||
| * Get a single datasource by name | ||
| */ | ||
| function getLocalDatasource(name, cwd = process.cwd()) { | ||
| return loadLocalDatasources(cwd).find((ds) => ds.name === name) ?? null; | ||
| } | ||
| /** | ||
| * Check if a datasource name already exists locally | ||
| */ | ||
| function datasourceExists(name, cwd = process.cwd()) { | ||
| return getLocalDatasource(name, cwd) !== null; | ||
| } | ||
| /** | ||
| * Resolve {{ env_var('VAR_NAME') }} patterns in credentials | ||
| * Used at deploy time to resolve env vars before uploading | ||
| */ | ||
| function resolveEnvVarsInCredentials(credentials) { | ||
| const resolved = {}; | ||
| const missing = []; | ||
| const envVarPattern = /\{\{\s*env_var\(['"]([\w_]+)['"]\)\s*\}\}/; | ||
| for (const [key, value] of Object.entries(credentials)) { | ||
| const match = value.match(envVarPattern); | ||
| if (match) { | ||
| const varName = match[1]; | ||
| const envValue = process.env[varName]; | ||
| if (envValue !== void 0) resolved[key] = envValue; | ||
| else { | ||
| missing.push(varName); | ||
| resolved[key] = value; | ||
| } | ||
| } else resolved[key] = value; | ||
| } | ||
| return { | ||
| resolved, | ||
| missing | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/local/credentials.ts | ||
| /** | ||
| * Credential utilities (git tracking check) | ||
| */ | ||
| const BON_DIR = ".bon"; | ||
| const DATASOURCES_FILE = "datasources.yaml"; | ||
| function getDatasourcesPath(cwd = process.cwd()) { | ||
| return path.join(cwd, BON_DIR, DATASOURCES_FILE); | ||
| } | ||
| /** | ||
| * Check if datasources file is tracked by git (it shouldn't be - contains credentials) | ||
| */ | ||
| function isDatasourcesTrackedByGit(cwd = process.cwd()) { | ||
| const filePath = getDatasourcesPath(cwd); | ||
| if (!fs.existsSync(filePath)) return false; | ||
| try { | ||
| execFileSync("git", [ | ||
| "ls-files", | ||
| "--error-unmatch", | ||
| filePath | ||
| ], { | ||
| cwd, | ||
| stdio: "pipe" | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { getLocalDatasource as a, resolveEnvVarsInCredentials as c, ensureBonDir as i, saveLocalDatasources as l, addLocalDatasource as n, loadLocalDatasources as o, datasourceExists as r, removeLocalDatasource as s, isDatasourcesTrackedByGit as t }; |
| import { r as post } from "./api-BZ9eHZdy.mjs"; | ||
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials } from "./local-ByvuW3eV.mjs"; | ||
| import pc from "picocolors"; | ||
| import "@inquirer/prompts"; | ||
| //#region src/commands/datasource/push.ts | ||
| /** | ||
| * Push a datasource programmatically (for use by deploy command) | ||
| * Returns true on success, false on failure | ||
| */ | ||
| async function pushDatasource(name, options = {}) { | ||
| const datasource = getLocalDatasource(name); | ||
| if (!datasource) { | ||
| if (!options.silent) console.error(pc.red(`Datasource "${name}" not found locally`)); | ||
| return false; | ||
| } | ||
| const { resolved, missing } = resolveEnvVarsInCredentials(datasource.credentials); | ||
| if (missing.length > 0) { | ||
| if (!options.silent) console.error(pc.red(`Missing env vars for "${name}": ${missing.join(", ")}`)); | ||
| return false; | ||
| } | ||
| try { | ||
| await post("/api/datasources", { | ||
| name: datasource.name, | ||
| warehouse_type: datasource.type, | ||
| config: datasource.config, | ||
| credentials: resolved | ||
| }); | ||
| return true; | ||
| } catch (err) { | ||
| if (!options.silent) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(pc.red(`Failed to push "${name}": ${message}`)); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { pushDatasource as t }; |
| import "./api-BZ9eHZdy.mjs"; | ||
| import "./local-ByvuW3eV.mjs"; | ||
| import { t as pushDatasource } from "./push-GInMUUa2.mjs"; | ||
| export { pushDatasource }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { z } from "zod"; | ||
| //#region src/lib/schema.ts | ||
| const identifier = z.string().regex(/^[_a-zA-Z][_a-zA-Z0-9]*$/, "must be a valid identifier (letters, numbers, underscores; cannot start with a number)"); | ||
| const refreshKeySchema = z.object({ | ||
| every: z.string().regex(/^\d+\s+(second|minute|hour|day|week)s?$/, { message: "must be a time interval like \"1 hour\", \"30 minute\", \"1 day\"" }).optional(), | ||
| sql: z.string().optional() | ||
| }); | ||
| const measureTypes = [ | ||
| "count", | ||
| "count_distinct", | ||
| "count_distinct_approx", | ||
| "sum", | ||
| "avg", | ||
| "min", | ||
| "max", | ||
| "number", | ||
| "string", | ||
| "time", | ||
| "boolean", | ||
| "running_total", | ||
| "number_agg" | ||
| ]; | ||
| const dimensionTypes = [ | ||
| "string", | ||
| "number", | ||
| "boolean", | ||
| "time", | ||
| "geo", | ||
| "switch" | ||
| ]; | ||
| const relationshipTypes = [ | ||
| "many_to_one", | ||
| "one_to_many", | ||
| "one_to_one" | ||
| ]; | ||
| const granularities = [ | ||
| "second", | ||
| "minute", | ||
| "hour", | ||
| "day", | ||
| "week", | ||
| "month", | ||
| "quarter", | ||
| "year" | ||
| ]; | ||
| const preAggTypes = [ | ||
| "rollup", | ||
| "original_sql", | ||
| "rollup_join", | ||
| "rollup_lambda" | ||
| ]; | ||
| const formats = [ | ||
| "percent", | ||
| "currency", | ||
| "number", | ||
| "imageUrl", | ||
| "link", | ||
| "id" | ||
| ]; | ||
| const measureSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(measureTypes), | ||
| sql: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: z.enum(formats).optional(), | ||
| public: z.boolean().optional(), | ||
| filters: z.array(z.object({ sql: z.string() })).optional(), | ||
| rolling_window: z.object({ | ||
| trailing: z.string().optional(), | ||
| leading: z.string().optional(), | ||
| offset: z.string().optional() | ||
| }).optional(), | ||
| drill_members: z.array(z.string()).optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const dimensionFormatSchema = z.union([z.enum(formats), z.string().startsWith("%")]); | ||
| const dimensionSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(dimensionTypes), | ||
| sql: z.string().optional(), | ||
| primary_key: z.boolean().optional(), | ||
| sub_query: z.boolean().optional(), | ||
| propagate_filters_to_sub_query: z.boolean().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: dimensionFormatSchema.optional(), | ||
| public: z.boolean().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional(), | ||
| latitude: z.object({ sql: z.string() }).optional(), | ||
| longitude: z.object({ sql: z.string() }).optional(), | ||
| case: z.object({ | ||
| when: z.array(z.object({ | ||
| sql: z.string(), | ||
| label: z.string() | ||
| })), | ||
| else: z.object({ label: z.string() }).optional() | ||
| }).optional() | ||
| }); | ||
| const joinSchema = z.object({ | ||
| name: identifier, | ||
| relationship: z.enum(relationshipTypes), | ||
| sql: z.string() | ||
| }); | ||
| const segmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const preAggregationSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(preAggTypes).optional(), | ||
| measures: z.array(z.string()).optional(), | ||
| dimensions: z.array(z.string()).optional(), | ||
| time_dimension: z.string().optional(), | ||
| granularity: z.enum(granularities).optional(), | ||
| partition_granularity: z.enum(granularities).optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| scheduled_refresh: z.boolean().optional() | ||
| }); | ||
| const hierarchySchema = z.object({ | ||
| name: identifier, | ||
| levels: z.array(z.string()), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const cubeSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string().optional(), | ||
| sql_table: z.string().optional(), | ||
| data_source: z.string().optional(), | ||
| extends: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| measures: z.array(measureSchema).optional(), | ||
| dimensions: z.array(dimensionSchema).optional(), | ||
| joins: z.array(joinSchema).optional(), | ||
| segments: z.array(segmentSchema).optional(), | ||
| pre_aggregations: z.array(preAggregationSchema).optional(), | ||
| hierarchies: z.array(hierarchySchema).optional() | ||
| }).refine((data) => data.sql != null || data.sql_table != null || data.extends != null, { message: "sql, sql_table, or extends is required" }); | ||
| const viewIncludeItemSchema = z.union([z.string(), z.object({ | ||
| name: z.string(), | ||
| alias: z.string().optional(), | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| format: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| })]); | ||
| const viewCubeRefSchema = z.object({ | ||
| join_path: z.string(), | ||
| includes: z.union([z.literal("*"), z.array(viewIncludeItemSchema)]).optional(), | ||
| excludes: z.array(z.string()).optional(), | ||
| prefix: z.boolean().optional() | ||
| }); | ||
| const folderSchema = z.lazy(() => z.object({ | ||
| name: z.string(), | ||
| members: z.array(z.string()).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| })); | ||
| const viewMeasureSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| format: z.string().optional(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewDimensionSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewSegmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional() | ||
| }); | ||
| const viewSchema = z.object({ | ||
| name: identifier, | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| cubes: z.array(viewCubeRefSchema).optional(), | ||
| measures: z.array(viewMeasureSchema).optional(), | ||
| dimensions: z.array(viewDimensionSchema).optional(), | ||
| segments: z.array(viewSegmentSchema).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| }); | ||
| const fileSchema = z.object({ | ||
| cubes: z.array(cubeSchema).optional(), | ||
| views: z.array(viewSchema).optional() | ||
| }).refine((data) => data.cubes && data.cubes.length > 0 || data.views && data.views.length > 0, "File must contain at least one cube or view"); | ||
| function formatZodError(error, fileName, parsed) { | ||
| return error.issues.map((issue) => { | ||
| const pathParts = issue.path; | ||
| let entityContext = ""; | ||
| if (pathParts.length >= 2) { | ||
| const collection = pathParts[0]; | ||
| const index = pathParts[1]; | ||
| const entity = parsed?.[collection]?.[index]; | ||
| if (entity?.name) entityContext = ` (${entity.name})`; | ||
| } | ||
| const pathStr = pathParts.join("."); | ||
| const location = pathStr ? `${pathStr}${entityContext}` : ""; | ||
| if (issue.code === "invalid_value" && "values" in issue) return `${fileName}: ${location} — invalid value, expected one of: ${issue.values.join(", ")}`; | ||
| return `${fileName}: ${location ? `${location} — ` : ""}${issue.message}`; | ||
| }); | ||
| } | ||
| function checkViewMemberConflicts(parsedFiles, cubeMap) { | ||
| const errors = []; | ||
| for (const { fileName, parsed } of parsedFiles) for (const view of parsed.views ?? []) { | ||
| if (!view.name || !view.cubes) continue; | ||
| const seen = /* @__PURE__ */ new Map(); | ||
| for (const m of view.measures ?? []) if (m.name) seen.set(m.name, `${view.name} (direct)`); | ||
| for (const d of view.dimensions ?? []) if (d.name) seen.set(d.name, `${view.name} (direct)`); | ||
| for (const s of view.segments ?? []) if (s.name) seen.set(s.name, `${view.name} (direct)`); | ||
| for (const cubeRef of view.cubes) { | ||
| const joinPath = cubeRef.join_path; | ||
| if (!joinPath) continue; | ||
| const segments = joinPath.split("."); | ||
| const targetCubeName = segments[segments.length - 1]; | ||
| let memberNames = []; | ||
| if (cubeRef.includes === "*") { | ||
| const cube = cubeMap.get(targetCubeName); | ||
| if (!cube) continue; | ||
| memberNames = [ | ||
| ...cube.measures, | ||
| ...cube.dimensions, | ||
| ...cube.segments | ||
| ]; | ||
| } else if (Array.isArray(cubeRef.includes)) { | ||
| for (const item of cubeRef.includes) if (typeof item === "string") memberNames.push(item); | ||
| else if (item && typeof item === "object" && item.name) memberNames.push(item.alias || item.name); | ||
| } else continue; | ||
| if (Array.isArray(cubeRef.excludes)) { | ||
| const excludeSet = new Set(cubeRef.excludes); | ||
| memberNames = memberNames.filter((n) => !excludeSet.has(n)); | ||
| } | ||
| for (const rawName of memberNames) { | ||
| const finalName = cubeRef.prefix ? `${targetCubeName}_${rawName}` : rawName; | ||
| const existingSource = seen.get(finalName); | ||
| if (existingSource) errors.push(`${fileName}: view '${view.name}' — member '${finalName}' from '${joinPath}' conflicts with '${existingSource}'. Use prefix: true or an alias.`); | ||
| else seen.set(finalName, joinPath); | ||
| } | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| const parsedFiles = []; | ||
| const cubeMap = /* @__PURE__ */ new Map(); | ||
| for (const file of files) { | ||
| let parsed; | ||
| try { | ||
| parsed = YAML.parse(file.content); | ||
| } catch (err) { | ||
| errors.push(`${file.fileName}: YAML parse error — ${err.message}`); | ||
| continue; | ||
| } | ||
| if (!parsed || typeof parsed !== "object") { | ||
| errors.push(`${file.fileName}: file is empty or not a YAML object`); | ||
| continue; | ||
| } | ||
| const result = fileSchema.safeParse(parsed); | ||
| if (!result.success) { | ||
| errors.push(...formatZodError(result.error, file.fileName, parsed)); | ||
| continue; | ||
| } | ||
| parsedFiles.push({ | ||
| fileName: file.fileName, | ||
| parsed | ||
| }); | ||
| for (const cube of parsed.cubes ?? []) if (cube.name) { | ||
| const existing = allNames.get(cube.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${cube.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(cube.name, file.fileName); | ||
| cubes.push(cube.name); | ||
| cubeMap.set(cube.name, { | ||
| measures: (cube.measures ?? []).map((m) => m.name).filter(Boolean), | ||
| dimensions: (cube.dimensions ?? []).map((d) => d.name).filter(Boolean), | ||
| segments: (cube.segments ?? []).map((s) => s.name).filter(Boolean) | ||
| }); | ||
| } | ||
| } | ||
| for (const view of parsed.views ?? []) if (view.name) { | ||
| const existing = allNames.get(view.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${view.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(view.name, file.fileName); | ||
| views.push(view.name); | ||
| } | ||
| } | ||
| } | ||
| if (errors.length === 0) errors.push(...checkViewMemberConflicts(parsedFiles, cubeMap)); | ||
| return { | ||
| errors, | ||
| cubes, | ||
| views | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/validate.ts | ||
| function collectYamlFiles(dir, rootDir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push({ | ||
| fileName: path.relative(rootDir, fullPath), | ||
| content: fs.readFileSync(fullPath, "utf-8") | ||
| }); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| function checkMissingDescriptions(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| const cubes = parsed.cubes || []; | ||
| for (const cube of cubes) { | ||
| if (!cube.name) continue; | ||
| if (!cube.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "cube", | ||
| name: cube.name | ||
| }); | ||
| const measures = cube.measures || []; | ||
| for (const measure of measures) if (measure.name && !measure.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "measure", | ||
| name: measure.name | ||
| }); | ||
| const dimensions = cube.dimensions || []; | ||
| for (const dimension of dimensions) if (dimension.name && !dimension.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "dimension", | ||
| name: dimension.name | ||
| }); | ||
| } | ||
| const views = parsed.views || []; | ||
| for (const view of views) { | ||
| if (!view.name) continue; | ||
| if (!view.description) missing.push({ | ||
| parent: view.name, | ||
| type: "view", | ||
| name: view.name | ||
| }); | ||
| } | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkSuspectPrimaryKeys(files) { | ||
| const suspects = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) { | ||
| if (!cube.name) continue; | ||
| for (const dim of cube.dimensions || []) if (dim.primary_key && dim.type === "time") suspects.push({ | ||
| cube: cube.name, | ||
| dimension: dim.name, | ||
| type: dim.type | ||
| }); | ||
| } | ||
| } catch {} | ||
| return suspects; | ||
| } | ||
| function checkMissingDataSource(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) if (cube.name && !cube.data_source) missing.push(cube.name); | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkUnjoinedViewCubes(files) { | ||
| const results = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const view of parsed.views || []) { | ||
| if (!view.name || !view.cubes || view.cubes.length < 2) continue; | ||
| const roots = /* @__PURE__ */ new Set(); | ||
| for (const cubeRef of view.cubes) { | ||
| if (!cubeRef.join_path) continue; | ||
| const firstSegment = cubeRef.join_path.split(".")[0]; | ||
| roots.add(firstSegment); | ||
| } | ||
| if (roots.size > 1) results.push({ | ||
| view: view.name, | ||
| roots: [...roots] | ||
| }); | ||
| } | ||
| } catch {} | ||
| return results; | ||
| } | ||
| async function validate(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const files = [...collectYamlFiles(paths.cubes, projectPath), ...collectYamlFiles(paths.views, projectPath)]; | ||
| if (files.length === 0) return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| const suspectPrimaryKeys = checkSuspectPrimaryKeys(files); | ||
| const unjoinedViewCubes = checkUnjoinedViewCubes(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource, | ||
| suspectPrimaryKeys, | ||
| unjoinedViewCubes | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
2403305
1.1%91
8.33%5687
3.8%16
60%5
25%