@bonnard/cli 0.2.1 → 0.2.3
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 +1415 -132
- package/dist/bin/{cubes-Bf0IPYd7.mjs → cubes-9rklhdAJ.mjs} +1 -1
- package/dist/bin/push-mZujN1Ik.mjs +35 -0
- package/dist/bin/{validate-DEh1XQnH.mjs → validate-BdqZBH2n.mjs} +1 -1
- package/dist/docs/topics/workflow.deploy.md +1 -1
- package/dist/docs/topics/workflow.md +0 -1
- package/dist/docs/topics/workflow.validate.md +1 -1
- package/dist/templates/claude/skills/bonnard-get-started/SKILL.md +13 -15
- package/dist/templates/claude/skills/bonnard-metabase-migrate/SKILL.md +258 -0
- package/dist/templates/cursor/rules/bonnard-get-started.mdc +13 -15
- package/dist/templates/cursor/rules/bonnard-metabase-migrate.mdc +257 -0
- package/dist/templates/shared/bonnard.md +4 -1
- package/package.json +1 -1
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;
|
|
@@ -1485,7 +1490,7 @@ async function addManual(options) {
|
|
|
1485
1490
|
console.log();
|
|
1486
1491
|
console.log(pc.green(`✓ Datasource "${name}" saved to .bon/datasources.yaml`));
|
|
1487
1492
|
console.log();
|
|
1488
|
-
console.log(pc.dim(`
|
|
1493
|
+
console.log(pc.dim(`Connection will be tested during \`bon deploy\``));
|
|
1489
1494
|
}
|
|
1490
1495
|
/**
|
|
1491
1496
|
* Add the Contoso demo datasource (read-only retail dataset)
|
|
@@ -1518,7 +1523,7 @@ async function addDemo(options) {
|
|
|
1518
1523
|
console.log(pc.dim("Contoso is a read-only retail dataset with tables like:"));
|
|
1519
1524
|
console.log(pc.dim(" fact_sales, dim_product, dim_store, dim_customer"));
|
|
1520
1525
|
console.log();
|
|
1521
|
-
console.log(pc.dim(`
|
|
1526
|
+
console.log(pc.dim(`Connection will be tested during \`bon deploy\``));
|
|
1522
1527
|
}
|
|
1523
1528
|
/**
|
|
1524
1529
|
* Main datasource add command
|
|
@@ -1625,34 +1630,6 @@ async function datasourceListCommand(options = {}) {
|
|
|
1625
1630
|
if (showRemote) await listRemoteDatasources();
|
|
1626
1631
|
}
|
|
1627
1632
|
|
|
1628
|
-
//#endregion
|
|
1629
|
-
//#region src/commands/datasource/test.ts
|
|
1630
|
-
async function datasourceTestCommand(name) {
|
|
1631
|
-
if (!loadCredentials()) {
|
|
1632
|
-
console.log(pc.red("Not logged in. Run `bon login` to test datasources."));
|
|
1633
|
-
process.exit(1);
|
|
1634
|
-
}
|
|
1635
|
-
console.log(pc.dim(`Testing ${name} via remote API...`));
|
|
1636
|
-
console.log();
|
|
1637
|
-
try {
|
|
1638
|
-
const result = await post("/api/datasources/test", { name });
|
|
1639
|
-
if (result.success) {
|
|
1640
|
-
console.log(pc.green(result.message));
|
|
1641
|
-
if (result.details) {
|
|
1642
|
-
if (result.details.warehouse) console.log(pc.dim(` Warehouse: ${result.details.warehouse}`));
|
|
1643
|
-
if (result.details.account) console.log(pc.dim(` Account: ${result.details.account}`));
|
|
1644
|
-
if (result.details.latencyMs != null) console.log(pc.dim(` Latency: ${result.details.latencyMs}ms`));
|
|
1645
|
-
}
|
|
1646
|
-
} else {
|
|
1647
|
-
console.log(pc.red(result.message));
|
|
1648
|
-
process.exit(1);
|
|
1649
|
-
}
|
|
1650
|
-
} catch (err) {
|
|
1651
|
-
console.error(pc.red(`Failed to test data source: ${err.message}`));
|
|
1652
|
-
process.exit(1);
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
1633
|
//#endregion
|
|
1657
1634
|
//#region src/commands/datasource/remove.ts
|
|
1658
1635
|
async function datasourceRemoveCommand(name, options = {}) {
|
|
@@ -1687,93 +1664,6 @@ async function removeRemote(name) {
|
|
|
1687
1664
|
}
|
|
1688
1665
|
}
|
|
1689
1666
|
|
|
1690
|
-
//#endregion
|
|
1691
|
-
//#region src/commands/datasource/push.ts
|
|
1692
|
-
var push_exports = /* @__PURE__ */ __exportAll({
|
|
1693
|
-
datasourcePushCommand: () => datasourcePushCommand,
|
|
1694
|
-
pushDatasource: () => pushDatasource
|
|
1695
|
-
});
|
|
1696
|
-
/**
|
|
1697
|
-
* Push a local datasource to Bonnard server
|
|
1698
|
-
*/
|
|
1699
|
-
async function datasourcePushCommand(name, options = {}) {
|
|
1700
|
-
if (!loadCredentials()) {
|
|
1701
|
-
console.error(pc.red("Not logged in. Run `bon login` first."));
|
|
1702
|
-
process.exit(1);
|
|
1703
|
-
}
|
|
1704
|
-
const datasource = getLocalDatasource(name);
|
|
1705
|
-
if (!datasource) {
|
|
1706
|
-
console.error(pc.red(`Datasource "${name}" not found in .bon/datasources.yaml`));
|
|
1707
|
-
console.log(pc.dim("Run `bon datasource list --local` to see available datasources."));
|
|
1708
|
-
process.exit(1);
|
|
1709
|
-
}
|
|
1710
|
-
const { resolved, missing } = resolveEnvVarsInCredentials(datasource.credentials);
|
|
1711
|
-
if (missing.length > 0) {
|
|
1712
|
-
console.error(pc.red(`Missing environment variables: ${missing.join(", ")}`));
|
|
1713
|
-
console.log(pc.dim("Set them in your environment or use plain values in .bon/datasources.yaml"));
|
|
1714
|
-
process.exit(1);
|
|
1715
|
-
}
|
|
1716
|
-
try {
|
|
1717
|
-
if ((await getRemoteDatasources()).some((ds) => ds.name === name) && !options.force) {
|
|
1718
|
-
if (!await confirm({
|
|
1719
|
-
message: `Datasource "${name}" already exists on remote. Overwrite?`,
|
|
1720
|
-
default: false
|
|
1721
|
-
})) {
|
|
1722
|
-
console.log(pc.dim("Aborted."));
|
|
1723
|
-
process.exit(0);
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
} catch (err) {
|
|
1727
|
-
console.log(pc.dim(`Note: Could not check remote datasources: ${err.message}`));
|
|
1728
|
-
}
|
|
1729
|
-
console.log(pc.dim(`Pushing "${name}"...`));
|
|
1730
|
-
try {
|
|
1731
|
-
await post("/api/datasources", {
|
|
1732
|
-
name: datasource.name,
|
|
1733
|
-
warehouse_type: datasource.type,
|
|
1734
|
-
config: datasource.config,
|
|
1735
|
-
credentials: resolved
|
|
1736
|
-
});
|
|
1737
|
-
console.log(pc.green(`✓ Datasource "${name}" pushed to Bonnard`));
|
|
1738
|
-
} catch (err) {
|
|
1739
|
-
const message = err.message;
|
|
1740
|
-
if (message.includes("already exists")) {
|
|
1741
|
-
console.error(pc.red(`Datasource "${name}" already exists on remote.`));
|
|
1742
|
-
console.log(pc.dim("Use --force to overwrite."));
|
|
1743
|
-
process.exit(1);
|
|
1744
|
-
}
|
|
1745
|
-
console.error(pc.red(`Failed to push datasource: ${message}`));
|
|
1746
|
-
process.exit(1);
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
/**
|
|
1750
|
-
* Push a datasource programmatically (for use by deploy command)
|
|
1751
|
-
* Returns true on success, false on failure
|
|
1752
|
-
*/
|
|
1753
|
-
async function pushDatasource(name, options = {}) {
|
|
1754
|
-
const datasource = getLocalDatasource(name);
|
|
1755
|
-
if (!datasource) {
|
|
1756
|
-
if (!options.silent) console.error(pc.red(`Datasource "${name}" not found locally`));
|
|
1757
|
-
return false;
|
|
1758
|
-
}
|
|
1759
|
-
const { resolved, missing } = resolveEnvVarsInCredentials(datasource.credentials);
|
|
1760
|
-
if (missing.length > 0) {
|
|
1761
|
-
if (!options.silent) console.error(pc.red(`Missing env vars for "${name}": ${missing.join(", ")}`));
|
|
1762
|
-
return false;
|
|
1763
|
-
}
|
|
1764
|
-
try {
|
|
1765
|
-
await post("/api/datasources", {
|
|
1766
|
-
name: datasource.name,
|
|
1767
|
-
warehouse_type: datasource.type,
|
|
1768
|
-
config: datasource.config,
|
|
1769
|
-
credentials: resolved
|
|
1770
|
-
});
|
|
1771
|
-
return true;
|
|
1772
|
-
} catch {
|
|
1773
|
-
return false;
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
1667
|
//#endregion
|
|
1778
1668
|
//#region src/commands/validate.ts
|
|
1779
1669
|
async function validateCommand() {
|
|
@@ -1783,7 +1673,7 @@ async function validateCommand() {
|
|
|
1783
1673
|
console.log(pc.red("No bon.yaml found. Are you in a Bonnard project?"));
|
|
1784
1674
|
process.exit(1);
|
|
1785
1675
|
}
|
|
1786
|
-
const { validate } = await import("./validate-
|
|
1676
|
+
const { validate } = await import("./validate-BdqZBH2n.mjs");
|
|
1787
1677
|
const result = await validate(cwd);
|
|
1788
1678
|
if (result.cubes.length === 0 && result.views.length === 0 && result.valid) {
|
|
1789
1679
|
console.log(pc.yellow(`No cube or view files found in ${BONNARD_DIR}/cubes/ or ${BONNARD_DIR}/views/.`));
|
|
@@ -1849,7 +1739,7 @@ async function deployCommand(options = {}) {
|
|
|
1849
1739
|
process.exit(1);
|
|
1850
1740
|
}
|
|
1851
1741
|
console.log(pc.dim("Validating cubes and views..."));
|
|
1852
|
-
const { validate } = await import("./validate-
|
|
1742
|
+
const { validate } = await import("./validate-BdqZBH2n.mjs");
|
|
1853
1743
|
const result = await validate(cwd);
|
|
1854
1744
|
if (!result.valid) {
|
|
1855
1745
|
console.log(pc.red("Validation failed:\n"));
|
|
@@ -1918,9 +1808,9 @@ async function deployCommand(options = {}) {
|
|
|
1918
1808
|
* Returns true if any connection failed (strict mode)
|
|
1919
1809
|
*/
|
|
1920
1810
|
async function testAndSyncDatasources(cwd, options = {}) {
|
|
1921
|
-
const { extractDatasourcesFromCubes } = await import("./cubes-
|
|
1811
|
+
const { extractDatasourcesFromCubes } = await import("./cubes-9rklhdAJ.mjs");
|
|
1922
1812
|
const { loadLocalDatasources } = await Promise.resolve().then(() => local_exports);
|
|
1923
|
-
const { pushDatasource } = await
|
|
1813
|
+
const { pushDatasource } = await import("./push-mZujN1Ik.mjs");
|
|
1924
1814
|
const references = extractDatasourcesFromCubes(cwd);
|
|
1925
1815
|
if (references.length === 0) return false;
|
|
1926
1816
|
console.log();
|
|
@@ -1959,7 +1849,7 @@ async function testAndSyncDatasources(cwd, options = {}) {
|
|
|
1959
1849
|
console.log();
|
|
1960
1850
|
if (options.ci) {
|
|
1961
1851
|
console.log(pc.red("Deploy aborted (--ci mode)."));
|
|
1962
|
-
console.log(pc.dim(`
|
|
1852
|
+
console.log(pc.dim(`Use --push-datasources to auto-push missing datasources`));
|
|
1963
1853
|
return true;
|
|
1964
1854
|
}
|
|
1965
1855
|
if (options.pushDatasources) for (const name of missingRemote) {
|
|
@@ -2524,6 +2414,1397 @@ async function cubeQueryCommand(queryInput, options = {}) {
|
|
|
2524
2414
|
}
|
|
2525
2415
|
}
|
|
2526
2416
|
|
|
2417
|
+
//#endregion
|
|
2418
|
+
//#region src/lib/metabase/config.ts
|
|
2419
|
+
/**
|
|
2420
|
+
* Metabase config storage (.bon/metabase.yaml)
|
|
2421
|
+
*
|
|
2422
|
+
* Stores API key and URL for Metabase connectivity.
|
|
2423
|
+
* File has 0o600 permissions since it contains the API key.
|
|
2424
|
+
*/
|
|
2425
|
+
const BON_DIR = ".bon";
|
|
2426
|
+
const CONFIG_FILE = "metabase.yaml";
|
|
2427
|
+
function getConfigPath(cwd = process.cwd()) {
|
|
2428
|
+
return path.join(cwd, BON_DIR, CONFIG_FILE);
|
|
2429
|
+
}
|
|
2430
|
+
function loadMetabaseConfig(cwd = process.cwd()) {
|
|
2431
|
+
const filePath = getConfigPath(cwd);
|
|
2432
|
+
if (!fs.existsSync(filePath)) return null;
|
|
2433
|
+
try {
|
|
2434
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
2435
|
+
return YAML.parse(content)?.metabase ?? null;
|
|
2436
|
+
} catch {
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
function saveMetabaseConfig(config, cwd = process.cwd()) {
|
|
2441
|
+
ensureBonDir(cwd);
|
|
2442
|
+
const filePath = getConfigPath(cwd);
|
|
2443
|
+
const file = { metabase: config };
|
|
2444
|
+
const content = `# Bonnard Metabase configuration
|
|
2445
|
+
# This file contains an API key - add .bon/ to .gitignore
|
|
2446
|
+
|
|
2447
|
+
` + YAML.stringify(file, { indent: 2 });
|
|
2448
|
+
fs.writeFileSync(filePath, content, { mode: 384 });
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
//#endregion
|
|
2452
|
+
//#region src/lib/metabase/client.ts
|
|
2453
|
+
var MetabaseApiError = class extends Error {
|
|
2454
|
+
constructor(status, endpoint, message) {
|
|
2455
|
+
super(message);
|
|
2456
|
+
this.status = status;
|
|
2457
|
+
this.endpoint = endpoint;
|
|
2458
|
+
this.name = "MetabaseApiError";
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
function createMetabaseClient(config) {
|
|
2462
|
+
const baseUrl = config.url.replace(/\/+$/, "");
|
|
2463
|
+
async function metabaseFetch(endpoint, options = {}) {
|
|
2464
|
+
const url = `${baseUrl}/api${endpoint}`;
|
|
2465
|
+
const res = await fetch(url, {
|
|
2466
|
+
...options,
|
|
2467
|
+
headers: {
|
|
2468
|
+
"X-API-KEY": config.apiKey,
|
|
2469
|
+
"Content-Type": "application/json",
|
|
2470
|
+
...options.headers
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
if (!res.ok) {
|
|
2474
|
+
let message = `HTTP ${res.status}`;
|
|
2475
|
+
try {
|
|
2476
|
+
const body = await res.text();
|
|
2477
|
+
if (body) message = body;
|
|
2478
|
+
} catch {}
|
|
2479
|
+
throw new MetabaseApiError(res.status, endpoint, message);
|
|
2480
|
+
}
|
|
2481
|
+
return res.json();
|
|
2482
|
+
}
|
|
2483
|
+
return {
|
|
2484
|
+
async getCurrentUser() {
|
|
2485
|
+
return metabaseFetch("/user/current");
|
|
2486
|
+
},
|
|
2487
|
+
async getDatabases() {
|
|
2488
|
+
return (await metabaseFetch("/database")).data;
|
|
2489
|
+
},
|
|
2490
|
+
async getCollections() {
|
|
2491
|
+
return metabaseFetch("/collection");
|
|
2492
|
+
},
|
|
2493
|
+
async getCollectionTree() {
|
|
2494
|
+
return metabaseFetch("/collection/tree");
|
|
2495
|
+
},
|
|
2496
|
+
async getCards() {
|
|
2497
|
+
return metabaseFetch("/card");
|
|
2498
|
+
},
|
|
2499
|
+
async getCard(id) {
|
|
2500
|
+
return metabaseFetch(`/card/${id}`);
|
|
2501
|
+
},
|
|
2502
|
+
async getDashboards() {
|
|
2503
|
+
return metabaseFetch("/dashboard");
|
|
2504
|
+
},
|
|
2505
|
+
async getDashboard(id) {
|
|
2506
|
+
return metabaseFetch(`/dashboard/${id}`);
|
|
2507
|
+
},
|
|
2508
|
+
async convertToNativeSQL(datasetQuery) {
|
|
2509
|
+
return (await metabaseFetch("/dataset/native", {
|
|
2510
|
+
method: "POST",
|
|
2511
|
+
body: JSON.stringify(datasetQuery)
|
|
2512
|
+
})).query;
|
|
2513
|
+
},
|
|
2514
|
+
async getDatabaseMetadata(id) {
|
|
2515
|
+
return metabaseFetch(`/database/${id}/metadata`);
|
|
2516
|
+
},
|
|
2517
|
+
async getPermissionGroups() {
|
|
2518
|
+
return metabaseFetch("/permissions/group");
|
|
2519
|
+
},
|
|
2520
|
+
async getPermissionsGraph() {
|
|
2521
|
+
return metabaseFetch("/permissions/graph");
|
|
2522
|
+
},
|
|
2523
|
+
async getCollectionItems(id) {
|
|
2524
|
+
return (await metabaseFetch(`/collection/${id}/items?models=card&models=dataset&models=metric&models=dashboard`)).data;
|
|
2525
|
+
},
|
|
2526
|
+
async getPopularItems() {
|
|
2527
|
+
return metabaseFetch("/activity/popular_items");
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
//#endregion
|
|
2533
|
+
//#region src/commands/metabase/connect.ts
|
|
2534
|
+
async function prompts() {
|
|
2535
|
+
return import("@inquirer/prompts");
|
|
2536
|
+
}
|
|
2537
|
+
async function metabaseConnectCommand(options) {
|
|
2538
|
+
const nonInteractive = !!(options.url && options.apiKey);
|
|
2539
|
+
if (loadMetabaseConfig()) if (options.force) console.log(pc.dim("Overwriting existing Metabase configuration"));
|
|
2540
|
+
else if (nonInteractive) {
|
|
2541
|
+
console.error(pc.red("Metabase is already configured. Use --force to overwrite."));
|
|
2542
|
+
process.exit(1);
|
|
2543
|
+
} else {
|
|
2544
|
+
const { confirm } = await prompts();
|
|
2545
|
+
if (!await confirm({
|
|
2546
|
+
message: "Metabase is already configured. Overwrite?",
|
|
2547
|
+
default: false
|
|
2548
|
+
})) {
|
|
2549
|
+
console.log(pc.yellow("Cancelled."));
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
let url = options.url;
|
|
2554
|
+
let apiKey = options.apiKey;
|
|
2555
|
+
if (!nonInteractive) {
|
|
2556
|
+
const { input, password } = await prompts();
|
|
2557
|
+
if (!url) url = await input({
|
|
2558
|
+
message: "Metabase URL (e.g. https://metabase.example.com):",
|
|
2559
|
+
validate: (v) => {
|
|
2560
|
+
try {
|
|
2561
|
+
const u = new URL(v);
|
|
2562
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return "URL must use http or https";
|
|
2563
|
+
return true;
|
|
2564
|
+
} catch {
|
|
2565
|
+
return "Enter a valid URL";
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
if (!apiKey) apiKey = await password({ message: "API key:" });
|
|
2570
|
+
}
|
|
2571
|
+
if (!url || !apiKey) {
|
|
2572
|
+
console.error(pc.red("Both --url and --api-key are required in non-interactive mode."));
|
|
2573
|
+
process.exit(1);
|
|
2574
|
+
}
|
|
2575
|
+
try {
|
|
2576
|
+
new URL(url);
|
|
2577
|
+
} catch {
|
|
2578
|
+
console.error(pc.red(`Invalid URL: ${url}`));
|
|
2579
|
+
process.exit(1);
|
|
2580
|
+
}
|
|
2581
|
+
url = url.replace(/\/+$/, "");
|
|
2582
|
+
console.log();
|
|
2583
|
+
console.log(pc.dim("Testing connection..."));
|
|
2584
|
+
const client = createMetabaseClient({
|
|
2585
|
+
url,
|
|
2586
|
+
apiKey
|
|
2587
|
+
});
|
|
2588
|
+
try {
|
|
2589
|
+
const user = await client.getCurrentUser();
|
|
2590
|
+
console.log(pc.green("✓ Connected to Metabase"));
|
|
2591
|
+
console.log();
|
|
2592
|
+
console.log(` URL: ${url}`);
|
|
2593
|
+
console.log(` User: ${user.first_name} ${user.last_name} (${user.email})`);
|
|
2594
|
+
console.log(` Admin: ${user.is_superuser ? "Yes" : "No"}`);
|
|
2595
|
+
console.log();
|
|
2596
|
+
saveMetabaseConfig({
|
|
2597
|
+
url,
|
|
2598
|
+
apiKey
|
|
2599
|
+
});
|
|
2600
|
+
console.log(pc.green("✓ Configuration saved to .bon/metabase.yaml"));
|
|
2601
|
+
console.log();
|
|
2602
|
+
console.log(pc.dim("Explore your Metabase content: bon metabase explore"));
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
if (err instanceof MetabaseApiError) if (err.status === 401 || err.status === 403) {
|
|
2605
|
+
console.error(pc.red("Authentication failed. Check your API key."));
|
|
2606
|
+
console.log();
|
|
2607
|
+
console.log(pc.dim("Generate an API key in Metabase:"));
|
|
2608
|
+
console.log(pc.dim(" Admin > Settings > Authentication > API Keys"));
|
|
2609
|
+
} else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
|
|
2610
|
+
else if (err instanceof TypeError && (err.message.includes("fetch") || err.message.includes("ECONNREFUSED"))) {
|
|
2611
|
+
console.error(pc.red(`Could not connect to ${url}`));
|
|
2612
|
+
console.log(pc.dim("Check the URL and ensure Metabase is running."));
|
|
2613
|
+
} else console.error(pc.red(`Connection failed: ${err.message}`));
|
|
2614
|
+
process.exit(1);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
//#endregion
|
|
2619
|
+
//#region src/commands/metabase/explore.ts
|
|
2620
|
+
function requireConfig() {
|
|
2621
|
+
const config = loadMetabaseConfig();
|
|
2622
|
+
if (!config) {
|
|
2623
|
+
console.error(pc.red("Metabase is not configured."));
|
|
2624
|
+
console.log(pc.dim("Run: bon metabase connect"));
|
|
2625
|
+
process.exit(1);
|
|
2626
|
+
}
|
|
2627
|
+
return createMetabaseClient(config);
|
|
2628
|
+
}
|
|
2629
|
+
function getCardType$1(card) {
|
|
2630
|
+
if (card.type === "model" || card.dataset) return "model";
|
|
2631
|
+
if (card.type === "metric") return "metric";
|
|
2632
|
+
return "question";
|
|
2633
|
+
}
|
|
2634
|
+
function padColumn(value, width) {
|
|
2635
|
+
return value.padEnd(width);
|
|
2636
|
+
}
|
|
2637
|
+
const INACTIVE_MONTHS$1 = 3;
|
|
2638
|
+
function isCardActive$1(card) {
|
|
2639
|
+
if (!card.last_used_at) return false;
|
|
2640
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
2641
|
+
cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS$1);
|
|
2642
|
+
return new Date(card.last_used_at) >= cutoff;
|
|
2643
|
+
}
|
|
2644
|
+
function activityScore$1(card) {
|
|
2645
|
+
const views = card.view_count || 0;
|
|
2646
|
+
return isCardActive$1(card) ? views : Math.round(views * .1);
|
|
2647
|
+
}
|
|
2648
|
+
function formatLastUsed$1(card) {
|
|
2649
|
+
if (!card.last_used_at) return "never";
|
|
2650
|
+
const d = new Date(card.last_used_at);
|
|
2651
|
+
const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
|
|
2652
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
2653
|
+
if (diffDays === 0) return "today";
|
|
2654
|
+
if (diffDays === 1) return "yesterday";
|
|
2655
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
2656
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
2657
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
2658
|
+
}
|
|
2659
|
+
async function showOverview(client) {
|
|
2660
|
+
const [databases, collections, cards, dashboards] = await Promise.all([
|
|
2661
|
+
client.getDatabases(),
|
|
2662
|
+
client.getCollections(),
|
|
2663
|
+
client.getCards(),
|
|
2664
|
+
client.getDashboards()
|
|
2665
|
+
]);
|
|
2666
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
2667
|
+
const models = activeCards.filter((c) => getCardType$1(c) === "model");
|
|
2668
|
+
const metrics = activeCards.filter((c) => getCardType$1(c) === "metric");
|
|
2669
|
+
const questions = activeCards.filter((c) => getCardType$1(c) === "question");
|
|
2670
|
+
const activeCollections = collections.filter((c) => !c.archived && c.personal_owner_id === null);
|
|
2671
|
+
const activeDashboards = dashboards.filter((d) => !d.archived);
|
|
2672
|
+
const recentlyActive = activeCards.filter(isCardActive$1).length;
|
|
2673
|
+
const inactive = activeCards.length - recentlyActive;
|
|
2674
|
+
console.log();
|
|
2675
|
+
console.log(pc.bold("Metabase Overview"));
|
|
2676
|
+
console.log();
|
|
2677
|
+
console.log(` Databases: ${databases.length}`);
|
|
2678
|
+
console.log(` Collections: ${activeCollections.length}`);
|
|
2679
|
+
console.log(` Models: ${models.length}`);
|
|
2680
|
+
console.log(` Metrics: ${metrics.length}`);
|
|
2681
|
+
console.log(` Questions: ${questions.length}`);
|
|
2682
|
+
console.log(` Dashboards: ${activeDashboards.length}`);
|
|
2683
|
+
console.log();
|
|
2684
|
+
console.log(` Active (last ${INACTIVE_MONTHS$1}mo): ${pc.green(String(recentlyActive))}`);
|
|
2685
|
+
console.log(` Inactive: ${pc.dim(String(inactive))}`);
|
|
2686
|
+
console.log();
|
|
2687
|
+
console.log(pc.dim("Explore further:"));
|
|
2688
|
+
console.log(pc.dim(" bon metabase explore databases"));
|
|
2689
|
+
console.log(pc.dim(" bon metabase explore collections"));
|
|
2690
|
+
console.log(pc.dim(" bon metabase explore cards"));
|
|
2691
|
+
console.log(pc.dim(" bon metabase explore dashboards"));
|
|
2692
|
+
console.log(pc.dim(" bon metabase explore card <id>"));
|
|
2693
|
+
console.log(pc.dim(" bon metabase explore dashboard <id>"));
|
|
2694
|
+
console.log(pc.dim(" bon metabase explore database <id>"));
|
|
2695
|
+
console.log(pc.dim(" bon metabase explore table <id>"));
|
|
2696
|
+
console.log(pc.dim(" bon metabase explore collection <id>"));
|
|
2697
|
+
}
|
|
2698
|
+
async function showDatabases(client) {
|
|
2699
|
+
const databases = await client.getDatabases();
|
|
2700
|
+
console.log();
|
|
2701
|
+
console.log(pc.bold("Databases"));
|
|
2702
|
+
console.log();
|
|
2703
|
+
if (databases.length === 0) {
|
|
2704
|
+
console.log(pc.dim(" No databases found."));
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("ENGINE", 16))}${pc.dim("SAMPLE")}`);
|
|
2708
|
+
for (const db of databases) console.log(` ${padColumn(String(db.id), 6)}${padColumn(db.name, 30)}${padColumn(db.engine, 16)}${db.is_sample ? "Yes" : ""}`);
|
|
2709
|
+
}
|
|
2710
|
+
function printTree(nodes, indent = 0) {
|
|
2711
|
+
for (const node of nodes) {
|
|
2712
|
+
if (node.personal_owner_id !== null) continue;
|
|
2713
|
+
const prefix = indent === 0 ? " " : " " + " ".repeat(indent);
|
|
2714
|
+
const icon = node.children.length > 0 ? "+" : "-";
|
|
2715
|
+
console.log(`${prefix}${pc.dim(icon)} ${node.name} ${pc.dim(`(${node.id})`)}`);
|
|
2716
|
+
if (node.children.length > 0) printTree(node.children, indent + 1);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
async function showCollections(client) {
|
|
2720
|
+
const tree = await client.getCollectionTree();
|
|
2721
|
+
console.log();
|
|
2722
|
+
console.log(pc.bold("Collections"));
|
|
2723
|
+
console.log();
|
|
2724
|
+
const filtered = tree.filter((n) => n.personal_owner_id === null);
|
|
2725
|
+
if (filtered.length === 0) {
|
|
2726
|
+
console.log(pc.dim(" No collections found."));
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
printTree(filtered);
|
|
2730
|
+
}
|
|
2731
|
+
async function showCards(client) {
|
|
2732
|
+
const active = (await client.getCards()).filter((c) => !c.archived);
|
|
2733
|
+
const models = active.filter((c) => getCardType$1(c) === "model");
|
|
2734
|
+
const metrics = active.filter((c) => getCardType$1(c) === "metric");
|
|
2735
|
+
const questions = active.filter((c) => getCardType$1(c) === "question");
|
|
2736
|
+
const CAP = 50;
|
|
2737
|
+
console.log();
|
|
2738
|
+
const groups = [
|
|
2739
|
+
{
|
|
2740
|
+
label: "Models",
|
|
2741
|
+
items: models
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
label: "Metrics",
|
|
2745
|
+
items: metrics
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
label: "Questions",
|
|
2749
|
+
items: questions
|
|
2750
|
+
}
|
|
2751
|
+
];
|
|
2752
|
+
for (const group of groups) {
|
|
2753
|
+
if (group.items.length === 0) continue;
|
|
2754
|
+
const groupActive = group.items.filter(isCardActive$1).length;
|
|
2755
|
+
const groupInactive = group.items.length - groupActive;
|
|
2756
|
+
const sorted = [...group.items].sort((a, b) => activityScore$1(b) - activityScore$1(a));
|
|
2757
|
+
console.log(pc.bold(`${group.label} (${group.items.length})`) + pc.dim(` — ${groupActive} active, ${groupInactive} inactive`));
|
|
2758
|
+
console.log();
|
|
2759
|
+
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")}`);
|
|
2760
|
+
const display = sorted.slice(0, CAP);
|
|
2761
|
+
for (const card of display) {
|
|
2762
|
+
const lastUsed = formatLastUsed$1(card);
|
|
2763
|
+
const active = isCardActive$1(card);
|
|
2764
|
+
const name = card.name.slice(0, 34);
|
|
2765
|
+
const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 36)}${padColumn(card.display, 14)}${padColumn(lastUsed, 12)}${card.view_count || 0}`;
|
|
2766
|
+
console.log(active ? line : pc.dim(line));
|
|
2767
|
+
}
|
|
2768
|
+
if (group.items.length > CAP) console.log(pc.dim(` ... and ${group.items.length - CAP} more`));
|
|
2769
|
+
console.log();
|
|
2770
|
+
}
|
|
2771
|
+
if (models.length === 0 && metrics.length === 0 && questions.length === 0) console.log(pc.dim(" No cards found."));
|
|
2772
|
+
console.log(pc.dim("View details: bon metabase explore card <id>"));
|
|
2773
|
+
}
|
|
2774
|
+
async function showCardDetail(client, id) {
|
|
2775
|
+
const card = await client.getCard(id);
|
|
2776
|
+
const cardType = getCardType$1(card);
|
|
2777
|
+
console.log();
|
|
2778
|
+
console.log(pc.bold(card.name));
|
|
2779
|
+
if (card.description) console.log(pc.dim(card.description));
|
|
2780
|
+
console.log();
|
|
2781
|
+
const active = isCardActive$1(card);
|
|
2782
|
+
const lastUsed = formatLastUsed$1(card);
|
|
2783
|
+
const activityLabel = active ? pc.green("active") : pc.dim("inactive");
|
|
2784
|
+
console.log(` Type: ${cardType}`);
|
|
2785
|
+
console.log(` Display: ${card.display}`);
|
|
2786
|
+
console.log(` Database: ${card.database_id}`);
|
|
2787
|
+
console.log(` Views: ${card.view_count || 0}`);
|
|
2788
|
+
console.log(` Last used: ${lastUsed} (${activityLabel})`);
|
|
2789
|
+
console.log();
|
|
2790
|
+
let sql = null;
|
|
2791
|
+
const dq = card.dataset_query;
|
|
2792
|
+
if (dq.type === "native" && dq.native?.query) sql = dq.native.query;
|
|
2793
|
+
else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") sql = dq.stages[0].native;
|
|
2794
|
+
else if (dq.type === "query") try {
|
|
2795
|
+
sql = await client.convertToNativeSQL(dq);
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
|
|
2798
|
+
}
|
|
2799
|
+
else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") try {
|
|
2800
|
+
sql = await client.convertToNativeSQL(dq);
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
|
|
2803
|
+
}
|
|
2804
|
+
if (sql) {
|
|
2805
|
+
console.log(pc.bold("SQL"));
|
|
2806
|
+
console.log();
|
|
2807
|
+
for (const line of sql.trim().split("\n")) console.log(` ${line}`);
|
|
2808
|
+
console.log();
|
|
2809
|
+
}
|
|
2810
|
+
if (card.result_metadata && card.result_metadata.length > 0) {
|
|
2811
|
+
console.log(pc.bold("Columns"));
|
|
2812
|
+
console.log();
|
|
2813
|
+
console.log(` ${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("DISPLAY NAME", 30))}${pc.dim("TYPE")}`);
|
|
2814
|
+
for (const col of card.result_metadata) console.log(` ${padColumn(col.name, 30)}${padColumn(col.display_name, 30)}${col.base_type}`);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
async function showDashboards(client) {
|
|
2818
|
+
const active = (await client.getDashboards()).filter((d) => !d.archived);
|
|
2819
|
+
console.log();
|
|
2820
|
+
console.log(pc.bold(`Dashboards (${active.length})`));
|
|
2821
|
+
console.log();
|
|
2822
|
+
if (active.length === 0) {
|
|
2823
|
+
console.log(pc.dim(" No dashboards found."));
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
|
|
2827
|
+
for (const d of active) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
|
|
2828
|
+
console.log();
|
|
2829
|
+
console.log(pc.dim("View details: bon metabase explore dashboard <id>"));
|
|
2830
|
+
}
|
|
2831
|
+
async function showDashboardDetail(client, id) {
|
|
2832
|
+
const [dashboard, allCards] = await Promise.all([client.getDashboard(id), client.getCards()]);
|
|
2833
|
+
const cardActivityMap = /* @__PURE__ */ new Map();
|
|
2834
|
+
for (const c of allCards) cardActivityMap.set(c.id, c);
|
|
2835
|
+
console.log();
|
|
2836
|
+
console.log(pc.bold(dashboard.name));
|
|
2837
|
+
if (dashboard.description) console.log(pc.dim(dashboard.description));
|
|
2838
|
+
console.log();
|
|
2839
|
+
if (dashboard.parameters.length > 0) {
|
|
2840
|
+
console.log(pc.bold("Parameters"));
|
|
2841
|
+
console.log();
|
|
2842
|
+
console.log(` ${pc.dim(padColumn("NAME", 25))}${pc.dim(padColumn("TYPE", 20))}${pc.dim("SLUG")}`);
|
|
2843
|
+
for (const p of dashboard.parameters) console.log(` ${padColumn(p.name, 25)}${padColumn(p.type, 20)}${p.slug}`);
|
|
2844
|
+
console.log();
|
|
2845
|
+
}
|
|
2846
|
+
const cardsOnDashboard = dashboard.dashcards.filter((dc) => dc.card?.id != null);
|
|
2847
|
+
if (cardsOnDashboard.length > 0) {
|
|
2848
|
+
const inactiveOnDash = cardsOnDashboard.filter((dc) => {
|
|
2849
|
+
const full = cardActivityMap.get(dc.card.id);
|
|
2850
|
+
return full ? !isCardActive$1(full) : true;
|
|
2851
|
+
});
|
|
2852
|
+
const activeLabel = cardsOnDashboard.length - inactiveOnDash.length;
|
|
2853
|
+
console.log(pc.bold(`Cards (${cardsOnDashboard.length})`) + pc.dim(` — ${activeLabel} active, ${inactiveOnDash.length} inactive`));
|
|
2854
|
+
console.log();
|
|
2855
|
+
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")}`);
|
|
2856
|
+
for (const dc of cardsOnDashboard) {
|
|
2857
|
+
const card = dc.card;
|
|
2858
|
+
const name = (card.name ?? "(untitled)").slice(0, 29);
|
|
2859
|
+
const pos = `(${dc.col},${dc.row}) ${dc.size_x}x${dc.size_y}`;
|
|
2860
|
+
const full = cardActivityMap.get(card.id);
|
|
2861
|
+
const lastUsed = full ? formatLastUsed$1(full) : "?";
|
|
2862
|
+
const active = full ? isCardActive$1(full) : false;
|
|
2863
|
+
const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 31)}${padColumn(card.display ?? "", 14)}${padColumn(lastUsed, 12)}${pos}`;
|
|
2864
|
+
console.log(active ? line : pc.dim(line));
|
|
2865
|
+
}
|
|
2866
|
+
} else console.log(pc.dim(" No cards on this dashboard."));
|
|
2867
|
+
}
|
|
2868
|
+
async function showDatabaseDetail(client, id) {
|
|
2869
|
+
const meta = await client.getDatabaseMetadata(id);
|
|
2870
|
+
console.log();
|
|
2871
|
+
console.log(pc.bold(`${meta.name} (${meta.engine})`));
|
|
2872
|
+
console.log();
|
|
2873
|
+
const bySchema = /* @__PURE__ */ new Map();
|
|
2874
|
+
for (const t of meta.tables) {
|
|
2875
|
+
if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
|
|
2876
|
+
if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
|
|
2877
|
+
bySchema.get(t.schema).push(t);
|
|
2878
|
+
}
|
|
2879
|
+
for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
2880
|
+
console.log(pc.bold(` ${schema}`) + pc.dim(` (${tables.length} tables)`));
|
|
2881
|
+
const sorted = [...tables].sort((a, b) => a.name.localeCompare(b.name));
|
|
2882
|
+
for (const t of sorted) {
|
|
2883
|
+
const fieldCount = t.fields.length;
|
|
2884
|
+
const desc = t.description ? pc.dim(` — ${t.description.slice(0, 50)}`) : "";
|
|
2885
|
+
console.log(` ${padColumn(String(t.id), 8)}${padColumn(t.name, 40)}${fieldCount} fields${desc}`);
|
|
2886
|
+
}
|
|
2887
|
+
console.log();
|
|
2888
|
+
}
|
|
2889
|
+
console.log(pc.dim("View table fields: bon metabase explore table <id>"));
|
|
2890
|
+
}
|
|
2891
|
+
function classifyFieldType(field) {
|
|
2892
|
+
const bt = field.base_type || "";
|
|
2893
|
+
const st = field.semantic_type || "";
|
|
2894
|
+
if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date") return "time";
|
|
2895
|
+
if (st === "type/PK") return "pk";
|
|
2896
|
+
if (st === "type/FK") return "fk";
|
|
2897
|
+
if ([
|
|
2898
|
+
"type/Currency",
|
|
2899
|
+
"type/Percentage",
|
|
2900
|
+
"type/Quantity",
|
|
2901
|
+
"type/Score"
|
|
2902
|
+
].includes(st)) return "measure";
|
|
2903
|
+
if ([
|
|
2904
|
+
"type/Category",
|
|
2905
|
+
"type/Source",
|
|
2906
|
+
"type/City",
|
|
2907
|
+
"type/Country",
|
|
2908
|
+
"type/State",
|
|
2909
|
+
"type/Name",
|
|
2910
|
+
"type/Email",
|
|
2911
|
+
"type/URL"
|
|
2912
|
+
].includes(st)) return "dim";
|
|
2913
|
+
if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) return "numeric";
|
|
2914
|
+
if (bt.includes("Text") || bt.includes("String")) return "text";
|
|
2915
|
+
if (bt.includes("Boolean")) return "bool";
|
|
2916
|
+
return "";
|
|
2917
|
+
}
|
|
2918
|
+
async function showTableDetail(client, tableId) {
|
|
2919
|
+
const databases = await client.getDatabases();
|
|
2920
|
+
let foundTable = null;
|
|
2921
|
+
let dbName = "";
|
|
2922
|
+
let meta = null;
|
|
2923
|
+
for (const db of databases) {
|
|
2924
|
+
meta = await client.getDatabaseMetadata(db.id);
|
|
2925
|
+
const table = meta.tables.find((t) => t.id === tableId);
|
|
2926
|
+
if (table) {
|
|
2927
|
+
foundTable = table;
|
|
2928
|
+
dbName = db.name;
|
|
2929
|
+
break;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
if (!foundTable || !meta) {
|
|
2933
|
+
console.error(pc.red(`Table ${tableId} not found.`));
|
|
2934
|
+
process.exit(1);
|
|
2935
|
+
}
|
|
2936
|
+
const fieldIdLookup = /* @__PURE__ */ new Map();
|
|
2937
|
+
for (const t of meta.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
|
|
2938
|
+
table: t.name,
|
|
2939
|
+
field: f.name
|
|
2940
|
+
});
|
|
2941
|
+
console.log();
|
|
2942
|
+
console.log(pc.bold(`${foundTable.name}`) + pc.dim(` (${dbName} / ${foundTable.schema})`));
|
|
2943
|
+
if (foundTable.description) console.log(pc.dim(foundTable.description));
|
|
2944
|
+
console.log();
|
|
2945
|
+
const fields = foundTable.fields.filter((f) => f.visibility_type !== "hidden" && f.visibility_type !== "retired");
|
|
2946
|
+
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")}`);
|
|
2947
|
+
for (const f of fields) {
|
|
2948
|
+
const cls = classifyFieldType(f);
|
|
2949
|
+
const distinct = f.fingerprint?.global?.["distinct-count"];
|
|
2950
|
+
const nilPct = f.fingerprint?.global?.["nil%"];
|
|
2951
|
+
const fkTarget = f.fk_target_field_id ? (() => {
|
|
2952
|
+
const target = fieldIdLookup.get(f.fk_target_field_id);
|
|
2953
|
+
return target ? `${target.table}.${target.field}` : `field:${f.fk_target_field_id}`;
|
|
2954
|
+
})() : "";
|
|
2955
|
+
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}`);
|
|
2956
|
+
}
|
|
2957
|
+
console.log();
|
|
2958
|
+
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)`));
|
|
2959
|
+
}
|
|
2960
|
+
async function showCollectionDetail(client, id) {
|
|
2961
|
+
const [items, allCards] = await Promise.all([client.getCollectionItems(id), client.getCards()]);
|
|
2962
|
+
const cardMap = /* @__PURE__ */ new Map();
|
|
2963
|
+
for (const c of allCards) cardMap.set(c.id, c);
|
|
2964
|
+
const cardItems = items.filter((i) => i.model === "card" || i.model === "dataset" || i.model === "metric");
|
|
2965
|
+
const dashboardItems = items.filter((i) => i.model === "dashboard");
|
|
2966
|
+
console.log();
|
|
2967
|
+
console.log(pc.bold(`Collection ${id}`));
|
|
2968
|
+
console.log();
|
|
2969
|
+
if (cardItems.length > 0) {
|
|
2970
|
+
const sorted = cardItems.sort((a, b) => {
|
|
2971
|
+
const ca = cardMap.get(a.id);
|
|
2972
|
+
const cb = cardMap.get(b.id);
|
|
2973
|
+
return (cb ? activityScore$1(cb) : 0) - (ca ? activityScore$1(ca) : 0);
|
|
2974
|
+
});
|
|
2975
|
+
const activeCount = sorted.filter((i) => {
|
|
2976
|
+
const c = cardMap.get(i.id);
|
|
2977
|
+
return c ? isCardActive$1(c) : false;
|
|
2978
|
+
}).length;
|
|
2979
|
+
console.log(pc.bold(`Cards (${sorted.length})`) + pc.dim(` — ${activeCount} active, ${sorted.length - activeCount} inactive`));
|
|
2980
|
+
console.log();
|
|
2981
|
+
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")}`);
|
|
2982
|
+
for (const item of sorted) {
|
|
2983
|
+
const full = cardMap.get(item.id);
|
|
2984
|
+
const lastUsed = full ? formatLastUsed$1(full) : "?";
|
|
2985
|
+
const views = full ? full.view_count || 0 : 0;
|
|
2986
|
+
const active = full ? isCardActive$1(full) : false;
|
|
2987
|
+
const itemType = item.model === "dataset" ? "model" : item.model;
|
|
2988
|
+
const name = item.name.slice(0, 38);
|
|
2989
|
+
const line = ` ${padColumn(String(item.id), 6)}${padColumn(name, 40)}${padColumn(itemType, 10)}${padColumn(item.display || "", 14)}${padColumn(lastUsed, 12)}${views}`;
|
|
2990
|
+
console.log(active ? line : pc.dim(line));
|
|
2991
|
+
}
|
|
2992
|
+
console.log();
|
|
2993
|
+
}
|
|
2994
|
+
if (dashboardItems.length > 0) {
|
|
2995
|
+
console.log(pc.bold(`Dashboards (${dashboardItems.length})`));
|
|
2996
|
+
console.log();
|
|
2997
|
+
console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
|
|
2998
|
+
for (const d of dashboardItems) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
|
|
2999
|
+
console.log();
|
|
3000
|
+
}
|
|
3001
|
+
if (cardItems.length === 0 && dashboardItems.length === 0) console.log(pc.dim(" No items in this collection."));
|
|
3002
|
+
console.log(pc.dim("View card SQL: bon metabase explore card <id>"));
|
|
3003
|
+
console.log(pc.dim("View dashboard: bon metabase explore dashboard <id>"));
|
|
3004
|
+
}
|
|
3005
|
+
const RESOURCES = [
|
|
3006
|
+
"databases",
|
|
3007
|
+
"collections",
|
|
3008
|
+
"cards",
|
|
3009
|
+
"dashboards",
|
|
3010
|
+
"card",
|
|
3011
|
+
"dashboard",
|
|
3012
|
+
"database",
|
|
3013
|
+
"table",
|
|
3014
|
+
"collection"
|
|
3015
|
+
];
|
|
3016
|
+
async function metabaseExploreCommand(resource, id) {
|
|
3017
|
+
const client = requireConfig();
|
|
3018
|
+
try {
|
|
3019
|
+
if (!resource) {
|
|
3020
|
+
await showOverview(client);
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (!RESOURCES.includes(resource)) {
|
|
3024
|
+
console.error(pc.red(`Unknown resource: ${resource}`));
|
|
3025
|
+
console.log(pc.dim(`Valid resources: ${RESOURCES.join(", ")}`));
|
|
3026
|
+
process.exit(1);
|
|
3027
|
+
}
|
|
3028
|
+
switch (resource) {
|
|
3029
|
+
case "databases":
|
|
3030
|
+
await showDatabases(client);
|
|
3031
|
+
break;
|
|
3032
|
+
case "collections":
|
|
3033
|
+
await showCollections(client);
|
|
3034
|
+
break;
|
|
3035
|
+
case "cards":
|
|
3036
|
+
await showCards(client);
|
|
3037
|
+
break;
|
|
3038
|
+
case "dashboards":
|
|
3039
|
+
await showDashboards(client);
|
|
3040
|
+
break;
|
|
3041
|
+
case "card": {
|
|
3042
|
+
if (!id) {
|
|
3043
|
+
console.error(pc.red("Card ID required: bon metabase explore card <id>"));
|
|
3044
|
+
process.exit(1);
|
|
3045
|
+
}
|
|
3046
|
+
const cardId = parseInt(id, 10);
|
|
3047
|
+
if (isNaN(cardId)) {
|
|
3048
|
+
console.error(pc.red(`Invalid card ID: ${id}`));
|
|
3049
|
+
process.exit(1);
|
|
3050
|
+
}
|
|
3051
|
+
await showCardDetail(client, cardId);
|
|
3052
|
+
break;
|
|
3053
|
+
}
|
|
3054
|
+
case "dashboard": {
|
|
3055
|
+
if (!id) {
|
|
3056
|
+
console.error(pc.red("Dashboard ID required: bon metabase explore dashboard <id>"));
|
|
3057
|
+
process.exit(1);
|
|
3058
|
+
}
|
|
3059
|
+
const dashId = parseInt(id, 10);
|
|
3060
|
+
if (isNaN(dashId)) {
|
|
3061
|
+
console.error(pc.red(`Invalid dashboard ID: ${id}`));
|
|
3062
|
+
process.exit(1);
|
|
3063
|
+
}
|
|
3064
|
+
await showDashboardDetail(client, dashId);
|
|
3065
|
+
break;
|
|
3066
|
+
}
|
|
3067
|
+
case "database": {
|
|
3068
|
+
if (!id) {
|
|
3069
|
+
console.error(pc.red("Database ID required: bon metabase explore database <id>"));
|
|
3070
|
+
process.exit(1);
|
|
3071
|
+
}
|
|
3072
|
+
const dbId = parseInt(id, 10);
|
|
3073
|
+
if (isNaN(dbId)) {
|
|
3074
|
+
console.error(pc.red(`Invalid database ID: ${id}`));
|
|
3075
|
+
process.exit(1);
|
|
3076
|
+
}
|
|
3077
|
+
await showDatabaseDetail(client, dbId);
|
|
3078
|
+
break;
|
|
3079
|
+
}
|
|
3080
|
+
case "table": {
|
|
3081
|
+
if (!id) {
|
|
3082
|
+
console.error(pc.red("Table ID required: bon metabase explore table <id>"));
|
|
3083
|
+
process.exit(1);
|
|
3084
|
+
}
|
|
3085
|
+
const tableId = parseInt(id, 10);
|
|
3086
|
+
if (isNaN(tableId)) {
|
|
3087
|
+
console.error(pc.red(`Invalid table ID: ${id}`));
|
|
3088
|
+
process.exit(1);
|
|
3089
|
+
}
|
|
3090
|
+
await showTableDetail(client, tableId);
|
|
3091
|
+
break;
|
|
3092
|
+
}
|
|
3093
|
+
case "collection": {
|
|
3094
|
+
if (!id) {
|
|
3095
|
+
console.error(pc.red("Collection ID required: bon metabase explore collection <id>"));
|
|
3096
|
+
process.exit(1);
|
|
3097
|
+
}
|
|
3098
|
+
const colId = parseInt(id, 10);
|
|
3099
|
+
if (isNaN(colId)) {
|
|
3100
|
+
console.error(pc.red(`Invalid collection ID: ${id}`));
|
|
3101
|
+
process.exit(1);
|
|
3102
|
+
}
|
|
3103
|
+
await showCollectionDetail(client, colId);
|
|
3104
|
+
break;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
} catch (err) {
|
|
3108
|
+
if (err instanceof MetabaseApiError) {
|
|
3109
|
+
if (err.status === 401 || err.status === 403) console.error(pc.red("Authentication failed. Re-run: bon metabase connect"));
|
|
3110
|
+
else if (err.status === 404) console.error(pc.red(`Not found: ${err.endpoint}`));
|
|
3111
|
+
else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
|
|
3112
|
+
process.exit(1);
|
|
3113
|
+
}
|
|
3114
|
+
throw err;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
//#endregion
|
|
3119
|
+
//#region src/commands/metabase/analyze.ts
|
|
3120
|
+
const OUTPUT_FILE = ".bon/metabase-analysis.md";
|
|
3121
|
+
const TOP_CARDS_LIMIT = 50;
|
|
3122
|
+
const TOP_DASHBOARDS_LIMIT = 10;
|
|
3123
|
+
function classifyField(field) {
|
|
3124
|
+
const bt = field.base_type || "";
|
|
3125
|
+
const st = field.semantic_type || "";
|
|
3126
|
+
if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date" || st === "type/Time") return "time";
|
|
3127
|
+
if ([
|
|
3128
|
+
"type/Currency",
|
|
3129
|
+
"type/Percentage",
|
|
3130
|
+
"type/Quantity",
|
|
3131
|
+
"type/Score"
|
|
3132
|
+
].includes(st)) return "measure";
|
|
3133
|
+
if (st === "type/PK" || st === "type/FK") return "dimension";
|
|
3134
|
+
if ([
|
|
3135
|
+
"type/Category",
|
|
3136
|
+
"type/Source",
|
|
3137
|
+
"type/City",
|
|
3138
|
+
"type/Country",
|
|
3139
|
+
"type/State",
|
|
3140
|
+
"type/Name",
|
|
3141
|
+
"type/Title",
|
|
3142
|
+
"type/Email",
|
|
3143
|
+
"type/URL",
|
|
3144
|
+
"type/ZipCode"
|
|
3145
|
+
].includes(st)) return "dimension";
|
|
3146
|
+
if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) {
|
|
3147
|
+
const distinct = field.fingerprint?.global?.["distinct-count"];
|
|
3148
|
+
if (distinct !== void 0 && distinct < 20) return "dimension";
|
|
3149
|
+
return "measure";
|
|
3150
|
+
}
|
|
3151
|
+
if (bt.includes("Text") || bt.includes("String")) return "dimension";
|
|
3152
|
+
if (bt.includes("Boolean")) return "dimension";
|
|
3153
|
+
return "other";
|
|
3154
|
+
}
|
|
3155
|
+
function extractSQL(card) {
|
|
3156
|
+
const dq = card.dataset_query;
|
|
3157
|
+
if (dq.type === "native" && dq.native?.query) return dq.native.query;
|
|
3158
|
+
if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") return dq.stages[0].native;
|
|
3159
|
+
return null;
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Returns true if the card uses MBQL (query builder) rather than native SQL.
|
|
3163
|
+
*/
|
|
3164
|
+
function isMbqlCard(card) {
|
|
3165
|
+
const dq = card.dataset_query;
|
|
3166
|
+
if (dq.type === "query") return true;
|
|
3167
|
+
if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") return true;
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Extracts table references from SQL (FROM and JOIN clauses).
|
|
3172
|
+
* Returns a map of table name -> reference count.
|
|
3173
|
+
* Excludes CTE names so only real tables are counted.
|
|
3174
|
+
*/
|
|
3175
|
+
function extractTableReferences(sql) {
|
|
3176
|
+
const refs = /* @__PURE__ */ new Map();
|
|
3177
|
+
const cteNames = /* @__PURE__ */ new Set();
|
|
3178
|
+
const cteMatch = sql.match(/\bWITH\b[\s\S]*?(?=\bSELECT\b(?![\s\S]*\bWITH\b))/gi);
|
|
3179
|
+
if (cteMatch) {
|
|
3180
|
+
const cteDefPattern = /\b(\w+)\s+AS\s*\(/gi;
|
|
3181
|
+
for (const block of cteMatch) {
|
|
3182
|
+
let m;
|
|
3183
|
+
while ((m = cteDefPattern.exec(block)) !== null) cteNames.add(m[1].toLowerCase());
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
const tableRefPattern = /(?:FROM|JOIN)\s+("?\w+"?\s*\.\s*"?\w+"?|"?\w+"?)(?:\s+(?:AS\s+)?\w+)?/gi;
|
|
3187
|
+
let match;
|
|
3188
|
+
while ((match = tableRefPattern.exec(sql)) !== null) {
|
|
3189
|
+
let tableName = match[1].replace(/"/g, "").replace(/\s/g, "").toLowerCase();
|
|
3190
|
+
const baseName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
3191
|
+
if (cteNames.has(baseName)) continue;
|
|
3192
|
+
if ([
|
|
3193
|
+
"select",
|
|
3194
|
+
"where",
|
|
3195
|
+
"group",
|
|
3196
|
+
"order",
|
|
3197
|
+
"having",
|
|
3198
|
+
"limit",
|
|
3199
|
+
"union",
|
|
3200
|
+
"values",
|
|
3201
|
+
"set",
|
|
3202
|
+
"update",
|
|
3203
|
+
"insert",
|
|
3204
|
+
"delete",
|
|
3205
|
+
"into",
|
|
3206
|
+
"table",
|
|
3207
|
+
"create",
|
|
3208
|
+
"alter",
|
|
3209
|
+
"drop",
|
|
3210
|
+
"index",
|
|
3211
|
+
"view",
|
|
3212
|
+
"as",
|
|
3213
|
+
"on",
|
|
3214
|
+
"and",
|
|
3215
|
+
"or",
|
|
3216
|
+
"not",
|
|
3217
|
+
"in",
|
|
3218
|
+
"is",
|
|
3219
|
+
"null",
|
|
3220
|
+
"true",
|
|
3221
|
+
"false",
|
|
3222
|
+
"case",
|
|
3223
|
+
"when",
|
|
3224
|
+
"then",
|
|
3225
|
+
"else",
|
|
3226
|
+
"end",
|
|
3227
|
+
"with",
|
|
3228
|
+
"the",
|
|
3229
|
+
"lateral",
|
|
3230
|
+
"generate_series",
|
|
3231
|
+
"unnest"
|
|
3232
|
+
].includes(baseName)) continue;
|
|
3233
|
+
if (!refs.has(tableName)) refs.set(tableName, 0);
|
|
3234
|
+
refs.set(tableName, refs.get(tableName) + 1);
|
|
3235
|
+
}
|
|
3236
|
+
return refs;
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Classifies a card's SQL pattern:
|
|
3240
|
+
* - analytical: GROUP BY + aggregation (core semantic layer candidates)
|
|
3241
|
+
* - lookup: Single-row lookup (WHERE email={{x}}, no GROUP BY) — CRM/operational
|
|
3242
|
+
* - display: UNION-based formatting/layout without aggregation
|
|
3243
|
+
* - unknown: non-native or unclassifiable
|
|
3244
|
+
*/
|
|
3245
|
+
function classifyCardPattern(card, sqlOverride) {
|
|
3246
|
+
const sql = sqlOverride ?? extractSQL(card);
|
|
3247
|
+
if (!sql) return "unknown";
|
|
3248
|
+
const upper = sql.toUpperCase();
|
|
3249
|
+
const hasGroupBy = /\bGROUP\s+BY\b/.test(upper);
|
|
3250
|
+
const hasAgg = /\b(COUNT|SUM|AVG|MIN|MAX|PERCENTILE)\s*\(/.test(upper);
|
|
3251
|
+
const hasUnion = /\bUNION\b/.test(upper);
|
|
3252
|
+
const hasTemplateVar = /\{\{[^}]+\}\}/.test(sql);
|
|
3253
|
+
const hasLookupVar = /\{\{(email|primary_mail|user|customer|name|phone|id)\}\}/i.test(sql);
|
|
3254
|
+
if (hasGroupBy && hasAgg) return "analytical";
|
|
3255
|
+
if (hasLookupVar && !hasGroupBy && !hasAgg) return "lookup";
|
|
3256
|
+
if (hasUnion && !hasGroupBy && !hasAgg) return "display";
|
|
3257
|
+
if (hasAgg && !hasGroupBy) return "analytical";
|
|
3258
|
+
if (hasTemplateVar && hasAgg) return "analytical";
|
|
3259
|
+
return "unknown";
|
|
3260
|
+
}
|
|
3261
|
+
/**
|
|
3262
|
+
* Extracts Metabase template variable names ({{var}}) from SQL.
|
|
3263
|
+
*/
|
|
3264
|
+
function extractTemplateVars(sql) {
|
|
3265
|
+
const vars = /* @__PURE__ */ new Set();
|
|
3266
|
+
const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
3267
|
+
let m;
|
|
3268
|
+
while ((m = pattern.exec(sql)) !== null) vars.add(m[1].toLowerCase());
|
|
3269
|
+
return vars;
|
|
3270
|
+
}
|
|
3271
|
+
const INACTIVE_MONTHS = 3;
|
|
3272
|
+
function isCardActive(card) {
|
|
3273
|
+
if (!card.last_used_at) return false;
|
|
3274
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
3275
|
+
cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS);
|
|
3276
|
+
return new Date(card.last_used_at) >= cutoff;
|
|
3277
|
+
}
|
|
3278
|
+
/**
|
|
3279
|
+
* Score that weights view_count by recency.
|
|
3280
|
+
* Active cards (used in last 3 months) keep full view_count.
|
|
3281
|
+
* Inactive cards are penalized by 90%.
|
|
3282
|
+
*/
|
|
3283
|
+
function activityScore(card) {
|
|
3284
|
+
const views = card.view_count || 0;
|
|
3285
|
+
return isCardActive(card) ? views : Math.round(views * .1);
|
|
3286
|
+
}
|
|
3287
|
+
function formatLastUsed(card) {
|
|
3288
|
+
if (!card.last_used_at) return "never";
|
|
3289
|
+
const d = new Date(card.last_used_at);
|
|
3290
|
+
const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
|
|
3291
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
3292
|
+
if (diffDays === 0) return "today";
|
|
3293
|
+
if (diffDays === 1) return "yesterday";
|
|
3294
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
3295
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
3296
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
3297
|
+
}
|
|
3298
|
+
function getCardType(card) {
|
|
3299
|
+
if (card.type === "model" || card.dataset) return "model";
|
|
3300
|
+
if (card.type === "metric") return "metric";
|
|
3301
|
+
return "question";
|
|
3302
|
+
}
|
|
3303
|
+
function extractSchemaAccess(graph, groups, dbId) {
|
|
3304
|
+
const groupMap = new Map(groups.map((g) => [String(g.id), g.name]));
|
|
3305
|
+
const schemaAccessMap = /* @__PURE__ */ new Map();
|
|
3306
|
+
for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
|
|
3307
|
+
const groupName = groupMap.get(groupId) || `Group ${groupId}`;
|
|
3308
|
+
const perms = dbPerms[String(dbId)];
|
|
3309
|
+
if (!perms) continue;
|
|
3310
|
+
const createQueries = perms["create-queries"];
|
|
3311
|
+
if (!createQueries) continue;
|
|
3312
|
+
if (typeof createQueries === "string") {
|
|
3313
|
+
if (createQueries !== "no") {
|
|
3314
|
+
if (!schemaAccessMap.has("*")) schemaAccessMap.set("*", []);
|
|
3315
|
+
schemaAccessMap.get("*").push(groupName);
|
|
3316
|
+
}
|
|
3317
|
+
continue;
|
|
3318
|
+
}
|
|
3319
|
+
for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") {
|
|
3320
|
+
if (!schemaAccessMap.has(schema)) schemaAccessMap.set(schema, []);
|
|
3321
|
+
schemaAccessMap.get(schema).push(groupName);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
return Array.from(schemaAccessMap.entries()).map(([schema, grps]) => ({
|
|
3325
|
+
schema,
|
|
3326
|
+
groups: grps
|
|
3327
|
+
})).sort((a, b) => a.schema.localeCompare(b.schema));
|
|
3328
|
+
}
|
|
3329
|
+
/**
|
|
3330
|
+
* Returns the set of schemas accessible to non-admin groups for a given database.
|
|
3331
|
+
* Returns null if a non-admin group has wildcard access (all schemas are user-facing),
|
|
3332
|
+
* or if no explicit schema permissions are found.
|
|
3333
|
+
*/
|
|
3334
|
+
function getUserFacingSchemas(graph, groups, dbId) {
|
|
3335
|
+
const adminGroupIds = new Set(groups.filter((g) => g.name === "Administrators").map((g) => String(g.id)));
|
|
3336
|
+
const schemas = /* @__PURE__ */ new Set();
|
|
3337
|
+
for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
|
|
3338
|
+
if (adminGroupIds.has(groupId)) continue;
|
|
3339
|
+
const perms = dbPerms[String(dbId)];
|
|
3340
|
+
if (!perms) continue;
|
|
3341
|
+
const createQueries = perms["create-queries"];
|
|
3342
|
+
if (!createQueries) continue;
|
|
3343
|
+
if (typeof createQueries === "string") {
|
|
3344
|
+
if (createQueries !== "no") return null;
|
|
3345
|
+
continue;
|
|
3346
|
+
}
|
|
3347
|
+
for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") schemas.add(schema);
|
|
3348
|
+
}
|
|
3349
|
+
return schemas.size > 0 ? schemas : null;
|
|
3350
|
+
}
|
|
3351
|
+
function flattenCollections(nodes, parentPath = "") {
|
|
3352
|
+
const result = [];
|
|
3353
|
+
for (const node of nodes) {
|
|
3354
|
+
if (node.personal_owner_id !== null) continue;
|
|
3355
|
+
const p = parentPath ? `${parentPath}/${node.name}` : node.name;
|
|
3356
|
+
result.push({
|
|
3357
|
+
id: node.id,
|
|
3358
|
+
path: p
|
|
3359
|
+
});
|
|
3360
|
+
if (node.children.length > 0) result.push(...flattenCollections(node.children, p));
|
|
3361
|
+
}
|
|
3362
|
+
return result;
|
|
3363
|
+
}
|
|
3364
|
+
const MAX_TREE_DEPTH = 2;
|
|
3365
|
+
function renderTree(nodes, indent = 0) {
|
|
3366
|
+
let out = "";
|
|
3367
|
+
for (const node of nodes) {
|
|
3368
|
+
if (node.personal_owner_id !== null) continue;
|
|
3369
|
+
const prefix = " ".repeat(indent);
|
|
3370
|
+
const icon = node.children.length > 0 ? "+" : "-";
|
|
3371
|
+
out += `${prefix}${icon} ${node.name} (${node.id})\n`;
|
|
3372
|
+
if (node.children.length > 0 && indent < MAX_TREE_DEPTH - 1) out += renderTree(node.children, indent + 1);
|
|
3373
|
+
else if (node.children.length > 0) {
|
|
3374
|
+
const childCount = node.children.filter((c) => c.personal_owner_id === null).length;
|
|
3375
|
+
if (childCount > 0) out += `${prefix} ... ${childCount} sub-collections\n`;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
return out;
|
|
3379
|
+
}
|
|
3380
|
+
function aggregateParameters(dashboards) {
|
|
3381
|
+
const paramMap = /* @__PURE__ */ new Map();
|
|
3382
|
+
for (const d of dashboards) for (const p of d.parameters) {
|
|
3383
|
+
const key = `${p.type}:${p.slug}`;
|
|
3384
|
+
if (!paramMap.has(key)) paramMap.set(key, {
|
|
3385
|
+
name: p.name,
|
|
3386
|
+
type: p.type,
|
|
3387
|
+
slug: p.slug,
|
|
3388
|
+
dashboardCount: 0,
|
|
3389
|
+
dashboards: []
|
|
3390
|
+
});
|
|
3391
|
+
const entry = paramMap.get(key);
|
|
3392
|
+
entry.dashboardCount++;
|
|
3393
|
+
entry.dashboards.push(d.name);
|
|
3394
|
+
}
|
|
3395
|
+
return Array.from(paramMap.values()).sort((a, b) => b.dashboardCount - a.dashboardCount);
|
|
3396
|
+
}
|
|
3397
|
+
function summarizeTable(table) {
|
|
3398
|
+
let dimensions = 0, measures = 0, timeDimensions = 0;
|
|
3399
|
+
for (const f of table.fields) {
|
|
3400
|
+
if (f.visibility_type === "hidden" || f.visibility_type === "retired") continue;
|
|
3401
|
+
const cls = classifyField(f);
|
|
3402
|
+
if (cls === "dimension") dimensions++;
|
|
3403
|
+
else if (cls === "measure") measures++;
|
|
3404
|
+
else if (cls === "time") timeDimensions++;
|
|
3405
|
+
}
|
|
3406
|
+
return {
|
|
3407
|
+
id: table.id,
|
|
3408
|
+
name: table.name,
|
|
3409
|
+
schema: table.schema,
|
|
3410
|
+
description: table.description,
|
|
3411
|
+
fieldCount: table.fields.length,
|
|
3412
|
+
dimensions,
|
|
3413
|
+
measures,
|
|
3414
|
+
timeDimensions,
|
|
3415
|
+
hasDescription: !!table.description
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
function buildReport(data) {
|
|
3419
|
+
const { instanceUrl, user, databases, cards, dashboardList, dashboardDetails, collectionTree, collectionMap, permissionGroups, permissionsGraph, topCardsLimit, convertedSqlMap } = data;
|
|
3420
|
+
/** Get SQL for a card — native extraction first, then converted MBQL fallback. */
|
|
3421
|
+
function getCardSQL(card) {
|
|
3422
|
+
return extractSQL(card) || convertedSqlMap.get(card.id) || null;
|
|
3423
|
+
}
|
|
3424
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
3425
|
+
const activeDashboards = dashboardList.filter((d) => !d.archived);
|
|
3426
|
+
const models = activeCards.filter((c) => getCardType(c) === "model");
|
|
3427
|
+
const metrics = activeCards.filter((c) => getCardType(c) === "metric");
|
|
3428
|
+
const questions = activeCards.filter((c) => getCardType(c) === "question");
|
|
3429
|
+
const topCards = [...activeCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, topCardsLimit);
|
|
3430
|
+
const activeCount = activeCards.filter(isCardActive).length;
|
|
3431
|
+
const inactiveCount = activeCards.length - activeCount;
|
|
3432
|
+
const userFacingSchemasMap = /* @__PURE__ */ new Map();
|
|
3433
|
+
if (permissionGroups && permissionsGraph) for (const db of databases) userFacingSchemasMap.set(db.id, getUserFacingSchemas(permissionsGraph, permissionGroups, db.id));
|
|
3434
|
+
let report = "";
|
|
3435
|
+
report += `# Metabase Analysis Report\n\n`;
|
|
3436
|
+
report += `Generated: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n`;
|
|
3437
|
+
report += `Instance: ${instanceUrl}\n`;
|
|
3438
|
+
report += `User: ${user.name} (${user.email})\n\n`;
|
|
3439
|
+
report += `## How to Use This Report\n\n`;
|
|
3440
|
+
report += `This report maps your Metabase instance to inform semantic layer design:\n\n`;
|
|
3441
|
+
report += `1. **Most Referenced Tables** → Create cubes for these tables first\n`;
|
|
3442
|
+
report += `2. **Top Cards by Activity** → Replicate aggregations (GROUP BY + SUM/COUNT/AVG) as cube measures\n`;
|
|
3443
|
+
report += `3. **Common Filter Variables** → Ensure these are dimensions on relevant cubes\n`;
|
|
3444
|
+
report += `4. **Foreign Key Relationships** → Define joins between cubes\n`;
|
|
3445
|
+
report += `5. **Collection Structure** → Map collections to views (one view per business domain)\n`;
|
|
3446
|
+
report += `6. **Table Inventory** → Use field classification (dims/measures/time) to build each cube\n\n`;
|
|
3447
|
+
report += `Drill deeper with:\n`;
|
|
3448
|
+
report += `- \`bon metabase explore table <id>\` — field types and classification\n`;
|
|
3449
|
+
report += `- \`bon metabase explore card <id>\` — SQL and columns\n`;
|
|
3450
|
+
report += `- \`bon metabase explore collection <id>\` — cards in a collection\n`;
|
|
3451
|
+
report += `- \`bon metabase explore database <id>\` — schemas and tables\n\n`;
|
|
3452
|
+
report += `## Summary\n\n`;
|
|
3453
|
+
report += `| Metric | Count |\n|--------|-------|\n`;
|
|
3454
|
+
report += `| Databases | ${databases.length} |\n`;
|
|
3455
|
+
for (const db of databases) {
|
|
3456
|
+
const visibleTables = db.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
|
|
3457
|
+
report += `| Tables (${db.name}) | ${visibleTables.length} |\n`;
|
|
3458
|
+
}
|
|
3459
|
+
report += `| Models | ${models.length} |\n`;
|
|
3460
|
+
report += `| Metrics | ${metrics.length} |\n`;
|
|
3461
|
+
report += `| Questions | ${questions.length} |\n`;
|
|
3462
|
+
report += `| Dashboards | ${activeDashboards.length} |\n`;
|
|
3463
|
+
report += `| Collections | ${collectionMap.size} |\n`;
|
|
3464
|
+
report += `| Active cards (used in last ${INACTIVE_MONTHS}mo) | ${activeCount} |\n`;
|
|
3465
|
+
report += `| Inactive cards | ${inactiveCount} |\n`;
|
|
3466
|
+
report += `\n`;
|
|
3467
|
+
if (permissionGroups && permissionsGraph) {
|
|
3468
|
+
report += `## Schema Access\n\n`;
|
|
3469
|
+
report += `Schemas accessible to non-admin groups are user-facing and should be prioritized for modeling.\n\n`;
|
|
3470
|
+
for (const db of databases) {
|
|
3471
|
+
const access = extractSchemaAccess(permissionsGraph, permissionGroups, db.id);
|
|
3472
|
+
report += `### ${db.name} (${db.engine})\n\n`;
|
|
3473
|
+
report += `| Schema | Accessible To |\n|--------|---------------|\n`;
|
|
3474
|
+
for (const a of access) report += `| ${a.schema} | ${a.groups.join(", ")} |\n`;
|
|
3475
|
+
report += `\n`;
|
|
3476
|
+
}
|
|
3477
|
+
report += `### Permission Groups\n\n`;
|
|
3478
|
+
report += `| Group | Members |\n|-------|---------|\n`;
|
|
3479
|
+
for (const g of permissionGroups) report += `| ${g.name} | ${g.member_count} |\n`;
|
|
3480
|
+
report += `\n`;
|
|
3481
|
+
}
|
|
3482
|
+
report += `## Collection Structure (Business Domains)\n\n`;
|
|
3483
|
+
report += `Collections represent how users organize content by business area.\n`;
|
|
3484
|
+
report += `Map these to views in the semantic layer.\n\n`;
|
|
3485
|
+
report += "```\n";
|
|
3486
|
+
const filtered = collectionTree.filter((n) => n.personal_owner_id === null);
|
|
3487
|
+
report += renderTree(filtered);
|
|
3488
|
+
report += "```\n\n";
|
|
3489
|
+
report += `## Top ${topCards.length} Cards by Activity\n\n`;
|
|
3490
|
+
report += `Ranked by view count, weighted by recency. Cards not used in the last ${INACTIVE_MONTHS} months are penalized 90%.\n`;
|
|
3491
|
+
report += `Use \`bon metabase explore card <id>\` to view SQL and column details for any card.\n\n`;
|
|
3492
|
+
report += `| Rank | ID | Views | Last Used | Active | Pattern | Type | Display | Collection | Name |\n`;
|
|
3493
|
+
report += `|------|----|-------|-----------|--------|---------|------|---------|------------|------|\n`;
|
|
3494
|
+
for (let i = 0; i < topCards.length; i++) {
|
|
3495
|
+
const c = topCards[i];
|
|
3496
|
+
const ct = getCardType(c);
|
|
3497
|
+
const col = c.collection_id ? collectionMap.get(c.collection_id) || "?" : "Root";
|
|
3498
|
+
const active = isCardActive(c) ? "Yes" : "No";
|
|
3499
|
+
const lastUsed = formatLastUsed(c);
|
|
3500
|
+
const pattern = classifyCardPattern(c, getCardSQL(c));
|
|
3501
|
+
report += `| ${i + 1} | ${c.id} | ${c.view_count || 0} | ${lastUsed} | ${active} | ${pattern} | ${ct} | ${c.display} | ${col} | ${c.name} |\n`;
|
|
3502
|
+
}
|
|
3503
|
+
report += `\n`;
|
|
3504
|
+
if (dashboardDetails.length > 0) {
|
|
3505
|
+
const params = aggregateParameters(dashboardDetails);
|
|
3506
|
+
report += `## Dashboard Filter Parameters\n\n`;
|
|
3507
|
+
report += `Parameters used across dashboards. These represent the most important filter dimensions.\n\n`;
|
|
3508
|
+
if (params.length > 0) {
|
|
3509
|
+
report += `| Parameter | Type | Used In (dashboards) | Dashboard Names |\n`;
|
|
3510
|
+
report += `|-----------|------|----------------------|-----------------|\n`;
|
|
3511
|
+
for (const p of params) {
|
|
3512
|
+
const names = p.dashboards.slice(0, 3).join(", ");
|
|
3513
|
+
const more = p.dashboards.length > 3 ? ` +${p.dashboards.length - 3} more` : "";
|
|
3514
|
+
report += `| ${p.name} | ${p.type} | ${p.dashboardCount} | ${names}${more} |\n`;
|
|
3515
|
+
}
|
|
3516
|
+
report += `\n`;
|
|
3517
|
+
} else report += `No parameters found across analyzed dashboards.\n\n`;
|
|
3518
|
+
}
|
|
3519
|
+
const templateVarCounts = /* @__PURE__ */ new Map();
|
|
3520
|
+
for (const c of activeCards) {
|
|
3521
|
+
const sql = getCardSQL(c);
|
|
3522
|
+
if (!sql) continue;
|
|
3523
|
+
const vars = extractTemplateVars(sql);
|
|
3524
|
+
for (const v of vars) templateVarCounts.set(v, (templateVarCounts.get(v) || 0) + 1);
|
|
3525
|
+
}
|
|
3526
|
+
if (templateVarCounts.size > 0) {
|
|
3527
|
+
const sortedVars = Array.from(templateVarCounts.entries()).sort((a, b) => b[1] - a[1]).filter(([, count]) => count >= 3);
|
|
3528
|
+
if (sortedVars.length > 0) {
|
|
3529
|
+
const totalVars = templateVarCounts.size;
|
|
3530
|
+
report += `## Common Filter Variables (from SQL)\n\n`;
|
|
3531
|
+
report += `Template variables (\`{{var}}\`) used in 3+ cards. These represent key filter dimensions.\n`;
|
|
3532
|
+
report += `${totalVars} unique variables found; showing ${sortedVars.length} most common.\n\n`;
|
|
3533
|
+
report += `| Variable | Used In (cards) |\n|----------|-----------------|\n`;
|
|
3534
|
+
for (const [varName, count] of sortedVars) report += `| ${varName} | ${count} |\n`;
|
|
3535
|
+
report += `\n`;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
const globalTableRefs = /* @__PURE__ */ new Map();
|
|
3539
|
+
for (const c of activeCards) {
|
|
3540
|
+
const sql = getCardSQL(c);
|
|
3541
|
+
if (!sql) continue;
|
|
3542
|
+
const refs = extractTableReferences(sql);
|
|
3543
|
+
for (const [table, count] of refs) globalTableRefs.set(table, (globalTableRefs.get(table) || 0) + count);
|
|
3544
|
+
}
|
|
3545
|
+
if (globalTableRefs.size > 0) {
|
|
3546
|
+
const sortedRefs = Array.from(globalTableRefs.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20);
|
|
3547
|
+
report += `## Most Referenced Tables (from SQL)\n\n`;
|
|
3548
|
+
report += `Tables most frequently referenced in FROM/JOIN clauses across all cards.\n\n`;
|
|
3549
|
+
report += `| Table | References |\n|-------|------------|\n`;
|
|
3550
|
+
for (const [table, count] of sortedRefs) report += `| ${table} | ${count} |\n`;
|
|
3551
|
+
report += `\n`;
|
|
3552
|
+
}
|
|
3553
|
+
const fieldIdLookup = /* @__PURE__ */ new Map();
|
|
3554
|
+
for (const db of databases) for (const t of db.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
|
|
3555
|
+
schema: t.schema,
|
|
3556
|
+
table: t.name,
|
|
3557
|
+
field: f.name
|
|
3558
|
+
});
|
|
3559
|
+
const fkRelationships = [];
|
|
3560
|
+
for (const db of databases) {
|
|
3561
|
+
const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
|
|
3562
|
+
for (const t of db.tables) {
|
|
3563
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(t.schema)) continue;
|
|
3564
|
+
for (const f of t.fields) {
|
|
3565
|
+
if (!f.fk_target_field_id) continue;
|
|
3566
|
+
const target = fieldIdLookup.get(f.fk_target_field_id);
|
|
3567
|
+
if (!target) continue;
|
|
3568
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(target.schema)) continue;
|
|
3569
|
+
fkRelationships.push({
|
|
3570
|
+
fromSchema: t.schema,
|
|
3571
|
+
fromTable: t.name,
|
|
3572
|
+
fromField: f.name,
|
|
3573
|
+
toSchema: target.schema,
|
|
3574
|
+
toTable: target.table,
|
|
3575
|
+
toField: target.field
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
if (fkRelationships.length > 0) {
|
|
3581
|
+
const referencedFKs = fkRelationships.filter((fk) => {
|
|
3582
|
+
const fromKey = `${fk.fromSchema}.${fk.fromTable}`.toLowerCase();
|
|
3583
|
+
const toKey = `${fk.toSchema}.${fk.toTable}`.toLowerCase();
|
|
3584
|
+
return globalTableRefs.has(fromKey) || globalTableRefs.has(toKey);
|
|
3585
|
+
});
|
|
3586
|
+
if (referencedFKs.length > 0) {
|
|
3587
|
+
report += `## Foreign Key Relationships\n\n`;
|
|
3588
|
+
report += `FK relationships involving tables referenced by cards. Use these to define cube joins.\n`;
|
|
3589
|
+
if (referencedFKs.length < fkRelationships.length) report += `${fkRelationships.length - referencedFKs.length} unreferenced FKs omitted.\n`;
|
|
3590
|
+
report += `\n`;
|
|
3591
|
+
report += `| From | Field | To | Field |\n`;
|
|
3592
|
+
report += `|------|-------|----|-------|\n`;
|
|
3593
|
+
for (const fk of referencedFKs) {
|
|
3594
|
+
const from = fk.fromSchema === fk.toSchema ? fk.fromTable : `${fk.fromSchema}.${fk.fromTable}`;
|
|
3595
|
+
const to = fk.fromSchema === fk.toSchema ? fk.toTable : `${fk.toSchema}.${fk.toTable}`;
|
|
3596
|
+
report += `| ${from} | ${fk.fromField} | ${to} | ${fk.toField} |\n`;
|
|
3597
|
+
}
|
|
3598
|
+
report += `\n`;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
report += `## Table Inventory\n\n`;
|
|
3602
|
+
if (permissionGroups && permissionsGraph) {
|
|
3603
|
+
report += `Showing tables from user-facing schemas only (accessible to non-admin groups).\n`;
|
|
3604
|
+
report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
|
|
3605
|
+
} else {
|
|
3606
|
+
report += `Permissions data unavailable — showing all schemas.\n`;
|
|
3607
|
+
report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
|
|
3608
|
+
}
|
|
3609
|
+
report += `Use \`bon metabase explore databases\` for full database details.\n\n`;
|
|
3610
|
+
report += `Field classification: **Dims** = categorical/text/PKs/FKs, **Measures** = numeric, **Time** = date/datetime\n\n`;
|
|
3611
|
+
let skippedSchemas = 0;
|
|
3612
|
+
let skippedTables = 0;
|
|
3613
|
+
for (const db of databases) {
|
|
3614
|
+
const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
|
|
3615
|
+
const bySchema = /* @__PURE__ */ new Map();
|
|
3616
|
+
for (const t of db.tables) {
|
|
3617
|
+
if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
|
|
3618
|
+
if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
|
|
3619
|
+
bySchema.get(t.schema).push(t);
|
|
3620
|
+
}
|
|
3621
|
+
for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
3622
|
+
if (userFacingSchemas !== null && !userFacingSchemas.has(schema)) {
|
|
3623
|
+
skippedSchemas++;
|
|
3624
|
+
skippedTables += tables.length;
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
const filteredTables = tables.filter((t) => !t.name.startsWith("stg_") && !t.name.startsWith("int_"));
|
|
3628
|
+
skippedTables += tables.length - filteredTables.length;
|
|
3629
|
+
if (filteredTables.length === 0) continue;
|
|
3630
|
+
const summaries = filteredTables.map((t) => {
|
|
3631
|
+
const s = summarizeTable(t);
|
|
3632
|
+
const refKey1 = `${s.schema}.${s.name}`.toLowerCase();
|
|
3633
|
+
const refKey2 = s.name.toLowerCase();
|
|
3634
|
+
const refCount = globalTableRefs.get(refKey1) || globalTableRefs.get(refKey2) || 0;
|
|
3635
|
+
return {
|
|
3636
|
+
...s,
|
|
3637
|
+
refCount
|
|
3638
|
+
};
|
|
3639
|
+
}).sort((a, b) => b.refCount - a.refCount || a.name.localeCompare(b.name));
|
|
3640
|
+
if (!summaries.some((s) => s.refCount > 0)) {
|
|
3641
|
+
skippedSchemas++;
|
|
3642
|
+
skippedTables += filteredTables.length;
|
|
3643
|
+
continue;
|
|
3644
|
+
}
|
|
3645
|
+
const referenced = summaries.filter((s) => s.refCount > 0);
|
|
3646
|
+
const unreferenced = summaries.length - referenced.length;
|
|
3647
|
+
report += `### ${db.name} / ${schema} (${referenced.length} referenced`;
|
|
3648
|
+
if (unreferenced > 0) report += `, ${unreferenced} unreferenced`;
|
|
3649
|
+
report += `)\n\n`;
|
|
3650
|
+
report += `| Table | Fields | Dims | Measures | Time | Refs |\n`;
|
|
3651
|
+
report += `|-------|--------|------|----------|------|------|\n`;
|
|
3652
|
+
for (const s of referenced) report += `| ${s.name} | ${s.fieldCount} | ${s.dimensions} | ${s.measures} | ${s.timeDimensions} | ${s.refCount} |\n`;
|
|
3653
|
+
if (unreferenced > 0) skippedTables += unreferenced;
|
|
3654
|
+
report += `\n`;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
if (skippedSchemas > 0 || skippedTables > 0) report += `*${skippedSchemas} admin-only schemas and ${skippedTables} staging/intermediate tables omitted.*\n\n`;
|
|
3658
|
+
report += `## Cards by Domain\n\n`;
|
|
3659
|
+
report += `Card counts and top questions per collection (by view count).\n\n`;
|
|
3660
|
+
const cardsByCollection = /* @__PURE__ */ new Map();
|
|
3661
|
+
for (const c of activeCards) {
|
|
3662
|
+
const col = c.collection_id ? collectionMap.get(c.collection_id) || "Uncategorized" : "Root";
|
|
3663
|
+
if (!cardsByCollection.has(col)) cardsByCollection.set(col, []);
|
|
3664
|
+
cardsByCollection.get(col).push(c);
|
|
3665
|
+
}
|
|
3666
|
+
const sortedCollections = Array.from(cardsByCollection.entries()).map(([col, colCards]) => ({
|
|
3667
|
+
col,
|
|
3668
|
+
colCards,
|
|
3669
|
+
activeCount: colCards.filter(isCardActive).length
|
|
3670
|
+
})).filter((c) => c.activeCount > 0).sort((a, b) => b.activeCount - a.activeCount);
|
|
3671
|
+
for (const { col, colCards, activeCount } of sortedCollections) {
|
|
3672
|
+
const topNames = [...colCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 3).map((c) => c.name).join(", ");
|
|
3673
|
+
report += `- **${col}** (${colCards.length} cards, ${activeCount} active): ${topNames}\n`;
|
|
3674
|
+
}
|
|
3675
|
+
const inactiveCollections = cardsByCollection.size - sortedCollections.length;
|
|
3676
|
+
if (inactiveCollections > 0) report += `\n*${inactiveCollections} collections with no active cards omitted.*\n`;
|
|
3677
|
+
report += `\n`;
|
|
3678
|
+
return report;
|
|
3679
|
+
}
|
|
3680
|
+
async function metabaseAnalyzeCommand(options) {
|
|
3681
|
+
const config = loadMetabaseConfig();
|
|
3682
|
+
if (!config) {
|
|
3683
|
+
console.error(pc.red("Metabase is not configured."));
|
|
3684
|
+
console.log(pc.dim("Run: bon metabase connect"));
|
|
3685
|
+
process.exit(1);
|
|
3686
|
+
}
|
|
3687
|
+
const client = createMetabaseClient(config);
|
|
3688
|
+
const outputPath = options.output || OUTPUT_FILE;
|
|
3689
|
+
const topCardsLimit = options.topCards ? parseInt(options.topCards, 10) : TOP_CARDS_LIMIT;
|
|
3690
|
+
console.log();
|
|
3691
|
+
console.log(pc.dim("Fetching data from Metabase..."));
|
|
3692
|
+
const [user, cards, databases, collectionTree, dashboardList] = await Promise.all([
|
|
3693
|
+
client.getCurrentUser(),
|
|
3694
|
+
client.getCards(),
|
|
3695
|
+
client.getDatabases(),
|
|
3696
|
+
client.getCollectionTree(),
|
|
3697
|
+
client.getDashboards()
|
|
3698
|
+
]);
|
|
3699
|
+
console.log(pc.dim(` ${cards.length} cards, ${databases.length} databases, ${dashboardList.filter((d) => !d.archived).length} dashboards`));
|
|
3700
|
+
let permissionGroups = null;
|
|
3701
|
+
let permissionsGraph = null;
|
|
3702
|
+
try {
|
|
3703
|
+
[permissionGroups, permissionsGraph] = await Promise.all([client.getPermissionGroups(), client.getPermissionsGraph()]);
|
|
3704
|
+
console.log(pc.dim(` ${permissionGroups.length} permission groups`));
|
|
3705
|
+
} catch (err) {
|
|
3706
|
+
if (err instanceof MetabaseApiError && (err.status === 401 || err.status === 403)) console.log(pc.dim(" Permissions: skipped (requires admin API key)"));
|
|
3707
|
+
else throw err;
|
|
3708
|
+
}
|
|
3709
|
+
console.log(pc.dim("Fetching database metadata..."));
|
|
3710
|
+
const dbMetadata = [];
|
|
3711
|
+
for (const db of databases) {
|
|
3712
|
+
const meta = await client.getDatabaseMetadata(db.id);
|
|
3713
|
+
const visibleTables = meta.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
|
|
3714
|
+
console.log(pc.dim(` ${db.name}: ${visibleTables.length} tables`));
|
|
3715
|
+
dbMetadata.push(meta);
|
|
3716
|
+
}
|
|
3717
|
+
console.log(pc.dim("Fetching top dashboard details..."));
|
|
3718
|
+
const activeDashboards = dashboardList.filter((d) => !d.archived);
|
|
3719
|
+
let popularDashboardIds = /* @__PURE__ */ new Set();
|
|
3720
|
+
try {
|
|
3721
|
+
const popular = await client.getPopularItems();
|
|
3722
|
+
for (const item of popular) if (item.model === "dashboard") popularDashboardIds.add(item.model_id);
|
|
3723
|
+
} catch {}
|
|
3724
|
+
const dashboardsToFetch = [...activeDashboards].sort((a, b) => {
|
|
3725
|
+
const aPopular = popularDashboardIds.has(a.id) ? 1 : 0;
|
|
3726
|
+
return (popularDashboardIds.has(b.id) ? 1 : 0) - aPopular;
|
|
3727
|
+
}).slice(0, TOP_DASHBOARDS_LIMIT);
|
|
3728
|
+
const dashboardDetails = [];
|
|
3729
|
+
for (const d of dashboardsToFetch) try {
|
|
3730
|
+
const detail = await client.getDashboard(d.id);
|
|
3731
|
+
dashboardDetails.push(detail);
|
|
3732
|
+
} catch {}
|
|
3733
|
+
console.log(pc.dim(` ${dashboardDetails.length} dashboards analyzed`));
|
|
3734
|
+
const MBQL_CONVERT_LIMIT = 100;
|
|
3735
|
+
const activeCards = cards.filter((c) => !c.archived);
|
|
3736
|
+
const mbqlCards = activeCards.filter(isMbqlCard).sort((a, b) => activityScore(b) - activityScore(a)).slice(0, MBQL_CONVERT_LIMIT);
|
|
3737
|
+
const convertedSqlMap = /* @__PURE__ */ new Map();
|
|
3738
|
+
if (mbqlCards.length > 0) {
|
|
3739
|
+
const totalMbql = activeCards.filter(isMbqlCard).length;
|
|
3740
|
+
console.log(pc.dim(`Converting top ${mbqlCards.length} query-builder cards to SQL (${totalMbql} total, capped at ${MBQL_CONVERT_LIMIT})...`));
|
|
3741
|
+
const BATCH_SIZE = 10;
|
|
3742
|
+
for (let i = 0; i < mbqlCards.length; i += BATCH_SIZE) {
|
|
3743
|
+
const batch = mbqlCards.slice(i, i + BATCH_SIZE);
|
|
3744
|
+
const results = await Promise.allSettled(batch.map(async (c) => {
|
|
3745
|
+
const sql = await client.convertToNativeSQL(c.dataset_query);
|
|
3746
|
+
return {
|
|
3747
|
+
id: c.id,
|
|
3748
|
+
sql
|
|
3749
|
+
};
|
|
3750
|
+
}));
|
|
3751
|
+
for (const r of results) if (r.status === "fulfilled") convertedSqlMap.set(r.value.id, r.value.sql);
|
|
3752
|
+
}
|
|
3753
|
+
console.log(pc.dim(` ${convertedSqlMap.size}/${mbqlCards.length} converted successfully`));
|
|
3754
|
+
}
|
|
3755
|
+
const flatCollections = flattenCollections(collectionTree.filter((n) => n.personal_owner_id === null));
|
|
3756
|
+
const collectionMap = new Map(flatCollections.map((c) => [c.id, c.path]));
|
|
3757
|
+
console.log(pc.dim("Building report..."));
|
|
3758
|
+
const report = buildReport({
|
|
3759
|
+
instanceUrl: config.url,
|
|
3760
|
+
user: {
|
|
3761
|
+
name: `${user.first_name} ${user.last_name}`.trim(),
|
|
3762
|
+
email: user.email,
|
|
3763
|
+
admin: user.is_superuser
|
|
3764
|
+
},
|
|
3765
|
+
databases: dbMetadata,
|
|
3766
|
+
cards,
|
|
3767
|
+
dashboardList,
|
|
3768
|
+
dashboardDetails,
|
|
3769
|
+
collectionTree,
|
|
3770
|
+
collectionMap,
|
|
3771
|
+
permissionGroups,
|
|
3772
|
+
permissionsGraph,
|
|
3773
|
+
topCardsLimit,
|
|
3774
|
+
convertedSqlMap
|
|
3775
|
+
});
|
|
3776
|
+
ensureBonDir();
|
|
3777
|
+
const fullPath = path.resolve(outputPath);
|
|
3778
|
+
fs.writeFileSync(fullPath, report, "utf-8");
|
|
3779
|
+
const allActive = cards.filter((c) => !c.archived);
|
|
3780
|
+
const recentlyUsed = allActive.filter(isCardActive);
|
|
3781
|
+
const topCards = allActive.sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 5);
|
|
3782
|
+
console.log();
|
|
3783
|
+
console.log(pc.green(`✓ Analysis written to ${outputPath}`));
|
|
3784
|
+
console.log();
|
|
3785
|
+
console.log(pc.bold("Key findings:"));
|
|
3786
|
+
console.log();
|
|
3787
|
+
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`);
|
|
3788
|
+
console.log(` ${allActive.length} cards (${recentlyUsed.length} active in last ${INACTIVE_MONTHS}mo, ${allActive.length - recentlyUsed.length} inactive)`);
|
|
3789
|
+
console.log(` ${activeDashboards.length} dashboards across ${collectionMap.size} collections`);
|
|
3790
|
+
if (permissionGroups) {
|
|
3791
|
+
const nonAdminGroups = permissionGroups.filter((g) => g.name !== "Administrators");
|
|
3792
|
+
if (permissionsGraph) for (const db of databases) {
|
|
3793
|
+
const userSchemas = extractSchemaAccess(permissionsGraph, nonAdminGroups, db.id).filter((a) => a.schema !== "*");
|
|
3794
|
+
if (userSchemas.length > 0) console.log(` User-facing schemas: ${userSchemas.map((s) => s.schema).join(", ")}`);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
console.log();
|
|
3798
|
+
console.log(pc.bold("Top cards (by activity):"));
|
|
3799
|
+
for (const c of topCards) {
|
|
3800
|
+
const lastUsed = formatLastUsed(c);
|
|
3801
|
+
const flag = isCardActive(c) ? "" : pc.dim(" (inactive)");
|
|
3802
|
+
console.log(` ${String(c.view_count || 0).padStart(6)} views ${lastUsed.padEnd(12)} ${c.name}${flag}`);
|
|
3803
|
+
}
|
|
3804
|
+
console.log();
|
|
3805
|
+
console.log(pc.dim(`Full report: ${outputPath}`));
|
|
3806
|
+
}
|
|
3807
|
+
|
|
2527
3808
|
//#endregion
|
|
2528
3809
|
//#region src/bin/bon.ts
|
|
2529
3810
|
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
@@ -2535,9 +3816,7 @@ program.command("whoami").description("Show current login status").option("--ver
|
|
|
2535
3816
|
const datasource = program.command("datasource").description("Manage warehouse data source connections");
|
|
2536
3817
|
datasource.command("add").description("Add a data source to .bon/datasources.yaml. Use --name and --type together for non-interactive mode").option("--demo", "Add a read-only demo datasource (Contoso retail dataset) for testing").option("--from-dbt [profile]", "Import from dbt profiles.yml (optionally specify profile/target)").option("--target <target>", "Target name when using --from-dbt").option("--all", "Import all connections from dbt profiles").option("--default-targets", "Import only default targets from dbt profiles (non-interactive)").option("--name <name>", "Datasource name (required for non-interactive mode)").option("--type <type>", "Warehouse type: snowflake, postgres, bigquery, databricks (required for non-interactive mode)").option("--account <account>", "Snowflake account identifier").option("--database <database>", "Database name").option("--schema <schema>", "Schema name").option("--warehouse <warehouse>", "Warehouse name (Snowflake)").option("--role <role>", "Role (Snowflake)").option("--host <host>", "Host (Postgres)").option("--port <port>", "Port (Postgres, default: 5432)").option("--project-id <projectId>", "GCP Project ID (BigQuery)").option("--dataset <dataset>", "Dataset name (BigQuery)").option("--location <location>", "Location (BigQuery)").option("--hostname <hostname>", "Server hostname (Databricks)").option("--http-path <httpPath>", "HTTP path (Databricks)").option("--catalog <catalog>", "Catalog name (Databricks)").option("--user <user>", "Username").option("--password <password>", "Password (use --password-env for env var reference)").option("--token <token>", "Access token (use --token-env for env var reference)").option("--service-account-json <json>", "Service account JSON (BigQuery)").option("--keyfile <path>", "Path to service account key file (BigQuery)").option("--password-env <varName>", "Env var name for password, stores as {{ env_var('NAME') }}").option("--token-env <varName>", "Env var name for token, stores as {{ env_var('NAME') }}").option("--force", "Overwrite existing datasource without prompting").action(datasourceAddCommand);
|
|
2537
3818
|
datasource.command("list").description("List data sources (shows both local and remote by default)").option("--local", "Show only local data sources from .bon/datasources.yaml").option("--remote", "Show only remote data sources from Bonnard server (requires login)").action(datasourceListCommand);
|
|
2538
|
-
datasource.command("test").description("Test data source connectivity via Bonnard API (requires login)").argument("<name>", "Data source name from .bon/datasources.yaml").action(datasourceTestCommand);
|
|
2539
3819
|
datasource.command("remove").description("Remove a data source from .bon/datasources.yaml (local by default)").argument("<name>", "Data source name").option("--remote", "Remove from Bonnard server instead of local (requires login)").action(datasourceRemoveCommand);
|
|
2540
|
-
datasource.command("push").description("Push a local data source to Bonnard server (requires login)").argument("<name>", "Data source name from .bon/datasources.yaml").option("--force", "Overwrite if already exists on remote").action(datasourcePushCommand);
|
|
2541
3820
|
program.command("validate").description("Validate YAML syntax in bonnard/cubes/ and bonnard/views/").action(validateCommand);
|
|
2542
3821
|
program.command("deploy").description("Deploy cubes and views to Bonnard. Requires login, validates, syncs datasources").option("--ci", "Non-interactive mode (fail if missing datasources)").option("--push-datasources", "Auto-push missing datasources without prompting").requiredOption("-m, --message <text>", "Deploy message describing your changes").action(deployCommand);
|
|
2543
3822
|
program.command("deployments").description("List deployment history").option("--all", "Show all deployments (default: last 10)").option("--format <format>", "Output format: table or json", "table").action(deploymentsCommand);
|
|
@@ -2546,7 +3825,11 @@ program.command("annotate").description("Annotate deployment changes with reason
|
|
|
2546
3825
|
program.command("mcp").description("MCP connection info and setup instructions").action(mcpCommand).command("test").description("Test MCP server connectivity").action(mcpTestCommand);
|
|
2547
3826
|
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
3827
|
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);
|
|
3828
|
+
const metabase = program.command("metabase").description("Connect to and explore Metabase content");
|
|
3829
|
+
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);
|
|
3830
|
+
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);
|
|
3831
|
+
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
3832
|
program.parse();
|
|
2550
3833
|
|
|
2551
3834
|
//#endregion
|
|
2552
|
-
export { getProjectPaths as t };
|
|
3835
|
+
export { getProjectPaths as i, resolveEnvVarsInCredentials as n, post as r, getLocalDatasource as t };
|