@bonnard/cli 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/bon.mjs
CHANGED
|
@@ -565,17 +565,22 @@ function createAgentTemplates(cwd, env) {
|
|
|
565
565
|
const claudeSkillsDir = path.join(cwd, ".claude", "skills");
|
|
566
566
|
fs.mkdirSync(claudeRulesDir, { recursive: true });
|
|
567
567
|
fs.mkdirSync(path.join(claudeSkillsDir, "bonnard-get-started"), { recursive: true });
|
|
568
|
+
fs.mkdirSync(path.join(claudeSkillsDir, "bonnard-metabase-migrate"), { recursive: true });
|
|
568
569
|
writeTemplateFile(sharedBonnard, path.join(claudeRulesDir, "bonnard.md"), createdFiles);
|
|
569
570
|
writeTemplateFile(loadTemplate("claude/skills/bonnard-get-started/SKILL.md"), path.join(claudeSkillsDir, "bonnard-get-started", "SKILL.md"), createdFiles);
|
|
571
|
+
writeTemplateFile(loadTemplate("claude/skills/bonnard-metabase-migrate/SKILL.md"), path.join(claudeSkillsDir, "bonnard-metabase-migrate", "SKILL.md"), createdFiles);
|
|
570
572
|
mergeSettingsJson(loadJsonTemplate("claude/settings.json"), path.join(cwd, ".claude", "settings.json"), createdFiles);
|
|
571
573
|
const cursorRulesDir = path.join(cwd, ".cursor", "rules");
|
|
572
574
|
fs.mkdirSync(cursorRulesDir, { recursive: true });
|
|
573
575
|
writeTemplateFile(withCursorFrontmatter(sharedBonnard, "Bonnard semantic layer project context", true), path.join(cursorRulesDir, "bonnard.mdc"), createdFiles);
|
|
574
576
|
writeTemplateFile(loadTemplate("cursor/rules/bonnard-get-started.mdc"), path.join(cursorRulesDir, "bonnard-get-started.mdc"), createdFiles);
|
|
577
|
+
writeTemplateFile(loadTemplate("cursor/rules/bonnard-metabase-migrate.mdc"), path.join(cursorRulesDir, "bonnard-metabase-migrate.mdc"), createdFiles);
|
|
575
578
|
const codexSkillsDir = path.join(cwd, ".agents", "skills");
|
|
576
579
|
fs.mkdirSync(path.join(codexSkillsDir, "bonnard-get-started"), { recursive: true });
|
|
580
|
+
fs.mkdirSync(path.join(codexSkillsDir, "bonnard-metabase-migrate"), { recursive: true });
|
|
577
581
|
writeTemplateFile(sharedBonnard, path.join(cwd, "AGENTS.md"), createdFiles);
|
|
578
582
|
writeTemplateFile(loadTemplate("claude/skills/bonnard-get-started/SKILL.md"), path.join(codexSkillsDir, "bonnard-get-started", "SKILL.md"), createdFiles);
|
|
583
|
+
writeTemplateFile(loadTemplate("claude/skills/bonnard-metabase-migrate/SKILL.md"), path.join(codexSkillsDir, "bonnard-metabase-migrate", "SKILL.md"), createdFiles);
|
|
579
584
|
return createdFiles;
|
|
580
585
|
}
|
|
581
586
|
async function initCommand() {
|
|
@@ -867,10 +872,10 @@ async function whoamiCommand(options = {}) {
|
|
|
867
872
|
*
|
|
868
873
|
* Env vars are resolved at deploy time, not import time.
|
|
869
874
|
*/
|
|
870
|
-
const BON_DIR$
|
|
875
|
+
const BON_DIR$2 = ".bon";
|
|
871
876
|
const DATASOURCES_FILE$1 = "datasources.yaml";
|
|
872
877
|
function getBonDir(cwd = process.cwd()) {
|
|
873
|
-
return path.join(cwd, BON_DIR$
|
|
878
|
+
return path.join(cwd, BON_DIR$2);
|
|
874
879
|
}
|
|
875
880
|
function getDatasourcesPath$1(cwd = process.cwd()) {
|
|
876
881
|
return path.join(getBonDir(cwd), DATASOURCES_FILE$1);
|
|
@@ -972,10 +977,10 @@ function resolveEnvVarsInCredentials(credentials) {
|
|
|
972
977
|
/**
|
|
973
978
|
* Credential utilities (git tracking check)
|
|
974
979
|
*/
|
|
975
|
-
const BON_DIR = ".bon";
|
|
980
|
+
const BON_DIR$1 = ".bon";
|
|
976
981
|
const DATASOURCES_FILE = "datasources.yaml";
|
|
977
982
|
function getDatasourcesPath(cwd = process.cwd()) {
|
|
978
|
-
return path.join(cwd, BON_DIR, DATASOURCES_FILE);
|
|
983
|
+
return path.join(cwd, BON_DIR$1, DATASOURCES_FILE);
|
|
979
984
|
}
|
|
980
985
|
/**
|
|
981
986
|
* Check if datasources file is tracked by git (it shouldn't be - contains credentials)
|
|
@@ -1134,7 +1139,7 @@ function mapDbtConnection(connection) {
|
|
|
1134
1139
|
|
|
1135
1140
|
//#endregion
|
|
1136
1141
|
//#region src/commands/datasource/add.ts
|
|
1137
|
-
async function prompts() {
|
|
1142
|
+
async function prompts$1() {
|
|
1138
1143
|
return import("@inquirer/prompts");
|
|
1139
1144
|
}
|
|
1140
1145
|
const WAREHOUSE_CONFIGS = [
|
|
@@ -1353,7 +1358,7 @@ async function importFromDbt(options) {
|
|
|
1353
1358
|
await importConnections(connections.filter((c) => c.isDefaultTarget));
|
|
1354
1359
|
return;
|
|
1355
1360
|
}
|
|
1356
|
-
const { checkbox } = await prompts();
|
|
1361
|
+
const { checkbox } = await prompts$1();
|
|
1357
1362
|
console.log();
|
|
1358
1363
|
console.log(pc.bold(`Found ${connections.length} connections in ~/.dbt/profiles.yml:`));
|
|
1359
1364
|
console.log();
|
|
@@ -1413,7 +1418,7 @@ async function importConnections(connections) {
|
|
|
1413
1418
|
* Add datasource manually (with flags and/or interactive prompts)
|
|
1414
1419
|
*/
|
|
1415
1420
|
async function addManual(options) {
|
|
1416
|
-
const { input, select, password, confirm } = await prompts();
|
|
1421
|
+
const { input, select, password, confirm } = await prompts$1();
|
|
1417
1422
|
const nonInteractive = isNonInteractive(options);
|
|
1418
1423
|
if (isDatasourcesTrackedByGit()) console.log(pc.yellow("Warning: .bon/datasources.yaml is tracked by git. Add it to .gitignore!"));
|
|
1419
1424
|
let name = options.name;
|
|
@@ -2524,6 +2529,1397 @@ async function cubeQueryCommand(queryInput, options = {}) {
|
|
|
2524
2529
|
}
|
|
2525
2530
|
}
|
|
2526
2531
|
|
|
2532
|
+
//#endregion
|
|
2533
|
+
//#region src/lib/metabase/config.ts
|
|
2534
|
+
/**
|
|
2535
|
+
* Metabase config storage (.bon/metabase.yaml)
|
|
2536
|
+
*
|
|
2537
|
+
* Stores API key and URL for Metabase connectivity.
|
|
2538
|
+
* File has 0o600 permissions since it contains the API key.
|
|
2539
|
+
*/
|
|
2540
|
+
const BON_DIR = ".bon";
|
|
2541
|
+
const CONFIG_FILE = "metabase.yaml";
|
|
2542
|
+
function getConfigPath(cwd = process.cwd()) {
|
|
2543
|
+
return path.join(cwd, BON_DIR, CONFIG_FILE);
|
|
2544
|
+
}
|
|
2545
|
+
function loadMetabaseConfig(cwd = process.cwd()) {
|
|
2546
|
+
const filePath = getConfigPath(cwd);
|
|
2547
|
+
if (!fs.existsSync(filePath)) return null;
|
|
2548
|
+
try {
|
|
2549
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
2550
|
+
return YAML.parse(content)?.metabase ?? null;
|
|
2551
|
+
} catch {
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
function saveMetabaseConfig(config, cwd = process.cwd()) {
|
|
2556
|
+
ensureBonDir(cwd);
|
|
2557
|
+
const filePath = getConfigPath(cwd);
|
|
2558
|
+
const file = { metabase: config };
|
|
2559
|
+
const content = `# Bonnard Metabase configuration
|
|
2560
|
+
# This file contains an API key - add .bon/ to .gitignore
|
|
2561
|
+
|
|
2562
|
+
` + YAML.stringify(file, { indent: 2 });
|
|
2563
|
+
fs.writeFileSync(filePath, content, { mode: 384 });
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
//#endregion
|
|
2567
|
+
//#region src/lib/metabase/client.ts
|
|
2568
|
+
var MetabaseApiError = class extends Error {
|
|
2569
|
+
constructor(status, endpoint, message) {
|
|
2570
|
+
super(message);
|
|
2571
|
+
this.status = status;
|
|
2572
|
+
this.endpoint = endpoint;
|
|
2573
|
+
this.name = "MetabaseApiError";
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
function createMetabaseClient(config) {
|
|
2577
|
+
const baseUrl = config.url.replace(/\/+$/, "");
|
|
2578
|
+
async function metabaseFetch(endpoint, options = {}) {
|
|
2579
|
+
const url = `${baseUrl}/api${endpoint}`;
|
|
2580
|
+
const res = await fetch(url, {
|
|
2581
|
+
...options,
|
|
2582
|
+
headers: {
|
|
2583
|
+
"X-API-KEY": config.apiKey,
|
|
2584
|
+
"Content-Type": "application/json",
|
|
2585
|
+
...options.headers
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2588
|
+
if (!res.ok) {
|
|
2589
|
+
let message = `HTTP ${res.status}`;
|
|
2590
|
+
try {
|
|
2591
|
+
const body = await res.text();
|
|
2592
|
+
if (body) message = body;
|
|
2593
|
+
} catch {}
|
|
2594
|
+
throw new MetabaseApiError(res.status, endpoint, message);
|
|
2595
|
+
}
|
|
2596
|
+
return res.json();
|
|
2597
|
+
}
|
|
2598
|
+
return {
|
|
2599
|
+
async getCurrentUser() {
|
|
2600
|
+
return metabaseFetch("/user/current");
|
|
2601
|
+
},
|
|
2602
|
+
async getDatabases() {
|
|
2603
|
+
return (await metabaseFetch("/database")).data;
|
|
2604
|
+
},
|
|
2605
|
+
async getCollections() {
|
|
2606
|
+
return metabaseFetch("/collection");
|
|
2607
|
+
},
|
|
2608
|
+
async getCollectionTree() {
|
|
2609
|
+
return metabaseFetch("/collection/tree");
|
|
2610
|
+
},
|
|
2611
|
+
async getCards() {
|
|
2612
|
+
return metabaseFetch("/card");
|
|
2613
|
+
},
|
|
2614
|
+
async getCard(id) {
|
|
2615
|
+
return metabaseFetch(`/card/${id}`);
|
|
2616
|
+
},
|
|
2617
|
+
async getDashboards() {
|
|
2618
|
+
return metabaseFetch("/dashboard");
|
|
2619
|
+
},
|
|
2620
|
+
async getDashboard(id) {
|
|
2621
|
+
return metabaseFetch(`/dashboard/${id}`);
|
|
2622
|
+
},
|
|
2623
|
+
async convertToNativeSQL(datasetQuery) {
|
|
2624
|
+
return (await metabaseFetch("/dataset/native", {
|
|
2625
|
+
method: "POST",
|
|
2626
|
+
body: JSON.stringify(datasetQuery)
|
|
2627
|
+
})).query;
|
|
2628
|
+
},
|
|
2629
|
+
async getDatabaseMetadata(id) {
|
|
2630
|
+
return metabaseFetch(`/database/${id}/metadata`);
|
|
2631
|
+
},
|
|
2632
|
+
async getPermissionGroups() {
|
|
2633
|
+
return metabaseFetch("/permissions/group");
|
|
2634
|
+
},
|
|
2635
|
+
async getPermissionsGraph() {
|
|
2636
|
+
return metabaseFetch("/permissions/graph");
|
|
2637
|
+
},
|
|
2638
|
+
async getCollectionItems(id) {
|
|
2639
|
+
return (await metabaseFetch(`/collection/${id}/items?models=card&models=dataset&models=metric&models=dashboard`)).data;
|
|
2640
|
+
},
|
|
2641
|
+
async getPopularItems() {
|
|
2642
|
+
return metabaseFetch("/activity/popular_items");
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
//#endregion
|
|
2648
|
+
//#region src/commands/metabase/connect.ts
|
|
2649
|
+
async function prompts() {
|
|
2650
|
+
return import("@inquirer/prompts");
|
|
2651
|
+
}
|
|
2652
|
+
async function metabaseConnectCommand(options) {
|
|
2653
|
+
const nonInteractive = !!(options.url && options.apiKey);
|
|
2654
|
+
if (loadMetabaseConfig()) if (options.force) console.log(pc.dim("Overwriting existing Metabase configuration"));
|
|
2655
|
+
else if (nonInteractive) {
|
|
2656
|
+
console.error(pc.red("Metabase is already configured. Use --force to overwrite."));
|
|
2657
|
+
process.exit(1);
|
|
2658
|
+
} else {
|
|
2659
|
+
const { confirm } = await prompts();
|
|
2660
|
+
if (!await confirm({
|
|
2661
|
+
message: "Metabase is already configured. Overwrite?",
|
|
2662
|
+
default: false
|
|
2663
|
+
})) {
|
|
2664
|
+
console.log(pc.yellow("Cancelled."));
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
let url = options.url;
|
|
2669
|
+
let apiKey = options.apiKey;
|
|
2670
|
+
if (!nonInteractive) {
|
|
2671
|
+
const { input, password } = await prompts();
|
|
2672
|
+
if (!url) url = await input({
|
|
2673
|
+
message: "Metabase URL (e.g. https://metabase.example.com):",
|
|
2674
|
+
validate: (v) => {
|
|
2675
|
+
try {
|
|
2676
|
+
const u = new URL(v);
|
|
2677
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return "URL must use http or https";
|
|
2678
|
+
return true;
|
|
2679
|
+
} catch {
|
|
2680
|
+
return "Enter a valid URL";
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
});
|
|
2684
|
+
if (!apiKey) apiKey = await password({ message: "API key:" });
|
|
2685
|
+
}
|
|
2686
|
+
if (!url || !apiKey) {
|
|
2687
|
+
console.error(pc.red("Both --url and --api-key are required in non-interactive mode."));
|
|
2688
|
+
process.exit(1);
|
|
2689
|
+
}
|
|
2690
|
+
try {
|
|
2691
|
+
new URL(url);
|
|
2692
|
+
} catch {
|
|
2693
|
+
console.error(pc.red(`Invalid URL: ${url}`));
|
|
2694
|
+
process.exit(1);
|
|
2695
|
+
}
|
|
2696
|
+
url = url.replace(/\/+$/, "");
|
|
2697
|
+
console.log();
|
|
2698
|
+
console.log(pc.dim("Testing connection..."));
|
|
2699
|
+
const client = createMetabaseClient({
|
|
2700
|
+
url,
|
|
2701
|
+
apiKey
|
|
2702
|
+
});
|
|
2703
|
+
try {
|
|
2704
|
+
const user = await client.getCurrentUser();
|
|
2705
|
+
console.log(pc.green("✓ Connected to Metabase"));
|
|
2706
|
+
console.log();
|
|
2707
|
+
console.log(` URL: ${url}`);
|
|
2708
|
+
console.log(` User: ${user.first_name} ${user.last_name} (${user.email})`);
|
|
2709
|
+
console.log(` Admin: ${user.is_superuser ? "Yes" : "No"}`);
|
|
2710
|
+
console.log();
|
|
2711
|
+
saveMetabaseConfig({
|
|
2712
|
+
url,
|
|
2713
|
+
apiKey
|
|
2714
|
+
});
|
|
2715
|
+
console.log(pc.green("✓ Configuration saved to .bon/metabase.yaml"));
|
|
2716
|
+
console.log();
|
|
2717
|
+
console.log(pc.dim("Explore your Metabase content: bon metabase explore"));
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
if (err instanceof MetabaseApiError) if (err.status === 401 || err.status === 403) {
|
|
2720
|
+
console.error(pc.red("Authentication failed. Check your API key."));
|
|
2721
|
+
console.log();
|
|
2722
|
+
console.log(pc.dim("Generate an API key in Metabase:"));
|
|
2723
|
+
console.log(pc.dim(" Admin > Settings > Authentication > API Keys"));
|
|
2724
|
+
} else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
|
|
2725
|
+
else if (err instanceof TypeError && (err.message.includes("fetch") || err.message.includes("ECONNREFUSED"))) {
|
|
2726
|
+
console.error(pc.red(`Could not connect to ${url}`));
|
|
2727
|
+
console.log(pc.dim("Check the URL and ensure Metabase is running."));
|
|
2728
|
+
} else console.error(pc.red(`Connection failed: ${err.message}`));
|
|
2729
|
+
process.exit(1);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
//#endregion
|
|
2734
|
+
//#region src/commands/metabase/explore.ts
|
|
2735
|
+
function requireConfig() {
|
|
2736
|
+
const config = loadMetabaseConfig();
|
|
2737
|
+
if (!config) {
|
|
2738
|
+
console.error(pc.red("Metabase is not configured."));
|
|
2739
|
+
console.log(pc.dim("Run: bon metabase connect"));
|
|
2740
|
+
process.exit(1);
|
|
2741
|
+
}
|
|
2742
|
+
return createMetabaseClient(config);
|
|
2743
|
+
}
|
|
2744
|
+
function getCardType$1(card) {
|
|
2745
|
+
if (card.type === "model" || card.dataset) return "model";
|
|
2746
|
+
if (card.type === "metric") return "metric";
|
|
2747
|
+
return "question";
|
|
2748
|
+
}
|
|
2749
|
+
function padColumn(value, width) {
|
|
2750
|
+
return value.padEnd(width);
|
|
2751
|
+
}
|
|
2752
|
+
const INACTIVE_MONTHS$1 = 3;
|
|
2753
|
+
function isCardActive$1(card) {
|
|
2754
|
+
if (!card.last_used_at) return false;
|
|
2755
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
2756
|
+
cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS$1);
|
|
2757
|
+
return new Date(card.last_used_at) >= cutoff;
|
|
2758
|
+
}
|
|
2759
|
+
function activityScore$1(card) {
|
|
2760
|
+
const views = card.view_count || 0;
|
|
2761
|
+
return isCardActive$1(card) ? views : Math.round(views * .1);
|
|
2762
|
+
}
|
|
2763
|
+
function formatLastUsed$1(card) {
|
|
2764
|
+
if (!card.last_used_at) return "never";
|
|
2765
|
+
const d = new Date(card.last_used_at);
|
|
2766
|
+
const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
|
|
2767
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
2768
|
+
if (diffDays === 0) return "today";
|
|
2769
|
+
if (diffDays === 1) return "yesterday";
|
|
2770
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
2771
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
2772
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
2773
|
+
}
|
|
2774
|
+
async function showOverview(client) {
|
|
2775
|
+
const [databases, collections, cards, dashboards] = await Promise.all([
|
|
2776
|
+
client.getDatabases(),
|
|
2777
|
+
client.getCollections(),
|
|
2778
|
+
client.getCards(),
|
|
2779
|
+
client.getDashboards()
|
|
2780
|
+
]);
|
|
2781
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
2782
|
+
const models = activeCards.filter((c) => getCardType$1(c) === "model");
|
|
2783
|
+
const metrics = activeCards.filter((c) => getCardType$1(c) === "metric");
|
|
2784
|
+
const questions = activeCards.filter((c) => getCardType$1(c) === "question");
|
|
2785
|
+
const activeCollections = collections.filter((c) => !c.archived && c.personal_owner_id === null);
|
|
2786
|
+
const activeDashboards = dashboards.filter((d) => !d.archived);
|
|
2787
|
+
const recentlyActive = activeCards.filter(isCardActive$1).length;
|
|
2788
|
+
const inactive = activeCards.length - recentlyActive;
|
|
2789
|
+
console.log();
|
|
2790
|
+
console.log(pc.bold("Metabase Overview"));
|
|
2791
|
+
console.log();
|
|
2792
|
+
console.log(` Databases: ${databases.length}`);
|
|
2793
|
+
console.log(` Collections: ${activeCollections.length}`);
|
|
2794
|
+
console.log(` Models: ${models.length}`);
|
|
2795
|
+
console.log(` Metrics: ${metrics.length}`);
|
|
2796
|
+
console.log(` Questions: ${questions.length}`);
|
|
2797
|
+
console.log(` Dashboards: ${activeDashboards.length}`);
|
|
2798
|
+
console.log();
|
|
2799
|
+
console.log(` Active (last ${INACTIVE_MONTHS$1}mo): ${pc.green(String(recentlyActive))}`);
|
|
2800
|
+
console.log(` Inactive: ${pc.dim(String(inactive))}`);
|
|
2801
|
+
console.log();
|
|
2802
|
+
console.log(pc.dim("Explore further:"));
|
|
2803
|
+
console.log(pc.dim(" bon metabase explore databases"));
|
|
2804
|
+
console.log(pc.dim(" bon metabase explore collections"));
|
|
2805
|
+
console.log(pc.dim(" bon metabase explore cards"));
|
|
2806
|
+
console.log(pc.dim(" bon metabase explore dashboards"));
|
|
2807
|
+
console.log(pc.dim(" bon metabase explore card <id>"));
|
|
2808
|
+
console.log(pc.dim(" bon metabase explore dashboard <id>"));
|
|
2809
|
+
console.log(pc.dim(" bon metabase explore database <id>"));
|
|
2810
|
+
console.log(pc.dim(" bon metabase explore table <id>"));
|
|
2811
|
+
console.log(pc.dim(" bon metabase explore collection <id>"));
|
|
2812
|
+
}
|
|
2813
|
+
async function showDatabases(client) {
|
|
2814
|
+
const databases = await client.getDatabases();
|
|
2815
|
+
console.log();
|
|
2816
|
+
console.log(pc.bold("Databases"));
|
|
2817
|
+
console.log();
|
|
2818
|
+
if (databases.length === 0) {
|
|
2819
|
+
console.log(pc.dim(" No databases found."));
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("ENGINE", 16))}${pc.dim("SAMPLE")}`);
|
|
2823
|
+
for (const db of databases) console.log(` ${padColumn(String(db.id), 6)}${padColumn(db.name, 30)}${padColumn(db.engine, 16)}${db.is_sample ? "Yes" : ""}`);
|
|
2824
|
+
}
|
|
2825
|
+
function printTree(nodes, indent = 0) {
|
|
2826
|
+
for (const node of nodes) {
|
|
2827
|
+
if (node.personal_owner_id !== null) continue;
|
|
2828
|
+
const prefix = indent === 0 ? " " : " " + " ".repeat(indent);
|
|
2829
|
+
const icon = node.children.length > 0 ? "+" : "-";
|
|
2830
|
+
console.log(`${prefix}${pc.dim(icon)} ${node.name} ${pc.dim(`(${node.id})`)}`);
|
|
2831
|
+
if (node.children.length > 0) printTree(node.children, indent + 1);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
async function showCollections(client) {
|
|
2835
|
+
const tree = await client.getCollectionTree();
|
|
2836
|
+
console.log();
|
|
2837
|
+
console.log(pc.bold("Collections"));
|
|
2838
|
+
console.log();
|
|
2839
|
+
const filtered = tree.filter((n) => n.personal_owner_id === null);
|
|
2840
|
+
if (filtered.length === 0) {
|
|
2841
|
+
console.log(pc.dim(" No collections found."));
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
printTree(filtered);
|
|
2845
|
+
}
|
|
2846
|
+
async function showCards(client) {
|
|
2847
|
+
const active = (await client.getCards()).filter((c) => !c.archived);
|
|
2848
|
+
const models = active.filter((c) => getCardType$1(c) === "model");
|
|
2849
|
+
const metrics = active.filter((c) => getCardType$1(c) === "metric");
|
|
2850
|
+
const questions = active.filter((c) => getCardType$1(c) === "question");
|
|
2851
|
+
const CAP = 50;
|
|
2852
|
+
console.log();
|
|
2853
|
+
const groups = [
|
|
2854
|
+
{
|
|
2855
|
+
label: "Models",
|
|
2856
|
+
items: models
|
|
2857
|
+
},
|
|
2858
|
+
{
|
|
2859
|
+
label: "Metrics",
|
|
2860
|
+
items: metrics
|
|
2861
|
+
},
|
|
2862
|
+
{
|
|
2863
|
+
label: "Questions",
|
|
2864
|
+
items: questions
|
|
2865
|
+
}
|
|
2866
|
+
];
|
|
2867
|
+
for (const group of groups) {
|
|
2868
|
+
if (group.items.length === 0) continue;
|
|
2869
|
+
const groupActive = group.items.filter(isCardActive$1).length;
|
|
2870
|
+
const groupInactive = group.items.length - groupActive;
|
|
2871
|
+
const sorted = [...group.items].sort((a, b) => activityScore$1(b) - activityScore$1(a));
|
|
2872
|
+
console.log(pc.bold(`${group.label} (${group.items.length})`) + pc.dim(` — ${groupActive} active, ${groupInactive} inactive`));
|
|
2873
|
+
console.log();
|
|
2874
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 36))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("VIEWS")}`);
|
|
2875
|
+
const display = sorted.slice(0, CAP);
|
|
2876
|
+
for (const card of display) {
|
|
2877
|
+
const lastUsed = formatLastUsed$1(card);
|
|
2878
|
+
const active = isCardActive$1(card);
|
|
2879
|
+
const name = card.name.slice(0, 34);
|
|
2880
|
+
const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 36)}${padColumn(card.display, 14)}${padColumn(lastUsed, 12)}${card.view_count || 0}`;
|
|
2881
|
+
console.log(active ? line : pc.dim(line));
|
|
2882
|
+
}
|
|
2883
|
+
if (group.items.length > CAP) console.log(pc.dim(` ... and ${group.items.length - CAP} more`));
|
|
2884
|
+
console.log();
|
|
2885
|
+
}
|
|
2886
|
+
if (models.length === 0 && metrics.length === 0 && questions.length === 0) console.log(pc.dim(" No cards found."));
|
|
2887
|
+
console.log(pc.dim("View details: bon metabase explore card <id>"));
|
|
2888
|
+
}
|
|
2889
|
+
async function showCardDetail(client, id) {
|
|
2890
|
+
const card = await client.getCard(id);
|
|
2891
|
+
const cardType = getCardType$1(card);
|
|
2892
|
+
console.log();
|
|
2893
|
+
console.log(pc.bold(card.name));
|
|
2894
|
+
if (card.description) console.log(pc.dim(card.description));
|
|
2895
|
+
console.log();
|
|
2896
|
+
const active = isCardActive$1(card);
|
|
2897
|
+
const lastUsed = formatLastUsed$1(card);
|
|
2898
|
+
const activityLabel = active ? pc.green("active") : pc.dim("inactive");
|
|
2899
|
+
console.log(` Type: ${cardType}`);
|
|
2900
|
+
console.log(` Display: ${card.display}`);
|
|
2901
|
+
console.log(` Database: ${card.database_id}`);
|
|
2902
|
+
console.log(` Views: ${card.view_count || 0}`);
|
|
2903
|
+
console.log(` Last used: ${lastUsed} (${activityLabel})`);
|
|
2904
|
+
console.log();
|
|
2905
|
+
let sql = null;
|
|
2906
|
+
const dq = card.dataset_query;
|
|
2907
|
+
if (dq.type === "native" && dq.native?.query) sql = dq.native.query;
|
|
2908
|
+
else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") sql = dq.stages[0].native;
|
|
2909
|
+
else if (dq.type === "query") try {
|
|
2910
|
+
sql = await client.convertToNativeSQL(dq);
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
|
|
2913
|
+
}
|
|
2914
|
+
else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") try {
|
|
2915
|
+
sql = await client.convertToNativeSQL(dq);
|
|
2916
|
+
} catch (err) {
|
|
2917
|
+
if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
|
|
2918
|
+
}
|
|
2919
|
+
if (sql) {
|
|
2920
|
+
console.log(pc.bold("SQL"));
|
|
2921
|
+
console.log();
|
|
2922
|
+
for (const line of sql.trim().split("\n")) console.log(` ${line}`);
|
|
2923
|
+
console.log();
|
|
2924
|
+
}
|
|
2925
|
+
if (card.result_metadata && card.result_metadata.length > 0) {
|
|
2926
|
+
console.log(pc.bold("Columns"));
|
|
2927
|
+
console.log();
|
|
2928
|
+
console.log(` ${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("DISPLAY NAME", 30))}${pc.dim("TYPE")}`);
|
|
2929
|
+
for (const col of card.result_metadata) console.log(` ${padColumn(col.name, 30)}${padColumn(col.display_name, 30)}${col.base_type}`);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
async function showDashboards(client) {
|
|
2933
|
+
const active = (await client.getDashboards()).filter((d) => !d.archived);
|
|
2934
|
+
console.log();
|
|
2935
|
+
console.log(pc.bold(`Dashboards (${active.length})`));
|
|
2936
|
+
console.log();
|
|
2937
|
+
if (active.length === 0) {
|
|
2938
|
+
console.log(pc.dim(" No dashboards found."));
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
|
|
2942
|
+
for (const d of active) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
|
|
2943
|
+
console.log();
|
|
2944
|
+
console.log(pc.dim("View details: bon metabase explore dashboard <id>"));
|
|
2945
|
+
}
|
|
2946
|
+
async function showDashboardDetail(client, id) {
|
|
2947
|
+
const [dashboard, allCards] = await Promise.all([client.getDashboard(id), client.getCards()]);
|
|
2948
|
+
const cardActivityMap = /* @__PURE__ */ new Map();
|
|
2949
|
+
for (const c of allCards) cardActivityMap.set(c.id, c);
|
|
2950
|
+
console.log();
|
|
2951
|
+
console.log(pc.bold(dashboard.name));
|
|
2952
|
+
if (dashboard.description) console.log(pc.dim(dashboard.description));
|
|
2953
|
+
console.log();
|
|
2954
|
+
if (dashboard.parameters.length > 0) {
|
|
2955
|
+
console.log(pc.bold("Parameters"));
|
|
2956
|
+
console.log();
|
|
2957
|
+
console.log(` ${pc.dim(padColumn("NAME", 25))}${pc.dim(padColumn("TYPE", 20))}${pc.dim("SLUG")}`);
|
|
2958
|
+
for (const p of dashboard.parameters) console.log(` ${padColumn(p.name, 25)}${padColumn(p.type, 20)}${p.slug}`);
|
|
2959
|
+
console.log();
|
|
2960
|
+
}
|
|
2961
|
+
const cardsOnDashboard = dashboard.dashcards.filter((dc) => dc.card?.id != null);
|
|
2962
|
+
if (cardsOnDashboard.length > 0) {
|
|
2963
|
+
const inactiveOnDash = cardsOnDashboard.filter((dc) => {
|
|
2964
|
+
const full = cardActivityMap.get(dc.card.id);
|
|
2965
|
+
return full ? !isCardActive$1(full) : true;
|
|
2966
|
+
});
|
|
2967
|
+
const activeLabel = cardsOnDashboard.length - inactiveOnDash.length;
|
|
2968
|
+
console.log(pc.bold(`Cards (${cardsOnDashboard.length})`) + pc.dim(` — ${activeLabel} active, ${inactiveOnDash.length} inactive`));
|
|
2969
|
+
console.log();
|
|
2970
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 31))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("POSITION")}`);
|
|
2971
|
+
for (const dc of cardsOnDashboard) {
|
|
2972
|
+
const card = dc.card;
|
|
2973
|
+
const name = (card.name ?? "(untitled)").slice(0, 29);
|
|
2974
|
+
const pos = `(${dc.col},${dc.row}) ${dc.size_x}x${dc.size_y}`;
|
|
2975
|
+
const full = cardActivityMap.get(card.id);
|
|
2976
|
+
const lastUsed = full ? formatLastUsed$1(full) : "?";
|
|
2977
|
+
const active = full ? isCardActive$1(full) : false;
|
|
2978
|
+
const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 31)}${padColumn(card.display ?? "", 14)}${padColumn(lastUsed, 12)}${pos}`;
|
|
2979
|
+
console.log(active ? line : pc.dim(line));
|
|
2980
|
+
}
|
|
2981
|
+
} else console.log(pc.dim(" No cards on this dashboard."));
|
|
2982
|
+
}
|
|
2983
|
+
async function showDatabaseDetail(client, id) {
|
|
2984
|
+
const meta = await client.getDatabaseMetadata(id);
|
|
2985
|
+
console.log();
|
|
2986
|
+
console.log(pc.bold(`${meta.name} (${meta.engine})`));
|
|
2987
|
+
console.log();
|
|
2988
|
+
const bySchema = /* @__PURE__ */ new Map();
|
|
2989
|
+
for (const t of meta.tables) {
|
|
2990
|
+
if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
|
|
2991
|
+
if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
|
|
2992
|
+
bySchema.get(t.schema).push(t);
|
|
2993
|
+
}
|
|
2994
|
+
for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
2995
|
+
console.log(pc.bold(` ${schema}`) + pc.dim(` (${tables.length} tables)`));
|
|
2996
|
+
const sorted = [...tables].sort((a, b) => a.name.localeCompare(b.name));
|
|
2997
|
+
for (const t of sorted) {
|
|
2998
|
+
const fieldCount = t.fields.length;
|
|
2999
|
+
const desc = t.description ? pc.dim(` — ${t.description.slice(0, 50)}`) : "";
|
|
3000
|
+
console.log(` ${padColumn(String(t.id), 8)}${padColumn(t.name, 40)}${fieldCount} fields${desc}`);
|
|
3001
|
+
}
|
|
3002
|
+
console.log();
|
|
3003
|
+
}
|
|
3004
|
+
console.log(pc.dim("View table fields: bon metabase explore table <id>"));
|
|
3005
|
+
}
|
|
3006
|
+
function classifyFieldType(field) {
|
|
3007
|
+
const bt = field.base_type || "";
|
|
3008
|
+
const st = field.semantic_type || "";
|
|
3009
|
+
if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date") return "time";
|
|
3010
|
+
if (st === "type/PK") return "pk";
|
|
3011
|
+
if (st === "type/FK") return "fk";
|
|
3012
|
+
if ([
|
|
3013
|
+
"type/Currency",
|
|
3014
|
+
"type/Percentage",
|
|
3015
|
+
"type/Quantity",
|
|
3016
|
+
"type/Score"
|
|
3017
|
+
].includes(st)) return "measure";
|
|
3018
|
+
if ([
|
|
3019
|
+
"type/Category",
|
|
3020
|
+
"type/Source",
|
|
3021
|
+
"type/City",
|
|
3022
|
+
"type/Country",
|
|
3023
|
+
"type/State",
|
|
3024
|
+
"type/Name",
|
|
3025
|
+
"type/Email",
|
|
3026
|
+
"type/URL"
|
|
3027
|
+
].includes(st)) return "dim";
|
|
3028
|
+
if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) return "numeric";
|
|
3029
|
+
if (bt.includes("Text") || bt.includes("String")) return "text";
|
|
3030
|
+
if (bt.includes("Boolean")) return "bool";
|
|
3031
|
+
return "";
|
|
3032
|
+
}
|
|
3033
|
+
async function showTableDetail(client, tableId) {
|
|
3034
|
+
const databases = await client.getDatabases();
|
|
3035
|
+
let foundTable = null;
|
|
3036
|
+
let dbName = "";
|
|
3037
|
+
let meta = null;
|
|
3038
|
+
for (const db of databases) {
|
|
3039
|
+
meta = await client.getDatabaseMetadata(db.id);
|
|
3040
|
+
const table = meta.tables.find((t) => t.id === tableId);
|
|
3041
|
+
if (table) {
|
|
3042
|
+
foundTable = table;
|
|
3043
|
+
dbName = db.name;
|
|
3044
|
+
break;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
if (!foundTable || !meta) {
|
|
3048
|
+
console.error(pc.red(`Table ${tableId} not found.`));
|
|
3049
|
+
process.exit(1);
|
|
3050
|
+
}
|
|
3051
|
+
const fieldIdLookup = /* @__PURE__ */ new Map();
|
|
3052
|
+
for (const t of meta.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
|
|
3053
|
+
table: t.name,
|
|
3054
|
+
field: f.name
|
|
3055
|
+
});
|
|
3056
|
+
console.log();
|
|
3057
|
+
console.log(pc.bold(`${foundTable.name}`) + pc.dim(` (${dbName} / ${foundTable.schema})`));
|
|
3058
|
+
if (foundTable.description) console.log(pc.dim(foundTable.description));
|
|
3059
|
+
console.log();
|
|
3060
|
+
const fields = foundTable.fields.filter((f) => f.visibility_type !== "hidden" && f.visibility_type !== "retired");
|
|
3061
|
+
console.log(` ${pc.dim(padColumn("FIELD", 30))}${pc.dim(padColumn("TYPE", 22))}${pc.dim(padColumn("SEMANTIC", 18))}${pc.dim(padColumn("CLASS", 8))}${pc.dim(padColumn("DISTINCT", 10))}${pc.dim(padColumn("NULL%", 8))}${pc.dim("FK TARGET")}`);
|
|
3062
|
+
for (const f of fields) {
|
|
3063
|
+
const cls = classifyFieldType(f);
|
|
3064
|
+
const distinct = f.fingerprint?.global?.["distinct-count"];
|
|
3065
|
+
const nilPct = f.fingerprint?.global?.["nil%"];
|
|
3066
|
+
const fkTarget = f.fk_target_field_id ? (() => {
|
|
3067
|
+
const target = fieldIdLookup.get(f.fk_target_field_id);
|
|
3068
|
+
return target ? `${target.table}.${target.field}` : `field:${f.fk_target_field_id}`;
|
|
3069
|
+
})() : "";
|
|
3070
|
+
console.log(` ${padColumn(f.name, 30)}${padColumn(f.base_type.replace("type/", ""), 22)}${padColumn(f.semantic_type?.replace("type/", "") || "", 18)}${padColumn(cls, 8)}${padColumn(distinct !== void 0 ? String(distinct) : "", 10)}${padColumn(nilPct !== void 0 ? `${Math.round(nilPct * 100)}%` : "", 8)}${fkTarget}`);
|
|
3071
|
+
}
|
|
3072
|
+
console.log();
|
|
3073
|
+
console.log(pc.dim(`${fields.length} fields (${fields.filter((f) => classifyFieldType(f) === "pk" || classifyFieldType(f) === "fk").length} keys, ${fields.filter((f) => classifyFieldType(f) === "time").length} time, ${fields.filter((f) => classifyFieldType(f) === "measure").length} measures)`));
|
|
3074
|
+
}
|
|
3075
|
+
async function showCollectionDetail(client, id) {
|
|
3076
|
+
const [items, allCards] = await Promise.all([client.getCollectionItems(id), client.getCards()]);
|
|
3077
|
+
const cardMap = /* @__PURE__ */ new Map();
|
|
3078
|
+
for (const c of allCards) cardMap.set(c.id, c);
|
|
3079
|
+
const cardItems = items.filter((i) => i.model === "card" || i.model === "dataset" || i.model === "metric");
|
|
3080
|
+
const dashboardItems = items.filter((i) => i.model === "dashboard");
|
|
3081
|
+
console.log();
|
|
3082
|
+
console.log(pc.bold(`Collection ${id}`));
|
|
3083
|
+
console.log();
|
|
3084
|
+
if (cardItems.length > 0) {
|
|
3085
|
+
const sorted = cardItems.sort((a, b) => {
|
|
3086
|
+
const ca = cardMap.get(a.id);
|
|
3087
|
+
const cb = cardMap.get(b.id);
|
|
3088
|
+
return (cb ? activityScore$1(cb) : 0) - (ca ? activityScore$1(ca) : 0);
|
|
3089
|
+
});
|
|
3090
|
+
const activeCount = sorted.filter((i) => {
|
|
3091
|
+
const c = cardMap.get(i.id);
|
|
3092
|
+
return c ? isCardActive$1(c) : false;
|
|
3093
|
+
}).length;
|
|
3094
|
+
console.log(pc.bold(`Cards (${sorted.length})`) + pc.dim(` — ${activeCount} active, ${sorted.length - activeCount} inactive`));
|
|
3095
|
+
console.log();
|
|
3096
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 40))}${pc.dim(padColumn("TYPE", 10))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("VIEWS")}`);
|
|
3097
|
+
for (const item of sorted) {
|
|
3098
|
+
const full = cardMap.get(item.id);
|
|
3099
|
+
const lastUsed = full ? formatLastUsed$1(full) : "?";
|
|
3100
|
+
const views = full ? full.view_count || 0 : 0;
|
|
3101
|
+
const active = full ? isCardActive$1(full) : false;
|
|
3102
|
+
const itemType = item.model === "dataset" ? "model" : item.model;
|
|
3103
|
+
const name = item.name.slice(0, 38);
|
|
3104
|
+
const line = ` ${padColumn(String(item.id), 6)}${padColumn(name, 40)}${padColumn(itemType, 10)}${padColumn(item.display || "", 14)}${padColumn(lastUsed, 12)}${views}`;
|
|
3105
|
+
console.log(active ? line : pc.dim(line));
|
|
3106
|
+
}
|
|
3107
|
+
console.log();
|
|
3108
|
+
}
|
|
3109
|
+
if (dashboardItems.length > 0) {
|
|
3110
|
+
console.log(pc.bold(`Dashboards (${dashboardItems.length})`));
|
|
3111
|
+
console.log();
|
|
3112
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
|
|
3113
|
+
for (const d of dashboardItems) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
|
|
3114
|
+
console.log();
|
|
3115
|
+
}
|
|
3116
|
+
if (cardItems.length === 0 && dashboardItems.length === 0) console.log(pc.dim(" No items in this collection."));
|
|
3117
|
+
console.log(pc.dim("View card SQL: bon metabase explore card <id>"));
|
|
3118
|
+
console.log(pc.dim("View dashboard: bon metabase explore dashboard <id>"));
|
|
3119
|
+
}
|
|
3120
|
+
const RESOURCES = [
|
|
3121
|
+
"databases",
|
|
3122
|
+
"collections",
|
|
3123
|
+
"cards",
|
|
3124
|
+
"dashboards",
|
|
3125
|
+
"card",
|
|
3126
|
+
"dashboard",
|
|
3127
|
+
"database",
|
|
3128
|
+
"table",
|
|
3129
|
+
"collection"
|
|
3130
|
+
];
|
|
3131
|
+
async function metabaseExploreCommand(resource, id) {
|
|
3132
|
+
const client = requireConfig();
|
|
3133
|
+
try {
|
|
3134
|
+
if (!resource) {
|
|
3135
|
+
await showOverview(client);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
if (!RESOURCES.includes(resource)) {
|
|
3139
|
+
console.error(pc.red(`Unknown resource: ${resource}`));
|
|
3140
|
+
console.log(pc.dim(`Valid resources: ${RESOURCES.join(", ")}`));
|
|
3141
|
+
process.exit(1);
|
|
3142
|
+
}
|
|
3143
|
+
switch (resource) {
|
|
3144
|
+
case "databases":
|
|
3145
|
+
await showDatabases(client);
|
|
3146
|
+
break;
|
|
3147
|
+
case "collections":
|
|
3148
|
+
await showCollections(client);
|
|
3149
|
+
break;
|
|
3150
|
+
case "cards":
|
|
3151
|
+
await showCards(client);
|
|
3152
|
+
break;
|
|
3153
|
+
case "dashboards":
|
|
3154
|
+
await showDashboards(client);
|
|
3155
|
+
break;
|
|
3156
|
+
case "card": {
|
|
3157
|
+
if (!id) {
|
|
3158
|
+
console.error(pc.red("Card ID required: bon metabase explore card <id>"));
|
|
3159
|
+
process.exit(1);
|
|
3160
|
+
}
|
|
3161
|
+
const cardId = parseInt(id, 10);
|
|
3162
|
+
if (isNaN(cardId)) {
|
|
3163
|
+
console.error(pc.red(`Invalid card ID: ${id}`));
|
|
3164
|
+
process.exit(1);
|
|
3165
|
+
}
|
|
3166
|
+
await showCardDetail(client, cardId);
|
|
3167
|
+
break;
|
|
3168
|
+
}
|
|
3169
|
+
case "dashboard": {
|
|
3170
|
+
if (!id) {
|
|
3171
|
+
console.error(pc.red("Dashboard ID required: bon metabase explore dashboard <id>"));
|
|
3172
|
+
process.exit(1);
|
|
3173
|
+
}
|
|
3174
|
+
const dashId = parseInt(id, 10);
|
|
3175
|
+
if (isNaN(dashId)) {
|
|
3176
|
+
console.error(pc.red(`Invalid dashboard ID: ${id}`));
|
|
3177
|
+
process.exit(1);
|
|
3178
|
+
}
|
|
3179
|
+
await showDashboardDetail(client, dashId);
|
|
3180
|
+
break;
|
|
3181
|
+
}
|
|
3182
|
+
case "database": {
|
|
3183
|
+
if (!id) {
|
|
3184
|
+
console.error(pc.red("Database ID required: bon metabase explore database <id>"));
|
|
3185
|
+
process.exit(1);
|
|
3186
|
+
}
|
|
3187
|
+
const dbId = parseInt(id, 10);
|
|
3188
|
+
if (isNaN(dbId)) {
|
|
3189
|
+
console.error(pc.red(`Invalid database ID: ${id}`));
|
|
3190
|
+
process.exit(1);
|
|
3191
|
+
}
|
|
3192
|
+
await showDatabaseDetail(client, dbId);
|
|
3193
|
+
break;
|
|
3194
|
+
}
|
|
3195
|
+
case "table": {
|
|
3196
|
+
if (!id) {
|
|
3197
|
+
console.error(pc.red("Table ID required: bon metabase explore table <id>"));
|
|
3198
|
+
process.exit(1);
|
|
3199
|
+
}
|
|
3200
|
+
const tableId = parseInt(id, 10);
|
|
3201
|
+
if (isNaN(tableId)) {
|
|
3202
|
+
console.error(pc.red(`Invalid table ID: ${id}`));
|
|
3203
|
+
process.exit(1);
|
|
3204
|
+
}
|
|
3205
|
+
await showTableDetail(client, tableId);
|
|
3206
|
+
break;
|
|
3207
|
+
}
|
|
3208
|
+
case "collection": {
|
|
3209
|
+
if (!id) {
|
|
3210
|
+
console.error(pc.red("Collection ID required: bon metabase explore collection <id>"));
|
|
3211
|
+
process.exit(1);
|
|
3212
|
+
}
|
|
3213
|
+
const colId = parseInt(id, 10);
|
|
3214
|
+
if (isNaN(colId)) {
|
|
3215
|
+
console.error(pc.red(`Invalid collection ID: ${id}`));
|
|
3216
|
+
process.exit(1);
|
|
3217
|
+
}
|
|
3218
|
+
await showCollectionDetail(client, colId);
|
|
3219
|
+
break;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
} catch (err) {
|
|
3223
|
+
if (err instanceof MetabaseApiError) {
|
|
3224
|
+
if (err.status === 401 || err.status === 403) console.error(pc.red("Authentication failed. Re-run: bon metabase connect"));
|
|
3225
|
+
else if (err.status === 404) console.error(pc.red(`Not found: ${err.endpoint}`));
|
|
3226
|
+
else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
|
|
3227
|
+
process.exit(1);
|
|
3228
|
+
}
|
|
3229
|
+
throw err;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
//#endregion
|
|
3234
|
+
//#region src/commands/metabase/analyze.ts
|
|
3235
|
+
const OUTPUT_FILE = ".bon/metabase-analysis.md";
|
|
3236
|
+
const TOP_CARDS_LIMIT = 50;
|
|
3237
|
+
const TOP_DASHBOARDS_LIMIT = 10;
|
|
3238
|
+
function classifyField(field) {
|
|
3239
|
+
const bt = field.base_type || "";
|
|
3240
|
+
const st = field.semantic_type || "";
|
|
3241
|
+
if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date" || st === "type/Time") return "time";
|
|
3242
|
+
if ([
|
|
3243
|
+
"type/Currency",
|
|
3244
|
+
"type/Percentage",
|
|
3245
|
+
"type/Quantity",
|
|
3246
|
+
"type/Score"
|
|
3247
|
+
].includes(st)) return "measure";
|
|
3248
|
+
if (st === "type/PK" || st === "type/FK") return "dimension";
|
|
3249
|
+
if ([
|
|
3250
|
+
"type/Category",
|
|
3251
|
+
"type/Source",
|
|
3252
|
+
"type/City",
|
|
3253
|
+
"type/Country",
|
|
3254
|
+
"type/State",
|
|
3255
|
+
"type/Name",
|
|
3256
|
+
"type/Title",
|
|
3257
|
+
"type/Email",
|
|
3258
|
+
"type/URL",
|
|
3259
|
+
"type/ZipCode"
|
|
3260
|
+
].includes(st)) return "dimension";
|
|
3261
|
+
if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) {
|
|
3262
|
+
const distinct = field.fingerprint?.global?.["distinct-count"];
|
|
3263
|
+
if (distinct !== void 0 && distinct < 20) return "dimension";
|
|
3264
|
+
return "measure";
|
|
3265
|
+
}
|
|
3266
|
+
if (bt.includes("Text") || bt.includes("String")) return "dimension";
|
|
3267
|
+
if (bt.includes("Boolean")) return "dimension";
|
|
3268
|
+
return "other";
|
|
3269
|
+
}
|
|
3270
|
+
function extractSQL(card) {
|
|
3271
|
+
const dq = card.dataset_query;
|
|
3272
|
+
if (dq.type === "native" && dq.native?.query) return dq.native.query;
|
|
3273
|
+
if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") return dq.stages[0].native;
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
/**
|
|
3277
|
+
* Returns true if the card uses MBQL (query builder) rather than native SQL.
|
|
3278
|
+
*/
|
|
3279
|
+
function isMbqlCard(card) {
|
|
3280
|
+
const dq = card.dataset_query;
|
|
3281
|
+
if (dq.type === "query") return true;
|
|
3282
|
+
if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") return true;
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Extracts table references from SQL (FROM and JOIN clauses).
|
|
3287
|
+
* Returns a map of table name -> reference count.
|
|
3288
|
+
* Excludes CTE names so only real tables are counted.
|
|
3289
|
+
*/
|
|
3290
|
+
function extractTableReferences(sql) {
|
|
3291
|
+
const refs = /* @__PURE__ */ new Map();
|
|
3292
|
+
const cteNames = /* @__PURE__ */ new Set();
|
|
3293
|
+
const cteMatch = sql.match(/\bWITH\b[\s\S]*?(?=\bSELECT\b(?![\s\S]*\bWITH\b))/gi);
|
|
3294
|
+
if (cteMatch) {
|
|
3295
|
+
const cteDefPattern = /\b(\w+)\s+AS\s*\(/gi;
|
|
3296
|
+
for (const block of cteMatch) {
|
|
3297
|
+
let m;
|
|
3298
|
+
while ((m = cteDefPattern.exec(block)) !== null) cteNames.add(m[1].toLowerCase());
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
const tableRefPattern = /(?:FROM|JOIN)\s+("?\w+"?\s*\.\s*"?\w+"?|"?\w+"?)(?:\s+(?:AS\s+)?\w+)?/gi;
|
|
3302
|
+
let match;
|
|
3303
|
+
while ((match = tableRefPattern.exec(sql)) !== null) {
|
|
3304
|
+
let tableName = match[1].replace(/"/g, "").replace(/\s/g, "").toLowerCase();
|
|
3305
|
+
const baseName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
3306
|
+
if (cteNames.has(baseName)) continue;
|
|
3307
|
+
if ([
|
|
3308
|
+
"select",
|
|
3309
|
+
"where",
|
|
3310
|
+
"group",
|
|
3311
|
+
"order",
|
|
3312
|
+
"having",
|
|
3313
|
+
"limit",
|
|
3314
|
+
"union",
|
|
3315
|
+
"values",
|
|
3316
|
+
"set",
|
|
3317
|
+
"update",
|
|
3318
|
+
"insert",
|
|
3319
|
+
"delete",
|
|
3320
|
+
"into",
|
|
3321
|
+
"table",
|
|
3322
|
+
"create",
|
|
3323
|
+
"alter",
|
|
3324
|
+
"drop",
|
|
3325
|
+
"index",
|
|
3326
|
+
"view",
|
|
3327
|
+
"as",
|
|
3328
|
+
"on",
|
|
3329
|
+
"and",
|
|
3330
|
+
"or",
|
|
3331
|
+
"not",
|
|
3332
|
+
"in",
|
|
3333
|
+
"is",
|
|
3334
|
+
"null",
|
|
3335
|
+
"true",
|
|
3336
|
+
"false",
|
|
3337
|
+
"case",
|
|
3338
|
+
"when",
|
|
3339
|
+
"then",
|
|
3340
|
+
"else",
|
|
3341
|
+
"end",
|
|
3342
|
+
"with",
|
|
3343
|
+
"the",
|
|
3344
|
+
"lateral",
|
|
3345
|
+
"generate_series",
|
|
3346
|
+
"unnest"
|
|
3347
|
+
].includes(baseName)) continue;
|
|
3348
|
+
if (!refs.has(tableName)) refs.set(tableName, 0);
|
|
3349
|
+
refs.set(tableName, refs.get(tableName) + 1);
|
|
3350
|
+
}
|
|
3351
|
+
return refs;
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* Classifies a card's SQL pattern:
|
|
3355
|
+
* - analytical: GROUP BY + aggregation (core semantic layer candidates)
|
|
3356
|
+
* - lookup: Single-row lookup (WHERE email={{x}}, no GROUP BY) — CRM/operational
|
|
3357
|
+
* - display: UNION-based formatting/layout without aggregation
|
|
3358
|
+
* - unknown: non-native or unclassifiable
|
|
3359
|
+
*/
|
|
3360
|
+
function classifyCardPattern(card, sqlOverride) {
|
|
3361
|
+
const sql = sqlOverride ?? extractSQL(card);
|
|
3362
|
+
if (!sql) return "unknown";
|
|
3363
|
+
const upper = sql.toUpperCase();
|
|
3364
|
+
const hasGroupBy = /\bGROUP\s+BY\b/.test(upper);
|
|
3365
|
+
const hasAgg = /\b(COUNT|SUM|AVG|MIN|MAX|PERCENTILE)\s*\(/.test(upper);
|
|
3366
|
+
const hasUnion = /\bUNION\b/.test(upper);
|
|
3367
|
+
const hasTemplateVar = /\{\{[^}]+\}\}/.test(sql);
|
|
3368
|
+
const hasLookupVar = /\{\{(email|primary_mail|user|customer|name|phone|id)\}\}/i.test(sql);
|
|
3369
|
+
if (hasGroupBy && hasAgg) return "analytical";
|
|
3370
|
+
if (hasLookupVar && !hasGroupBy && !hasAgg) return "lookup";
|
|
3371
|
+
if (hasUnion && !hasGroupBy && !hasAgg) return "display";
|
|
3372
|
+
if (hasAgg && !hasGroupBy) return "analytical";
|
|
3373
|
+
if (hasTemplateVar && hasAgg) return "analytical";
|
|
3374
|
+
return "unknown";
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Extracts Metabase template variable names ({{var}}) from SQL.
|
|
3378
|
+
*/
|
|
3379
|
+
function extractTemplateVars(sql) {
|
|
3380
|
+
const vars = /* @__PURE__ */ new Set();
|
|
3381
|
+
const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
3382
|
+
let m;
|
|
3383
|
+
while ((m = pattern.exec(sql)) !== null) vars.add(m[1].toLowerCase());
|
|
3384
|
+
return vars;
|
|
3385
|
+
}
|
|
3386
|
+
const INACTIVE_MONTHS = 3;
|
|
3387
|
+
function isCardActive(card) {
|
|
3388
|
+
if (!card.last_used_at) return false;
|
|
3389
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
3390
|
+
cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS);
|
|
3391
|
+
return new Date(card.last_used_at) >= cutoff;
|
|
3392
|
+
}
|
|
3393
|
+
/**
|
|
3394
|
+
* Score that weights view_count by recency.
|
|
3395
|
+
* Active cards (used in last 3 months) keep full view_count.
|
|
3396
|
+
* Inactive cards are penalized by 90%.
|
|
3397
|
+
*/
|
|
3398
|
+
function activityScore(card) {
|
|
3399
|
+
const views = card.view_count || 0;
|
|
3400
|
+
return isCardActive(card) ? views : Math.round(views * .1);
|
|
3401
|
+
}
|
|
3402
|
+
function formatLastUsed(card) {
|
|
3403
|
+
if (!card.last_used_at) return "never";
|
|
3404
|
+
const d = new Date(card.last_used_at);
|
|
3405
|
+
const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
|
|
3406
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
3407
|
+
if (diffDays === 0) return "today";
|
|
3408
|
+
if (diffDays === 1) return "yesterday";
|
|
3409
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
3410
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
3411
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
3412
|
+
}
|
|
3413
|
+
function getCardType(card) {
|
|
3414
|
+
if (card.type === "model" || card.dataset) return "model";
|
|
3415
|
+
if (card.type === "metric") return "metric";
|
|
3416
|
+
return "question";
|
|
3417
|
+
}
|
|
3418
|
+
function extractSchemaAccess(graph, groups, dbId) {
|
|
3419
|
+
const groupMap = new Map(groups.map((g) => [String(g.id), g.name]));
|
|
3420
|
+
const schemaAccessMap = /* @__PURE__ */ new Map();
|
|
3421
|
+
for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
|
|
3422
|
+
const groupName = groupMap.get(groupId) || `Group ${groupId}`;
|
|
3423
|
+
const perms = dbPerms[String(dbId)];
|
|
3424
|
+
if (!perms) continue;
|
|
3425
|
+
const createQueries = perms["create-queries"];
|
|
3426
|
+
if (!createQueries) continue;
|
|
3427
|
+
if (typeof createQueries === "string") {
|
|
3428
|
+
if (createQueries !== "no") {
|
|
3429
|
+
if (!schemaAccessMap.has("*")) schemaAccessMap.set("*", []);
|
|
3430
|
+
schemaAccessMap.get("*").push(groupName);
|
|
3431
|
+
}
|
|
3432
|
+
continue;
|
|
3433
|
+
}
|
|
3434
|
+
for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") {
|
|
3435
|
+
if (!schemaAccessMap.has(schema)) schemaAccessMap.set(schema, []);
|
|
3436
|
+
schemaAccessMap.get(schema).push(groupName);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
return Array.from(schemaAccessMap.entries()).map(([schema, grps]) => ({
|
|
3440
|
+
schema,
|
|
3441
|
+
groups: grps
|
|
3442
|
+
})).sort((a, b) => a.schema.localeCompare(b.schema));
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* Returns the set of schemas accessible to non-admin groups for a given database.
|
|
3446
|
+
* Returns null if a non-admin group has wildcard access (all schemas are user-facing),
|
|
3447
|
+
* or if no explicit schema permissions are found.
|
|
3448
|
+
*/
|
|
3449
|
+
function getUserFacingSchemas(graph, groups, dbId) {
|
|
3450
|
+
const adminGroupIds = new Set(groups.filter((g) => g.name === "Administrators").map((g) => String(g.id)));
|
|
3451
|
+
const schemas = /* @__PURE__ */ new Set();
|
|
3452
|
+
for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
|
|
3453
|
+
if (adminGroupIds.has(groupId)) continue;
|
|
3454
|
+
const perms = dbPerms[String(dbId)];
|
|
3455
|
+
if (!perms) continue;
|
|
3456
|
+
const createQueries = perms["create-queries"];
|
|
3457
|
+
if (!createQueries) continue;
|
|
3458
|
+
if (typeof createQueries === "string") {
|
|
3459
|
+
if (createQueries !== "no") return null;
|
|
3460
|
+
continue;
|
|
3461
|
+
}
|
|
3462
|
+
for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") schemas.add(schema);
|
|
3463
|
+
}
|
|
3464
|
+
return schemas.size > 0 ? schemas : null;
|
|
3465
|
+
}
|
|
3466
|
+
function flattenCollections(nodes, parentPath = "") {
|
|
3467
|
+
const result = [];
|
|
3468
|
+
for (const node of nodes) {
|
|
3469
|
+
if (node.personal_owner_id !== null) continue;
|
|
3470
|
+
const p = parentPath ? `${parentPath}/${node.name}` : node.name;
|
|
3471
|
+
result.push({
|
|
3472
|
+
id: node.id,
|
|
3473
|
+
path: p
|
|
3474
|
+
});
|
|
3475
|
+
if (node.children.length > 0) result.push(...flattenCollections(node.children, p));
|
|
3476
|
+
}
|
|
3477
|
+
return result;
|
|
3478
|
+
}
|
|
3479
|
+
const MAX_TREE_DEPTH = 2;
|
|
3480
|
+
function renderTree(nodes, indent = 0) {
|
|
3481
|
+
let out = "";
|
|
3482
|
+
for (const node of nodes) {
|
|
3483
|
+
if (node.personal_owner_id !== null) continue;
|
|
3484
|
+
const prefix = " ".repeat(indent);
|
|
3485
|
+
const icon = node.children.length > 0 ? "+" : "-";
|
|
3486
|
+
out += `${prefix}${icon} ${node.name} (${node.id})\n`;
|
|
3487
|
+
if (node.children.length > 0 && indent < MAX_TREE_DEPTH - 1) out += renderTree(node.children, indent + 1);
|
|
3488
|
+
else if (node.children.length > 0) {
|
|
3489
|
+
const childCount = node.children.filter((c) => c.personal_owner_id === null).length;
|
|
3490
|
+
if (childCount > 0) out += `${prefix} ... ${childCount} sub-collections\n`;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
return out;
|
|
3494
|
+
}
|
|
3495
|
+
function aggregateParameters(dashboards) {
|
|
3496
|
+
const paramMap = /* @__PURE__ */ new Map();
|
|
3497
|
+
for (const d of dashboards) for (const p of d.parameters) {
|
|
3498
|
+
const key = `${p.type}:${p.slug}`;
|
|
3499
|
+
if (!paramMap.has(key)) paramMap.set(key, {
|
|
3500
|
+
name: p.name,
|
|
3501
|
+
type: p.type,
|
|
3502
|
+
slug: p.slug,
|
|
3503
|
+
dashboardCount: 0,
|
|
3504
|
+
dashboards: []
|
|
3505
|
+
});
|
|
3506
|
+
const entry = paramMap.get(key);
|
|
3507
|
+
entry.dashboardCount++;
|
|
3508
|
+
entry.dashboards.push(d.name);
|
|
3509
|
+
}
|
|
3510
|
+
return Array.from(paramMap.values()).sort((a, b) => b.dashboardCount - a.dashboardCount);
|
|
3511
|
+
}
|
|
3512
|
+
function summarizeTable(table) {
|
|
3513
|
+
let dimensions = 0, measures = 0, timeDimensions = 0;
|
|
3514
|
+
for (const f of table.fields) {
|
|
3515
|
+
if (f.visibility_type === "hidden" || f.visibility_type === "retired") continue;
|
|
3516
|
+
const cls = classifyField(f);
|
|
3517
|
+
if (cls === "dimension") dimensions++;
|
|
3518
|
+
else if (cls === "measure") measures++;
|
|
3519
|
+
else if (cls === "time") timeDimensions++;
|
|
3520
|
+
}
|
|
3521
|
+
return {
|
|
3522
|
+
id: table.id,
|
|
3523
|
+
name: table.name,
|
|
3524
|
+
schema: table.schema,
|
|
3525
|
+
description: table.description,
|
|
3526
|
+
fieldCount: table.fields.length,
|
|
3527
|
+
dimensions,
|
|
3528
|
+
measures,
|
|
3529
|
+
timeDimensions,
|
|
3530
|
+
hasDescription: !!table.description
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function buildReport(data) {
|
|
3534
|
+
const { instanceUrl, user, databases, cards, dashboardList, dashboardDetails, collectionTree, collectionMap, permissionGroups, permissionsGraph, topCardsLimit, convertedSqlMap } = data;
|
|
3535
|
+
/** Get SQL for a card — native extraction first, then converted MBQL fallback. */
|
|
3536
|
+
function getCardSQL(card) {
|
|
3537
|
+
return extractSQL(card) || convertedSqlMap.get(card.id) || null;
|
|
3538
|
+
}
|
|
3539
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
3540
|
+
const activeDashboards = dashboardList.filter((d) => !d.archived);
|
|
3541
|
+
const models = activeCards.filter((c) => getCardType(c) === "model");
|
|
3542
|
+
const metrics = activeCards.filter((c) => getCardType(c) === "metric");
|
|
3543
|
+
const questions = activeCards.filter((c) => getCardType(c) === "question");
|
|
3544
|
+
const topCards = [...activeCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, topCardsLimit);
|
|
3545
|
+
const activeCount = activeCards.filter(isCardActive).length;
|
|
3546
|
+
const inactiveCount = activeCards.length - activeCount;
|
|
3547
|
+
const userFacingSchemasMap = /* @__PURE__ */ new Map();
|
|
3548
|
+
if (permissionGroups && permissionsGraph) for (const db of databases) userFacingSchemasMap.set(db.id, getUserFacingSchemas(permissionsGraph, permissionGroups, db.id));
|
|
3549
|
+
let report = "";
|
|
3550
|
+
report += `# Metabase Analysis Report\n\n`;
|
|
3551
|
+
report += `Generated: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n`;
|
|
3552
|
+
report += `Instance: ${instanceUrl}\n`;
|
|
3553
|
+
report += `User: ${user.name} (${user.email})\n\n`;
|
|
3554
|
+
report += `## How to Use This Report\n\n`;
|
|
3555
|
+
report += `This report maps your Metabase instance to inform semantic layer design:\n\n`;
|
|
3556
|
+
report += `1. **Most Referenced Tables** → Create cubes for these tables first\n`;
|
|
3557
|
+
report += `2. **Top Cards by Activity** → Replicate aggregations (GROUP BY + SUM/COUNT/AVG) as cube measures\n`;
|
|
3558
|
+
report += `3. **Common Filter Variables** → Ensure these are dimensions on relevant cubes\n`;
|
|
3559
|
+
report += `4. **Foreign Key Relationships** → Define joins between cubes\n`;
|
|
3560
|
+
report += `5. **Collection Structure** → Map collections to views (one view per business domain)\n`;
|
|
3561
|
+
report += `6. **Table Inventory** → Use field classification (dims/measures/time) to build each cube\n\n`;
|
|
3562
|
+
report += `Drill deeper with:\n`;
|
|
3563
|
+
report += `- \`bon metabase explore table <id>\` — field types and classification\n`;
|
|
3564
|
+
report += `- \`bon metabase explore card <id>\` — SQL and columns\n`;
|
|
3565
|
+
report += `- \`bon metabase explore collection <id>\` — cards in a collection\n`;
|
|
3566
|
+
report += `- \`bon metabase explore database <id>\` — schemas and tables\n\n`;
|
|
3567
|
+
report += `## Summary\n\n`;
|
|
3568
|
+
report += `| Metric | Count |\n|--------|-------|\n`;
|
|
3569
|
+
report += `| Databases | ${databases.length} |\n`;
|
|
3570
|
+
for (const db of databases) {
|
|
3571
|
+
const visibleTables = db.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
|
|
3572
|
+
report += `| Tables (${db.name}) | ${visibleTables.length} |\n`;
|
|
3573
|
+
}
|
|
3574
|
+
report += `| Models | ${models.length} |\n`;
|
|
3575
|
+
report += `| Metrics | ${metrics.length} |\n`;
|
|
3576
|
+
report += `| Questions | ${questions.length} |\n`;
|
|
3577
|
+
report += `| Dashboards | ${activeDashboards.length} |\n`;
|
|
3578
|
+
report += `| Collections | ${collectionMap.size} |\n`;
|
|
3579
|
+
report += `| Active cards (used in last ${INACTIVE_MONTHS}mo) | ${activeCount} |\n`;
|
|
3580
|
+
report += `| Inactive cards | ${inactiveCount} |\n`;
|
|
3581
|
+
report += `\n`;
|
|
3582
|
+
if (permissionGroups && permissionsGraph) {
|
|
3583
|
+
report += `## Schema Access\n\n`;
|
|
3584
|
+
report += `Schemas accessible to non-admin groups are user-facing and should be prioritized for modeling.\n\n`;
|
|
3585
|
+
for (const db of databases) {
|
|
3586
|
+
const access = extractSchemaAccess(permissionsGraph, permissionGroups, db.id);
|
|
3587
|
+
report += `### ${db.name} (${db.engine})\n\n`;
|
|
3588
|
+
report += `| Schema | Accessible To |\n|--------|---------------|\n`;
|
|
3589
|
+
for (const a of access) report += `| ${a.schema} | ${a.groups.join(", ")} |\n`;
|
|
3590
|
+
report += `\n`;
|
|
3591
|
+
}
|
|
3592
|
+
report += `### Permission Groups\n\n`;
|
|
3593
|
+
report += `| Group | Members |\n|-------|---------|\n`;
|
|
3594
|
+
for (const g of permissionGroups) report += `| ${g.name} | ${g.member_count} |\n`;
|
|
3595
|
+
report += `\n`;
|
|
3596
|
+
}
|
|
3597
|
+
report += `## Collection Structure (Business Domains)\n\n`;
|
|
3598
|
+
report += `Collections represent how users organize content by business area.\n`;
|
|
3599
|
+
report += `Map these to views in the semantic layer.\n\n`;
|
|
3600
|
+
report += "```\n";
|
|
3601
|
+
const filtered = collectionTree.filter((n) => n.personal_owner_id === null);
|
|
3602
|
+
report += renderTree(filtered);
|
|
3603
|
+
report += "```\n\n";
|
|
3604
|
+
report += `## Top ${topCards.length} Cards by Activity\n\n`;
|
|
3605
|
+
report += `Ranked by view count, weighted by recency. Cards not used in the last ${INACTIVE_MONTHS} months are penalized 90%.\n`;
|
|
3606
|
+
report += `Use \`bon metabase explore card <id>\` to view SQL and column details for any card.\n\n`;
|
|
3607
|
+
report += `| Rank | ID | Views | Last Used | Active | Pattern | Type | Display | Collection | Name |\n`;
|
|
3608
|
+
report += `|------|----|-------|-----------|--------|---------|------|---------|------------|------|\n`;
|
|
3609
|
+
for (let i = 0; i < topCards.length; i++) {
|
|
3610
|
+
const c = topCards[i];
|
|
3611
|
+
const ct = getCardType(c);
|
|
3612
|
+
const col = c.collection_id ? collectionMap.get(c.collection_id) || "?" : "Root";
|
|
3613
|
+
const active = isCardActive(c) ? "Yes" : "No";
|
|
3614
|
+
const lastUsed = formatLastUsed(c);
|
|
3615
|
+
const pattern = classifyCardPattern(c, getCardSQL(c));
|
|
3616
|
+
report += `| ${i + 1} | ${c.id} | ${c.view_count || 0} | ${lastUsed} | ${active} | ${pattern} | ${ct} | ${c.display} | ${col} | ${c.name} |\n`;
|
|
3617
|
+
}
|
|
3618
|
+
report += `\n`;
|
|
3619
|
+
if (dashboardDetails.length > 0) {
|
|
3620
|
+
const params = aggregateParameters(dashboardDetails);
|
|
3621
|
+
report += `## Dashboard Filter Parameters\n\n`;
|
|
3622
|
+
report += `Parameters used across dashboards. These represent the most important filter dimensions.\n\n`;
|
|
3623
|
+
if (params.length > 0) {
|
|
3624
|
+
report += `| Parameter | Type | Used In (dashboards) | Dashboard Names |\n`;
|
|
3625
|
+
report += `|-----------|------|----------------------|-----------------|\n`;
|
|
3626
|
+
for (const p of params) {
|
|
3627
|
+
const names = p.dashboards.slice(0, 3).join(", ");
|
|
3628
|
+
const more = p.dashboards.length > 3 ? ` +${p.dashboards.length - 3} more` : "";
|
|
3629
|
+
report += `| ${p.name} | ${p.type} | ${p.dashboardCount} | ${names}${more} |\n`;
|
|
3630
|
+
}
|
|
3631
|
+
report += `\n`;
|
|
3632
|
+
} else report += `No parameters found across analyzed dashboards.\n\n`;
|
|
3633
|
+
}
|
|
3634
|
+
const templateVarCounts = /* @__PURE__ */ new Map();
|
|
3635
|
+
for (const c of activeCards) {
|
|
3636
|
+
const sql = getCardSQL(c);
|
|
3637
|
+
if (!sql) continue;
|
|
3638
|
+
const vars = extractTemplateVars(sql);
|
|
3639
|
+
for (const v of vars) templateVarCounts.set(v, (templateVarCounts.get(v) || 0) + 1);
|
|
3640
|
+
}
|
|
3641
|
+
if (templateVarCounts.size > 0) {
|
|
3642
|
+
const sortedVars = Array.from(templateVarCounts.entries()).sort((a, b) => b[1] - a[1]).filter(([, count]) => count >= 3);
|
|
3643
|
+
if (sortedVars.length > 0) {
|
|
3644
|
+
const totalVars = templateVarCounts.size;
|
|
3645
|
+
report += `## Common Filter Variables (from SQL)\n\n`;
|
|
3646
|
+
report += `Template variables (\`{{var}}\`) used in 3+ cards. These represent key filter dimensions.\n`;
|
|
3647
|
+
report += `${totalVars} unique variables found; showing ${sortedVars.length} most common.\n\n`;
|
|
3648
|
+
report += `| Variable | Used In (cards) |\n|----------|-----------------|\n`;
|
|
3649
|
+
for (const [varName, count] of sortedVars) report += `| ${varName} | ${count} |\n`;
|
|
3650
|
+
report += `\n`;
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
const globalTableRefs = /* @__PURE__ */ new Map();
|
|
3654
|
+
for (const c of activeCards) {
|
|
3655
|
+
const sql = getCardSQL(c);
|
|
3656
|
+
if (!sql) continue;
|
|
3657
|
+
const refs = extractTableReferences(sql);
|
|
3658
|
+
for (const [table, count] of refs) globalTableRefs.set(table, (globalTableRefs.get(table) || 0) + count);
|
|
3659
|
+
}
|
|
3660
|
+
if (globalTableRefs.size > 0) {
|
|
3661
|
+
const sortedRefs = Array.from(globalTableRefs.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20);
|
|
3662
|
+
report += `## Most Referenced Tables (from SQL)\n\n`;
|
|
3663
|
+
report += `Tables most frequently referenced in FROM/JOIN clauses across all cards.\n\n`;
|
|
3664
|
+
report += `| Table | References |\n|-------|------------|\n`;
|
|
3665
|
+
for (const [table, count] of sortedRefs) report += `| ${table} | ${count} |\n`;
|
|
3666
|
+
report += `\n`;
|
|
3667
|
+
}
|
|
3668
|
+
const fieldIdLookup = /* @__PURE__ */ new Map();
|
|
3669
|
+
for (const db of databases) for (const t of db.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
|
|
3670
|
+
schema: t.schema,
|
|
3671
|
+
table: t.name,
|
|
3672
|
+
field: f.name
|
|
3673
|
+
});
|
|
3674
|
+
const fkRelationships = [];
|
|
3675
|
+
for (const db of databases) {
|
|
3676
|
+
const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
|
|
3677
|
+
for (const t of db.tables) {
|
|
3678
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(t.schema)) continue;
|
|
3679
|
+
for (const f of t.fields) {
|
|
3680
|
+
if (!f.fk_target_field_id) continue;
|
|
3681
|
+
const target = fieldIdLookup.get(f.fk_target_field_id);
|
|
3682
|
+
if (!target) continue;
|
|
3683
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(target.schema)) continue;
|
|
3684
|
+
fkRelationships.push({
|
|
3685
|
+
fromSchema: t.schema,
|
|
3686
|
+
fromTable: t.name,
|
|
3687
|
+
fromField: f.name,
|
|
3688
|
+
toSchema: target.schema,
|
|
3689
|
+
toTable: target.table,
|
|
3690
|
+
toField: target.field
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
if (fkRelationships.length > 0) {
|
|
3696
|
+
const referencedFKs = fkRelationships.filter((fk) => {
|
|
3697
|
+
const fromKey = `${fk.fromSchema}.${fk.fromTable}`.toLowerCase();
|
|
3698
|
+
const toKey = `${fk.toSchema}.${fk.toTable}`.toLowerCase();
|
|
3699
|
+
return globalTableRefs.has(fromKey) || globalTableRefs.has(toKey);
|
|
3700
|
+
});
|
|
3701
|
+
if (referencedFKs.length > 0) {
|
|
3702
|
+
report += `## Foreign Key Relationships\n\n`;
|
|
3703
|
+
report += `FK relationships involving tables referenced by cards. Use these to define cube joins.\n`;
|
|
3704
|
+
if (referencedFKs.length < fkRelationships.length) report += `${fkRelationships.length - referencedFKs.length} unreferenced FKs omitted.\n`;
|
|
3705
|
+
report += `\n`;
|
|
3706
|
+
report += `| From | Field | To | Field |\n`;
|
|
3707
|
+
report += `|------|-------|----|-------|\n`;
|
|
3708
|
+
for (const fk of referencedFKs) {
|
|
3709
|
+
const from = fk.fromSchema === fk.toSchema ? fk.fromTable : `${fk.fromSchema}.${fk.fromTable}`;
|
|
3710
|
+
const to = fk.fromSchema === fk.toSchema ? fk.toTable : `${fk.toSchema}.${fk.toTable}`;
|
|
3711
|
+
report += `| ${from} | ${fk.fromField} | ${to} | ${fk.toField} |\n`;
|
|
3712
|
+
}
|
|
3713
|
+
report += `\n`;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
report += `## Table Inventory\n\n`;
|
|
3717
|
+
if (permissionGroups && permissionsGraph) {
|
|
3718
|
+
report += `Showing tables from user-facing schemas only (accessible to non-admin groups).\n`;
|
|
3719
|
+
report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
|
|
3720
|
+
} else {
|
|
3721
|
+
report += `Permissions data unavailable — showing all schemas.\n`;
|
|
3722
|
+
report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
|
|
3723
|
+
}
|
|
3724
|
+
report += `Use \`bon metabase explore databases\` for full database details.\n\n`;
|
|
3725
|
+
report += `Field classification: **Dims** = categorical/text/PKs/FKs, **Measures** = numeric, **Time** = date/datetime\n\n`;
|
|
3726
|
+
let skippedSchemas = 0;
|
|
3727
|
+
let skippedTables = 0;
|
|
3728
|
+
for (const db of databases) {
|
|
3729
|
+
const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
|
|
3730
|
+
const bySchema = /* @__PURE__ */ new Map();
|
|
3731
|
+
for (const t of db.tables) {
|
|
3732
|
+
if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
|
|
3733
|
+
if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
|
|
3734
|
+
bySchema.get(t.schema).push(t);
|
|
3735
|
+
}
|
|
3736
|
+
for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
3737
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(schema)) {
|
|
3738
|
+
skippedSchemas++;
|
|
3739
|
+
skippedTables += tables.length;
|
|
3740
|
+
continue;
|
|
3741
|
+
}
|
|
3742
|
+
const filteredTables = tables.filter((t) => !t.name.startsWith("stg_") && !t.name.startsWith("int_"));
|
|
3743
|
+
skippedTables += tables.length - filteredTables.length;
|
|
3744
|
+
if (filteredTables.length === 0) continue;
|
|
3745
|
+
const summaries = filteredTables.map((t) => {
|
|
3746
|
+
const s = summarizeTable(t);
|
|
3747
|
+
const refKey1 = `${s.schema}.${s.name}`.toLowerCase();
|
|
3748
|
+
const refKey2 = s.name.toLowerCase();
|
|
3749
|
+
const refCount = globalTableRefs.get(refKey1) || globalTableRefs.get(refKey2) || 0;
|
|
3750
|
+
return {
|
|
3751
|
+
...s,
|
|
3752
|
+
refCount
|
|
3753
|
+
};
|
|
3754
|
+
}).sort((a, b) => b.refCount - a.refCount || a.name.localeCompare(b.name));
|
|
3755
|
+
if (!summaries.some((s) => s.refCount > 0)) {
|
|
3756
|
+
skippedSchemas++;
|
|
3757
|
+
skippedTables += filteredTables.length;
|
|
3758
|
+
continue;
|
|
3759
|
+
}
|
|
3760
|
+
const referenced = summaries.filter((s) => s.refCount > 0);
|
|
3761
|
+
const unreferenced = summaries.length - referenced.length;
|
|
3762
|
+
report += `### ${db.name} / ${schema} (${referenced.length} referenced`;
|
|
3763
|
+
if (unreferenced > 0) report += `, ${unreferenced} unreferenced`;
|
|
3764
|
+
report += `)\n\n`;
|
|
3765
|
+
report += `| Table | Fields | Dims | Measures | Time | Refs |\n`;
|
|
3766
|
+
report += `|-------|--------|------|----------|------|------|\n`;
|
|
3767
|
+
for (const s of referenced) report += `| ${s.name} | ${s.fieldCount} | ${s.dimensions} | ${s.measures} | ${s.timeDimensions} | ${s.refCount} |\n`;
|
|
3768
|
+
if (unreferenced > 0) skippedTables += unreferenced;
|
|
3769
|
+
report += `\n`;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
if (skippedSchemas > 0 || skippedTables > 0) report += `*${skippedSchemas} admin-only schemas and ${skippedTables} staging/intermediate tables omitted.*\n\n`;
|
|
3773
|
+
report += `## Cards by Domain\n\n`;
|
|
3774
|
+
report += `Card counts and top questions per collection (by view count).\n\n`;
|
|
3775
|
+
const cardsByCollection = /* @__PURE__ */ new Map();
|
|
3776
|
+
for (const c of activeCards) {
|
|
3777
|
+
const col = c.collection_id ? collectionMap.get(c.collection_id) || "Uncategorized" : "Root";
|
|
3778
|
+
if (!cardsByCollection.has(col)) cardsByCollection.set(col, []);
|
|
3779
|
+
cardsByCollection.get(col).push(c);
|
|
3780
|
+
}
|
|
3781
|
+
const sortedCollections = Array.from(cardsByCollection.entries()).map(([col, colCards]) => ({
|
|
3782
|
+
col,
|
|
3783
|
+
colCards,
|
|
3784
|
+
activeCount: colCards.filter(isCardActive).length
|
|
3785
|
+
})).filter((c) => c.activeCount > 0).sort((a, b) => b.activeCount - a.activeCount);
|
|
3786
|
+
for (const { col, colCards, activeCount } of sortedCollections) {
|
|
3787
|
+
const topNames = [...colCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 3).map((c) => c.name).join(", ");
|
|
3788
|
+
report += `- **${col}** (${colCards.length} cards, ${activeCount} active): ${topNames}\n`;
|
|
3789
|
+
}
|
|
3790
|
+
const inactiveCollections = cardsByCollection.size - sortedCollections.length;
|
|
3791
|
+
if (inactiveCollections > 0) report += `\n*${inactiveCollections} collections with no active cards omitted.*\n`;
|
|
3792
|
+
report += `\n`;
|
|
3793
|
+
return report;
|
|
3794
|
+
}
|
|
3795
|
+
async function metabaseAnalyzeCommand(options) {
|
|
3796
|
+
const config = loadMetabaseConfig();
|
|
3797
|
+
if (!config) {
|
|
3798
|
+
console.error(pc.red("Metabase is not configured."));
|
|
3799
|
+
console.log(pc.dim("Run: bon metabase connect"));
|
|
3800
|
+
process.exit(1);
|
|
3801
|
+
}
|
|
3802
|
+
const client = createMetabaseClient(config);
|
|
3803
|
+
const outputPath = options.output || OUTPUT_FILE;
|
|
3804
|
+
const topCardsLimit = options.topCards ? parseInt(options.topCards, 10) : TOP_CARDS_LIMIT;
|
|
3805
|
+
console.log();
|
|
3806
|
+
console.log(pc.dim("Fetching data from Metabase..."));
|
|
3807
|
+
const [user, cards, databases, collectionTree, dashboardList] = await Promise.all([
|
|
3808
|
+
client.getCurrentUser(),
|
|
3809
|
+
client.getCards(),
|
|
3810
|
+
client.getDatabases(),
|
|
3811
|
+
client.getCollectionTree(),
|
|
3812
|
+
client.getDashboards()
|
|
3813
|
+
]);
|
|
3814
|
+
console.log(pc.dim(` ${cards.length} cards, ${databases.length} databases, ${dashboardList.filter((d) => !d.archived).length} dashboards`));
|
|
3815
|
+
let permissionGroups = null;
|
|
3816
|
+
let permissionsGraph = null;
|
|
3817
|
+
try {
|
|
3818
|
+
[permissionGroups, permissionsGraph] = await Promise.all([client.getPermissionGroups(), client.getPermissionsGraph()]);
|
|
3819
|
+
console.log(pc.dim(` ${permissionGroups.length} permission groups`));
|
|
3820
|
+
} catch (err) {
|
|
3821
|
+
if (err instanceof MetabaseApiError && (err.status === 401 || err.status === 403)) console.log(pc.dim(" Permissions: skipped (requires admin API key)"));
|
|
3822
|
+
else throw err;
|
|
3823
|
+
}
|
|
3824
|
+
console.log(pc.dim("Fetching database metadata..."));
|
|
3825
|
+
const dbMetadata = [];
|
|
3826
|
+
for (const db of databases) {
|
|
3827
|
+
const meta = await client.getDatabaseMetadata(db.id);
|
|
3828
|
+
const visibleTables = meta.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
|
|
3829
|
+
console.log(pc.dim(` ${db.name}: ${visibleTables.length} tables`));
|
|
3830
|
+
dbMetadata.push(meta);
|
|
3831
|
+
}
|
|
3832
|
+
console.log(pc.dim("Fetching top dashboard details..."));
|
|
3833
|
+
const activeDashboards = dashboardList.filter((d) => !d.archived);
|
|
3834
|
+
let popularDashboardIds = /* @__PURE__ */ new Set();
|
|
3835
|
+
try {
|
|
3836
|
+
const popular = await client.getPopularItems();
|
|
3837
|
+
for (const item of popular) if (item.model === "dashboard") popularDashboardIds.add(item.model_id);
|
|
3838
|
+
} catch {}
|
|
3839
|
+
const dashboardsToFetch = [...activeDashboards].sort((a, b) => {
|
|
3840
|
+
const aPopular = popularDashboardIds.has(a.id) ? 1 : 0;
|
|
3841
|
+
return (popularDashboardIds.has(b.id) ? 1 : 0) - aPopular;
|
|
3842
|
+
}).slice(0, TOP_DASHBOARDS_LIMIT);
|
|
3843
|
+
const dashboardDetails = [];
|
|
3844
|
+
for (const d of dashboardsToFetch) try {
|
|
3845
|
+
const detail = await client.getDashboard(d.id);
|
|
3846
|
+
dashboardDetails.push(detail);
|
|
3847
|
+
} catch {}
|
|
3848
|
+
console.log(pc.dim(` ${dashboardDetails.length} dashboards analyzed`));
|
|
3849
|
+
const MBQL_CONVERT_LIMIT = 100;
|
|
3850
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
3851
|
+
const mbqlCards = activeCards.filter(isMbqlCard).sort((a, b) => activityScore(b) - activityScore(a)).slice(0, MBQL_CONVERT_LIMIT);
|
|
3852
|
+
const convertedSqlMap = /* @__PURE__ */ new Map();
|
|
3853
|
+
if (mbqlCards.length > 0) {
|
|
3854
|
+
const totalMbql = activeCards.filter(isMbqlCard).length;
|
|
3855
|
+
console.log(pc.dim(`Converting top ${mbqlCards.length} query-builder cards to SQL (${totalMbql} total, capped at ${MBQL_CONVERT_LIMIT})...`));
|
|
3856
|
+
const BATCH_SIZE = 10;
|
|
3857
|
+
for (let i = 0; i < mbqlCards.length; i += BATCH_SIZE) {
|
|
3858
|
+
const batch = mbqlCards.slice(i, i + BATCH_SIZE);
|
|
3859
|
+
const results = await Promise.allSettled(batch.map(async (c) => {
|
|
3860
|
+
const sql = await client.convertToNativeSQL(c.dataset_query);
|
|
3861
|
+
return {
|
|
3862
|
+
id: c.id,
|
|
3863
|
+
sql
|
|
3864
|
+
};
|
|
3865
|
+
}));
|
|
3866
|
+
for (const r of results) if (r.status === "fulfilled") convertedSqlMap.set(r.value.id, r.value.sql);
|
|
3867
|
+
}
|
|
3868
|
+
console.log(pc.dim(` ${convertedSqlMap.size}/${mbqlCards.length} converted successfully`));
|
|
3869
|
+
}
|
|
3870
|
+
const flatCollections = flattenCollections(collectionTree.filter((n) => n.personal_owner_id === null));
|
|
3871
|
+
const collectionMap = new Map(flatCollections.map((c) => [c.id, c.path]));
|
|
3872
|
+
console.log(pc.dim("Building report..."));
|
|
3873
|
+
const report = buildReport({
|
|
3874
|
+
instanceUrl: config.url,
|
|
3875
|
+
user: {
|
|
3876
|
+
name: `${user.first_name} ${user.last_name}`.trim(),
|
|
3877
|
+
email: user.email,
|
|
3878
|
+
admin: user.is_superuser
|
|
3879
|
+
},
|
|
3880
|
+
databases: dbMetadata,
|
|
3881
|
+
cards,
|
|
3882
|
+
dashboardList,
|
|
3883
|
+
dashboardDetails,
|
|
3884
|
+
collectionTree,
|
|
3885
|
+
collectionMap,
|
|
3886
|
+
permissionGroups,
|
|
3887
|
+
permissionsGraph,
|
|
3888
|
+
topCardsLimit,
|
|
3889
|
+
convertedSqlMap
|
|
3890
|
+
});
|
|
3891
|
+
ensureBonDir();
|
|
3892
|
+
const fullPath = path.resolve(outputPath);
|
|
3893
|
+
fs.writeFileSync(fullPath, report, "utf-8");
|
|
3894
|
+
const allActive = cards.filter((c) => !c.archived);
|
|
3895
|
+
const recentlyUsed = allActive.filter(isCardActive);
|
|
3896
|
+
const topCards = allActive.sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 5);
|
|
3897
|
+
console.log();
|
|
3898
|
+
console.log(pc.green(`✓ Analysis written to ${outputPath}`));
|
|
3899
|
+
console.log();
|
|
3900
|
+
console.log(pc.bold("Key findings:"));
|
|
3901
|
+
console.log();
|
|
3902
|
+
console.log(` ${databases.length} database(s), ${dbMetadata.reduce((sum, db) => sum + db.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired").length, 0)} tables`);
|
|
3903
|
+
console.log(` ${allActive.length} cards (${recentlyUsed.length} active in last ${INACTIVE_MONTHS}mo, ${allActive.length - recentlyUsed.length} inactive)`);
|
|
3904
|
+
console.log(` ${activeDashboards.length} dashboards across ${collectionMap.size} collections`);
|
|
3905
|
+
if (permissionGroups) {
|
|
3906
|
+
const nonAdminGroups = permissionGroups.filter((g) => g.name !== "Administrators");
|
|
3907
|
+
if (permissionsGraph) for (const db of databases) {
|
|
3908
|
+
const userSchemas = extractSchemaAccess(permissionsGraph, nonAdminGroups, db.id).filter((a) => a.schema !== "*");
|
|
3909
|
+
if (userSchemas.length > 0) console.log(` User-facing schemas: ${userSchemas.map((s) => s.schema).join(", ")}`);
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
console.log();
|
|
3913
|
+
console.log(pc.bold("Top cards (by activity):"));
|
|
3914
|
+
for (const c of topCards) {
|
|
3915
|
+
const lastUsed = formatLastUsed(c);
|
|
3916
|
+
const flag = isCardActive(c) ? "" : pc.dim(" (inactive)");
|
|
3917
|
+
console.log(` ${String(c.view_count || 0).padStart(6)} views ${lastUsed.padEnd(12)} ${c.name}${flag}`);
|
|
3918
|
+
}
|
|
3919
|
+
console.log();
|
|
3920
|
+
console.log(pc.dim(`Full report: ${outputPath}`));
|
|
3921
|
+
}
|
|
3922
|
+
|
|
2527
3923
|
//#endregion
|
|
2528
3924
|
//#region src/bin/bon.ts
|
|
2529
3925
|
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
@@ -2546,6 +3942,10 @@ program.command("annotate").description("Annotate deployment changes with reason
|
|
|
2546
3942
|
program.command("mcp").description("MCP connection info and setup instructions").action(mcpCommand).command("test").description("Test MCP server connectivity").action(mcpTestCommand);
|
|
2547
3943
|
program.command("query").description("Execute a query against the deployed semantic layer").argument("<query>", "JSON query or SQL (with --sql flag)").option("--sql", "Use SQL API instead of JSON format").option("--limit <limit>", "Max rows to return").option("--format <format>", "Output format: toon or json", "toon").action(cubeQueryCommand);
|
|
2548
3944
|
program.command("docs").description("Browse documentation for building cubes and views").argument("[topic]", "Topic to display (e.g., cubes, cubes.measures)").option("-r, --recursive", "Show topic and all child topics").option("-s, --search <query>", "Search topics for a keyword").option("-f, --format <format>", "Output format: markdown or json", "markdown").action(docsCommand).command("schema").description("Show JSON schema for a type (cube, view, measure, etc.)").argument("<type>", "Schema type to display").action(docsSchemaCommand);
|
|
3945
|
+
const metabase = program.command("metabase").description("Connect to and explore Metabase content");
|
|
3946
|
+
metabase.command("connect").description("Configure Metabase API connection").option("--url <url>", "Metabase instance URL").option("--api-key <key>", "Metabase API key").option("--force", "Overwrite existing configuration").action(metabaseConnectCommand);
|
|
3947
|
+
metabase.command("explore").description("Browse Metabase databases, collections, cards, and dashboards").argument("[resource]", "databases, collections, cards, dashboards, card, dashboard, database, table, collection").argument("[id]", "Resource ID (e.g. card <id>, dashboard <id>, database <id>, table <id>, collection <id>)").action(metabaseExploreCommand);
|
|
3948
|
+
metabase.command("analyze").description("Analyze Metabase instance and generate a structured report for semantic layer planning").option("--output <path>", "Output file path", ".bon/metabase-analysis.md").option("--top-cards <n>", "Number of top cards to include in report", "50").action(metabaseAnalyzeCommand);
|
|
2549
3949
|
program.parse();
|
|
2550
3950
|
|
|
2551
3951
|
//#endregion
|