@bonnard/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/bon.mjs CHANGED
@@ -565,17 +565,22 @@ function createAgentTemplates(cwd, env) {
565
565
  const claudeSkillsDir = path.join(cwd, ".claude", "skills");
566
566
  fs.mkdirSync(claudeRulesDir, { recursive: true });
567
567
  fs.mkdirSync(path.join(claudeSkillsDir, "bonnard-get-started"), { recursive: true });
568
+ fs.mkdirSync(path.join(claudeSkillsDir, "bonnard-metabase-migrate"), { recursive: true });
568
569
  writeTemplateFile(sharedBonnard, path.join(claudeRulesDir, "bonnard.md"), createdFiles);
569
570
  writeTemplateFile(loadTemplate("claude/skills/bonnard-get-started/SKILL.md"), path.join(claudeSkillsDir, "bonnard-get-started", "SKILL.md"), createdFiles);
571
+ writeTemplateFile(loadTemplate("claude/skills/bonnard-metabase-migrate/SKILL.md"), path.join(claudeSkillsDir, "bonnard-metabase-migrate", "SKILL.md"), createdFiles);
570
572
  mergeSettingsJson(loadJsonTemplate("claude/settings.json"), path.join(cwd, ".claude", "settings.json"), createdFiles);
571
573
  const cursorRulesDir = path.join(cwd, ".cursor", "rules");
572
574
  fs.mkdirSync(cursorRulesDir, { recursive: true });
573
575
  writeTemplateFile(withCursorFrontmatter(sharedBonnard, "Bonnard semantic layer project context", true), path.join(cursorRulesDir, "bonnard.mdc"), createdFiles);
574
576
  writeTemplateFile(loadTemplate("cursor/rules/bonnard-get-started.mdc"), path.join(cursorRulesDir, "bonnard-get-started.mdc"), createdFiles);
577
+ writeTemplateFile(loadTemplate("cursor/rules/bonnard-metabase-migrate.mdc"), path.join(cursorRulesDir, "bonnard-metabase-migrate.mdc"), createdFiles);
575
578
  const codexSkillsDir = path.join(cwd, ".agents", "skills");
576
579
  fs.mkdirSync(path.join(codexSkillsDir, "bonnard-get-started"), { recursive: true });
580
+ fs.mkdirSync(path.join(codexSkillsDir, "bonnard-metabase-migrate"), { recursive: true });
577
581
  writeTemplateFile(sharedBonnard, path.join(cwd, "AGENTS.md"), createdFiles);
578
582
  writeTemplateFile(loadTemplate("claude/skills/bonnard-get-started/SKILL.md"), path.join(codexSkillsDir, "bonnard-get-started", "SKILL.md"), createdFiles);
583
+ writeTemplateFile(loadTemplate("claude/skills/bonnard-metabase-migrate/SKILL.md"), path.join(codexSkillsDir, "bonnard-metabase-migrate", "SKILL.md"), createdFiles);
579
584
  return createdFiles;
580
585
  }
581
586
  async function initCommand() {
@@ -867,10 +872,10 @@ async function whoamiCommand(options = {}) {
867
872
  *
868
873
  * Env vars are resolved at deploy time, not import time.
869
874
  */
870
- const BON_DIR$1 = ".bon";
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$1);
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;
@@ -1519,7 +1524,6 @@ async function addDemo(options) {
1519
1524
  console.log(pc.dim(" fact_sales, dim_product, dim_store, dim_customer"));
1520
1525
  console.log();
1521
1526
  console.log(pc.dim(`Test connection: bon datasource test ${name}`));
1522
- console.log(pc.dim(`Explore tables: bon preview ${name} "SELECT table_name FROM information_schema.tables WHERE table_schema = 'contoso'"`));
1523
1527
  }
1524
1528
  /**
1525
1529
  * Main datasource add command
@@ -1626,464 +1630,11 @@ async function datasourceListCommand(options = {}) {
1626
1630
  if (showRemote) await listRemoteDatasources();
1627
1631
  }
1628
1632
 
1629
- //#endregion
1630
- //#region src/lib/connection/snowflake.ts
1631
- /**
1632
- * Snowflake connection testing and querying
1633
- */
1634
- const require$4 = createRequire(import.meta.url);
1635
- function loadSnowflake() {
1636
- try {
1637
- const snowflake = require$4("snowflake-sdk");
1638
- snowflake.configure({ logLevel: "ERROR" });
1639
- return snowflake;
1640
- } catch {
1641
- return null;
1642
- }
1643
- }
1644
- async function testSnowflakeConnection(config, credentials) {
1645
- const snowflake = loadSnowflake();
1646
- if (!snowflake) return {
1647
- success: false,
1648
- message: "Snowflake driver not installed",
1649
- error: "Run: pnpm add snowflake-sdk"
1650
- };
1651
- const startTime = Date.now();
1652
- return new Promise((resolve) => {
1653
- const connection = snowflake.createConnection({
1654
- account: config.account,
1655
- username: credentials.username,
1656
- password: credentials.password,
1657
- database: config.database,
1658
- warehouse: config.warehouse,
1659
- schema: config.schema,
1660
- role: config.role
1661
- });
1662
- connection.connect((err) => {
1663
- if (err) {
1664
- resolve({
1665
- success: false,
1666
- message: "Connection failed",
1667
- error: err.message
1668
- });
1669
- return;
1670
- }
1671
- connection.execute({
1672
- sqlText: "SELECT 1",
1673
- complete: (queryErr) => {
1674
- const latencyMs = Date.now() - startTime;
1675
- connection.destroy(() => {});
1676
- if (queryErr) resolve({
1677
- success: false,
1678
- message: "Query failed",
1679
- error: queryErr.message,
1680
- latencyMs
1681
- });
1682
- else resolve({
1683
- success: true,
1684
- message: "Connection successful",
1685
- latencyMs
1686
- });
1687
- }
1688
- });
1689
- });
1690
- });
1691
- }
1692
- async function querySnowflake(config, credentials, sql, options = {}) {
1693
- const snowflake = loadSnowflake();
1694
- if (!snowflake) return {
1695
- columns: [],
1696
- rows: [],
1697
- rowCount: 0,
1698
- truncated: false,
1699
- error: "Snowflake driver not installed. Run: pnpm add snowflake-sdk"
1700
- };
1701
- const limit = options.limit ?? 1e3;
1702
- return new Promise((resolve) => {
1703
- const connection = snowflake.createConnection({
1704
- account: config.account,
1705
- username: credentials.username,
1706
- password: credentials.password,
1707
- database: options.database || config.database,
1708
- warehouse: config.warehouse,
1709
- schema: options.schema || config.schema,
1710
- role: config.role
1711
- });
1712
- connection.connect((err) => {
1713
- if (err) {
1714
- resolve({
1715
- columns: [],
1716
- rows: [],
1717
- rowCount: 0,
1718
- truncated: false,
1719
- error: err.message
1720
- });
1721
- return;
1722
- }
1723
- connection.execute({
1724
- sqlText: sql,
1725
- complete: (queryErr, _stmt, rows) => {
1726
- connection.destroy(() => {});
1727
- if (queryErr) {
1728
- resolve({
1729
- columns: [],
1730
- rows: [],
1731
- rowCount: 0,
1732
- truncated: false,
1733
- error: queryErr.message
1734
- });
1735
- return;
1736
- }
1737
- const allRows = rows || [];
1738
- const truncated = allRows.length > limit;
1739
- const resultRows = truncated ? allRows.slice(0, limit) : allRows;
1740
- resolve({
1741
- columns: resultRows.length > 0 ? Object.keys(resultRows[0]) : [],
1742
- rows: resultRows,
1743
- rowCount: resultRows.length,
1744
- truncated
1745
- });
1746
- }
1747
- });
1748
- });
1749
- });
1750
- }
1751
-
1752
- //#endregion
1753
- //#region src/lib/connection/postgres.ts
1754
- /**
1755
- * Postgres connection testing and querying
1756
- */
1757
- const require$3 = createRequire(import.meta.url);
1758
- function loadPg() {
1759
- try {
1760
- return require$3("pg");
1761
- } catch {
1762
- return null;
1763
- }
1764
- }
1765
- function createClient(config, credentials, pg) {
1766
- return new pg.Client({
1767
- host: config.host,
1768
- port: config.port ? parseInt(config.port, 10) : 5432,
1769
- database: config.database,
1770
- user: credentials.username,
1771
- password: credentials.password,
1772
- ssl: config.sslmode === "require" ? { rejectUnauthorized: false } : void 0
1773
- });
1774
- }
1775
- async function testPostgresConnection(config, credentials) {
1776
- const pg = loadPg();
1777
- if (!pg) return {
1778
- success: false,
1779
- message: "Postgres driver not installed",
1780
- error: "Run: pnpm add pg"
1781
- };
1782
- const startTime = Date.now();
1783
- const client = createClient(config, credentials, pg);
1784
- try {
1785
- await client.connect();
1786
- await client.query("SELECT 1");
1787
- const latencyMs = Date.now() - startTime;
1788
- await client.end();
1789
- return {
1790
- success: true,
1791
- message: "Connection successful",
1792
- latencyMs
1793
- };
1794
- } catch (err) {
1795
- try {
1796
- await client.end();
1797
- } catch {}
1798
- return {
1799
- success: false,
1800
- message: "Connection failed",
1801
- error: err.message
1802
- };
1803
- }
1804
- }
1805
- async function queryPostgres(config, credentials, sql, options = {}) {
1806
- const pg = loadPg();
1807
- if (!pg) return {
1808
- columns: [],
1809
- rows: [],
1810
- rowCount: 0,
1811
- truncated: false,
1812
- error: "Postgres driver not installed. Run: pnpm add pg"
1813
- };
1814
- const limit = options.limit ?? 1e3;
1815
- const client = createClient(config, credentials, pg);
1816
- try {
1817
- await client.connect();
1818
- const schema = options.schema || config.schema;
1819
- if (schema) {
1820
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema)) throw new Error("Invalid schema name");
1821
- await client.query(`SET search_path TO "${schema}"`);
1822
- }
1823
- const result = await client.query(sql);
1824
- await client.end();
1825
- const columns = result.fields?.map((f) => f.name) || [];
1826
- const allRows = result.rows || [];
1827
- const truncated = allRows.length > limit;
1828
- const rows = truncated ? allRows.slice(0, limit) : allRows;
1829
- return {
1830
- columns,
1831
- rows,
1832
- rowCount: rows.length,
1833
- truncated
1834
- };
1835
- } catch (err) {
1836
- try {
1837
- await client.end();
1838
- } catch {}
1839
- return {
1840
- columns: [],
1841
- rows: [],
1842
- rowCount: 0,
1843
- truncated: false,
1844
- error: err.message
1845
- };
1846
- }
1847
- }
1848
-
1849
- //#endregion
1850
- //#region src/lib/connection/bigquery.ts
1851
- /**
1852
- * BigQuery connection testing
1853
- */
1854
- const require$2 = createRequire(import.meta.url);
1855
- async function testBigQueryConnection(config, credentials) {
1856
- let BigQuery;
1857
- try {
1858
- BigQuery = require$2("@google-cloud/bigquery").BigQuery;
1859
- } catch {
1860
- return {
1861
- success: false,
1862
- message: "BigQuery driver not installed",
1863
- error: "Run: pnpm add @google-cloud/bigquery"
1864
- };
1865
- }
1866
- const startTime = Date.now();
1867
- try {
1868
- const options = { projectId: config.project_id };
1869
- if (config.location) options.location = config.location;
1870
- if (credentials.service_account_json) options.credentials = JSON.parse(credentials.service_account_json);
1871
- else if (credentials.keyfile_path) options.keyFilename = credentials.keyfile_path;
1872
- await new BigQuery(options).query("SELECT 1");
1873
- return {
1874
- success: true,
1875
- message: "Connection successful",
1876
- latencyMs: Date.now() - startTime
1877
- };
1878
- } catch (err) {
1879
- return {
1880
- success: false,
1881
- message: "Connection failed",
1882
- error: err.message
1883
- };
1884
- }
1885
- }
1886
-
1887
- //#endregion
1888
- //#region src/lib/connection/databricks.ts
1889
- /**
1890
- * Databricks connection testing
1891
- */
1892
- const require$1 = createRequire(import.meta.url);
1893
- async function testDatabricksConnection(config, credentials) {
1894
- let DBSQLClient;
1895
- try {
1896
- const module = require$1("@databricks/sql");
1897
- DBSQLClient = module.default || module;
1898
- } catch {
1899
- return {
1900
- success: false,
1901
- message: "Databricks driver not installed",
1902
- error: "Run: pnpm add @databricks/sql"
1903
- };
1904
- }
1905
- const startTime = Date.now();
1906
- const client = new DBSQLClient();
1907
- try {
1908
- const connection = await client.connect({
1909
- host: config.hostname,
1910
- path: config.http_path,
1911
- token: credentials.token
1912
- });
1913
- const session = await connection.openSession({
1914
- initialCatalog: config.catalog,
1915
- initialSchema: config.schema
1916
- });
1917
- const operation = await session.executeStatement("SELECT 1");
1918
- await operation.fetchAll();
1919
- await operation.close();
1920
- const latencyMs = Date.now() - startTime;
1921
- await session.close();
1922
- await connection.close();
1923
- return {
1924
- success: true,
1925
- message: "Connection successful",
1926
- latencyMs
1927
- };
1928
- } catch (err) {
1929
- try {
1930
- await client.close();
1931
- } catch {}
1932
- return {
1933
- success: false,
1934
- message: "Connection failed",
1935
- error: err.message
1936
- };
1937
- }
1938
- }
1939
-
1940
- //#endregion
1941
- //#region src/lib/connection/index.ts
1942
- var connection_exports = /* @__PURE__ */ __exportAll({
1943
- executeQuery: () => executeQuery,
1944
- testConnection: () => testConnection
1945
- });
1946
- /**
1947
- * Test connection to a datasource
1948
- */
1949
- async function testConnection(datasource) {
1950
- const { type, config, credentials } = datasource;
1951
- switch (type) {
1952
- case "snowflake": return testSnowflakeConnection({
1953
- account: config.account,
1954
- database: config.database,
1955
- warehouse: config.warehouse,
1956
- schema: config.schema,
1957
- role: config.role
1958
- }, {
1959
- username: credentials.username,
1960
- password: credentials.password
1961
- });
1962
- case "postgres": return testPostgresConnection({
1963
- host: config.host,
1964
- port: config.port,
1965
- database: config.database,
1966
- schema: config.schema,
1967
- sslmode: config.sslmode
1968
- }, {
1969
- username: credentials.username,
1970
- password: credentials.password
1971
- });
1972
- case "bigquery": return testBigQueryConnection({
1973
- project_id: config.project_id,
1974
- dataset: config.dataset,
1975
- location: config.location
1976
- }, {
1977
- service_account_json: credentials.service_account_json,
1978
- keyfile_path: credentials.keyfile_path
1979
- });
1980
- case "databricks": return testDatabricksConnection({
1981
- hostname: config.hostname,
1982
- http_path: config.http_path,
1983
- catalog: config.catalog,
1984
- schema: config.schema
1985
- }, { token: credentials.token });
1986
- default: return {
1987
- success: false,
1988
- message: `Unsupported warehouse type: ${type}`
1989
- };
1990
- }
1991
- }
1992
- /**
1993
- * Execute a query against a datasource
1994
- */
1995
- async function executeQuery(datasource, sql, options = {}) {
1996
- const { type, config, credentials } = datasource;
1997
- switch (type) {
1998
- case "snowflake": return querySnowflake({
1999
- account: config.account,
2000
- database: config.database,
2001
- warehouse: config.warehouse,
2002
- schema: config.schema,
2003
- role: config.role
2004
- }, {
2005
- username: credentials.username,
2006
- password: credentials.password
2007
- }, sql, options);
2008
- case "postgres": return queryPostgres({
2009
- host: config.host,
2010
- port: config.port,
2011
- database: config.database,
2012
- schema: config.schema,
2013
- sslmode: config.sslmode
2014
- }, {
2015
- username: credentials.username,
2016
- password: credentials.password
2017
- }, sql, options);
2018
- case "bigquery": return {
2019
- columns: [],
2020
- rows: [],
2021
- rowCount: 0,
2022
- truncated: false,
2023
- error: "BigQuery local querying not yet implemented"
2024
- };
2025
- case "databricks": return {
2026
- columns: [],
2027
- rows: [],
2028
- rowCount: 0,
2029
- truncated: false,
2030
- error: "Databricks local querying not yet implemented"
2031
- };
2032
- default: return {
2033
- columns: [],
2034
- rows: [],
2035
- rowCount: 0,
2036
- truncated: false,
2037
- error: `Unsupported warehouse type: ${type}`
2038
- };
2039
- }
2040
- }
2041
-
2042
1633
  //#endregion
2043
1634
  //#region src/commands/datasource/test.ts
2044
- async function datasourceTestCommand(name, options = {}) {
2045
- const localDs = getLocalDatasource(name);
2046
- if (options.remote || !localDs) {
2047
- await testRemote(name, !localDs);
2048
- return;
2049
- }
2050
- await testLocal(name, localDs);
2051
- }
2052
- /**
2053
- * Test datasource locally using direct connection
2054
- */
2055
- async function testLocal(name, ds) {
2056
- console.log(pc.dim(`Testing ${name} locally...`));
2057
- console.log();
2058
- const { resolved, missing } = resolveEnvVarsInCredentials(ds.credentials);
2059
- if (missing.length > 0) {
2060
- console.log(pc.red(`Missing environment variables: ${missing.join(", ")}`));
2061
- console.log(pc.dim("Set these env vars or update .bon/datasources.yaml with actual values."));
2062
- process.exit(1);
2063
- }
2064
- const result = await testConnection({
2065
- type: ds.type,
2066
- config: ds.config,
2067
- credentials: resolved
2068
- });
2069
- if (result.success) {
2070
- console.log(pc.green(`✓ ${result.message}`));
2071
- if (result.latencyMs) console.log(pc.dim(` Latency: ${result.latencyMs}ms`));
2072
- } else {
2073
- console.log(pc.red(`✗ ${result.message}`));
2074
- if (result.error) console.log(pc.dim(` ${result.error}`));
2075
- process.exit(1);
2076
- }
2077
- }
2078
- /**
2079
- * Test datasource via remote API (requires login)
2080
- */
2081
- async function testRemote(name, notFoundLocally) {
1635
+ async function datasourceTestCommand(name) {
2082
1636
  if (!loadCredentials()) {
2083
- if (notFoundLocally) {
2084
- console.log(pc.red(`Datasource "${name}" not found locally.`));
2085
- console.log(pc.dim("Run `bon datasource add` to create it, or `bon login` to test remote datasources."));
2086
- } else console.log(pc.red("Not logged in. Run `bon login` to test remote datasources."));
1637
+ console.log(pc.red("Not logged in. Run `bon login` to test datasources."));
2087
1638
  process.exit(1);
2088
1639
  }
2089
1640
  console.log(pc.dim(`Testing ${name} via remote API...`));
@@ -2228,58 +1779,16 @@ async function pushDatasource(name, options = {}) {
2228
1779
  }
2229
1780
  }
2230
1781
 
2231
- //#endregion
2232
- //#region src/commands/preview.ts
2233
- async function previewCommand(datasourceName, sql, options) {
2234
- const limit = options.limit ? parseInt(options.limit, 10) : 1e3;
2235
- const format = options.format ?? "toon";
2236
- const ds = getLocalDatasource(datasourceName);
2237
- if (!ds) {
2238
- console.error(pc.red(`Datasource "${datasourceName}" not found in .bon/datasources.yaml`));
2239
- console.log(pc.dim("Run `bon datasource add` to create it."));
2240
- process.exit(1);
2241
- }
2242
- const { resolved, missing } = resolveEnvVarsInCredentials(ds.credentials);
2243
- if (missing.length > 0) {
2244
- console.error(pc.red(`Missing environment variables: ${missing.join(", ")}`));
2245
- console.log(pc.dim("Set these env vars or update .bon/datasources.yaml with actual values."));
2246
- process.exit(1);
2247
- }
2248
- const result = await executeQuery({
2249
- type: ds.type,
2250
- config: ds.config,
2251
- credentials: resolved
2252
- }, sql, {
2253
- limit,
2254
- schema: options.schema,
2255
- database: options.database
2256
- });
2257
- if (result.error) {
2258
- console.error(pc.red(result.error));
2259
- process.exit(1);
2260
- }
2261
- if (result.rowCount === 0) {
2262
- console.log("No rows returned.");
2263
- return;
2264
- }
2265
- if (format === "json") console.log(JSON.stringify(result, null, 2));
2266
- else {
2267
- const toon = encode({ results: result.rows });
2268
- console.log(toon);
2269
- }
2270
- if (result.truncated) console.log(pc.dim(`(truncated to ${result.rowCount} rows)`));
2271
- }
2272
-
2273
1782
  //#endregion
2274
1783
  //#region src/commands/validate.ts
2275
- async function validateCommand(options = {}) {
1784
+ async function validateCommand() {
2276
1785
  const cwd = process.cwd();
2277
1786
  const paths = getProjectPaths(cwd);
2278
1787
  if (!fs.existsSync(paths.config)) {
2279
1788
  console.log(pc.red("No bon.yaml found. Are you in a Bonnard project?"));
2280
1789
  process.exit(1);
2281
1790
  }
2282
- const { validate } = await import("./validate-C31hmPk8.mjs");
1791
+ const { validate } = await import("./validate-DEh1XQnH.mjs");
2283
1792
  const result = await validate(cwd);
2284
1793
  if (result.cubes.length === 0 && result.views.length === 0 && result.valid) {
2285
1794
  console.log(pc.yellow(`No cube or view files found in ${BONNARD_DIR}/cubes/ or ${BONNARD_DIR}/views/.`));
@@ -2314,62 +1823,6 @@ async function validateCommand(options = {}) {
2314
1823
  console.log(pc.dim(" This can cause issues when multiple warehouses are configured."));
2315
1824
  console.log(pc.dim(` ${result.cubesMissingDataSource.join(", ")}`));
2316
1825
  }
2317
- if (options.testConnection) {
2318
- console.log();
2319
- await testReferencedConnections(cwd);
2320
- }
2321
- }
2322
- /**
2323
- * Test connections for datasources referenced by cubes and views
2324
- * Lenient: warns but doesn't fail validation
2325
- */
2326
- async function testReferencedConnections(cwd) {
2327
- const { extractDatasourcesFromCubes } = await import("./cubes-De1_2_YJ.mjs");
2328
- const { loadLocalDatasources, resolveEnvVarsInCredentials } = await Promise.resolve().then(() => local_exports);
2329
- const { testConnection } = await Promise.resolve().then(() => connection_exports);
2330
- const references = extractDatasourcesFromCubes(cwd);
2331
- if (references.length === 0) {
2332
- console.log(pc.dim("No datasource references found in cubes."));
2333
- return;
2334
- }
2335
- console.log(pc.bold("Testing connections..."));
2336
- console.log();
2337
- const localDatasources = loadLocalDatasources(cwd);
2338
- let warnings = 0;
2339
- for (const ref of references) {
2340
- const ds = localDatasources.find((d) => d.name === ref.name);
2341
- if (!ds) {
2342
- console.log(pc.yellow(`⚠ ${ref.name}: not found in .bon/datasources.yaml`));
2343
- console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2344
- warnings++;
2345
- continue;
2346
- }
2347
- const { resolved, missing } = resolveEnvVarsInCredentials(ds.credentials);
2348
- if (missing.length > 0) {
2349
- console.log(pc.yellow(`⚠ ${ref.name}: missing env vars: ${missing.join(", ")}`));
2350
- console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2351
- warnings++;
2352
- continue;
2353
- }
2354
- const result = await testConnection({
2355
- type: ds.type,
2356
- config: ds.config,
2357
- credentials: resolved
2358
- });
2359
- if (result.success) {
2360
- const latency = result.latencyMs ? pc.dim(` (${result.latencyMs}ms)`) : "";
2361
- console.log(pc.green(`✓ ${ref.name}${latency}`));
2362
- } else {
2363
- console.log(pc.yellow(`⚠ ${ref.name}: ${result.error || result.message}`));
2364
- console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2365
- warnings++;
2366
- }
2367
- }
2368
- if (warnings > 0) {
2369
- console.log();
2370
- console.log(pc.yellow(`${warnings} connection warning(s)`));
2371
- console.log(pc.dim("Connection issues won't block file validation, but will fail at deploy."));
2372
- }
2373
1826
  }
2374
1827
 
2375
1828
  //#endregion
@@ -2401,7 +1854,7 @@ async function deployCommand(options = {}) {
2401
1854
  process.exit(1);
2402
1855
  }
2403
1856
  console.log(pc.dim("Validating cubes and views..."));
2404
- const { validate } = await import("./validate-C31hmPk8.mjs");
1857
+ const { validate } = await import("./validate-DEh1XQnH.mjs");
2405
1858
  const result = await validate(cwd);
2406
1859
  if (!result.valid) {
2407
1860
  console.log(pc.red("Validation failed:\n"));
@@ -2470,51 +1923,29 @@ async function deployCommand(options = {}) {
2470
1923
  * Returns true if any connection failed (strict mode)
2471
1924
  */
2472
1925
  async function testAndSyncDatasources(cwd, options = {}) {
2473
- const { extractDatasourcesFromCubes } = await import("./cubes-De1_2_YJ.mjs");
2474
- const { loadLocalDatasources, resolveEnvVarsInCredentials } = await Promise.resolve().then(() => local_exports);
2475
- const { testConnection } = await Promise.resolve().then(() => connection_exports);
1926
+ const { extractDatasourcesFromCubes } = await import("./cubes-Bf0IPYd7.mjs");
1927
+ const { loadLocalDatasources } = await Promise.resolve().then(() => local_exports);
2476
1928
  const { pushDatasource } = await Promise.resolve().then(() => push_exports);
2477
1929
  const references = extractDatasourcesFromCubes(cwd);
2478
1930
  if (references.length === 0) return false;
2479
1931
  console.log();
2480
- console.log(pc.dim("Testing datasource connections..."));
1932
+ console.log(pc.dim("Checking datasources..."));
2481
1933
  const localDatasources = loadLocalDatasources(cwd);
2482
1934
  let failed = false;
2483
- const validatedDatasources = [];
1935
+ const foundDatasources = [];
2484
1936
  for (const ref of references) {
2485
- const ds = localDatasources.find((d) => d.name === ref.name);
2486
- if (!ds) {
1937
+ if (!localDatasources.find((d) => d.name === ref.name)) {
2487
1938
  console.log(pc.red(`✗ ${ref.name}: not found in .bon/datasources.yaml`));
2488
1939
  console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2489
1940
  console.log(pc.dim(` Run: bon datasource add --from-dbt`));
2490
1941
  failed = true;
2491
1942
  continue;
2492
1943
  }
2493
- const { resolved, missing } = resolveEnvVarsInCredentials(ds.credentials);
2494
- if (missing.length > 0) {
2495
- console.log(pc.red(`✗ ${ref.name}: missing env vars: ${missing.join(", ")}`));
2496
- console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2497
- failed = true;
2498
- continue;
2499
- }
2500
- const result = await testConnection({
2501
- type: ds.type,
2502
- config: ds.config,
2503
- credentials: resolved
2504
- });
2505
- if (result.success) {
2506
- const latency = result.latencyMs ? pc.dim(` (${result.latencyMs}ms)`) : "";
2507
- console.log(pc.green(`✓ ${ref.name}${latency}`));
2508
- validatedDatasources.push(ref.name);
2509
- } else {
2510
- console.log(pc.red(`✗ ${ref.name}: ${result.error || result.message}`));
2511
- console.log(pc.dim(` Used by: ${ref.cubes.join(", ")}`));
2512
- failed = true;
2513
- }
1944
+ foundDatasources.push(ref.name);
2514
1945
  }
2515
- console.log();
2516
1946
  if (failed) {
2517
- console.log(pc.red("Connection tests failed. Fix datasource issues before deploying."));
1947
+ console.log();
1948
+ console.log(pc.red("Missing datasources. Fix issues before deploying."));
2518
1949
  return true;
2519
1950
  }
2520
1951
  console.log(pc.dim("Checking remote datasources..."));
@@ -2526,22 +1957,17 @@ async function testAndSyncDatasources(cwd, options = {}) {
2526
1957
  return true;
2527
1958
  }
2528
1959
  const remoteNames = new Set(remoteDatasources.map((ds) => ds.name));
2529
- const missingRemote = validatedDatasources.filter((name) => !remoteNames.has(name));
2530
- if (missingRemote.length === 0) {
2531
- console.log(pc.green("✓ All datasources exist on remote"));
1960
+ const missingRemote = foundDatasources.filter((name) => !remoteNames.has(name));
1961
+ if (missingRemote.length > 0) {
2532
1962
  console.log();
2533
- return false;
2534
- }
2535
- console.log();
2536
- console.log(pc.yellow(`⚠ Missing remote datasource${missingRemote.length > 1 ? "s" : ""}: ${missingRemote.join(", ")}`));
2537
- console.log();
2538
- if (options.ci) {
2539
- console.log(pc.red("Deploy aborted (--ci mode)."));
2540
- console.log(pc.dim(`Run: bon datasource push <name>`));
2541
- return true;
2542
- }
2543
- if (options.pushDatasources) {
2544
- for (const name of missingRemote) {
1963
+ console.log(pc.yellow(`⚠ Missing remote datasource${missingRemote.length > 1 ? "s" : ""}: ${missingRemote.join(", ")}`));
1964
+ console.log();
1965
+ if (options.ci) {
1966
+ console.log(pc.red("Deploy aborted (--ci mode)."));
1967
+ console.log(pc.dim(`Run: bon datasource push <name>`));
1968
+ return true;
1969
+ }
1970
+ if (options.pushDatasources) for (const name of missingRemote) {
2545
1971
  console.log(pc.dim(`Pushing "${name}"...`));
2546
1972
  if (await pushDatasource(name, { silent: true })) console.log(pc.green(`✓ Pushed "${name}"`));
2547
1973
  else {
@@ -2549,25 +1975,46 @@ async function testAndSyncDatasources(cwd, options = {}) {
2549
1975
  return true;
2550
1976
  }
2551
1977
  }
2552
- console.log();
2553
- return false;
2554
- }
2555
- if (!await confirm({
2556
- message: `Push ${missingRemote.length > 1 ? "these datasources" : `"${missingRemote[0]}"`} to Bonnard? (credentials will be encrypted)`,
2557
- default: true
2558
- })) {
2559
- console.log(pc.dim("Deploy aborted."));
2560
- return true;
1978
+ else {
1979
+ if (!await confirm({
1980
+ message: `Push ${missingRemote.length > 1 ? "these datasources" : `"${missingRemote[0]}"`} to Bonnard? (credentials will be encrypted)`,
1981
+ default: true
1982
+ })) {
1983
+ console.log(pc.dim("Deploy aborted."));
1984
+ return true;
1985
+ }
1986
+ console.log();
1987
+ for (const name of missingRemote) {
1988
+ console.log(pc.dim(`Pushing "${name}"...`));
1989
+ if (await pushDatasource(name, { silent: true })) console.log(pc.green(`✓ Pushed "${name}"`));
1990
+ else {
1991
+ console.log(pc.red(`✗ Failed to push "${name}"`));
1992
+ return true;
1993
+ }
1994
+ }
1995
+ }
2561
1996
  }
2562
1997
  console.log();
2563
- for (const name of missingRemote) {
2564
- console.log(pc.dim(`Pushing "${name}"...`));
2565
- if (await pushDatasource(name, { silent: true })) console.log(pc.green(`✓ Pushed "${name}"`));
2566
- else {
2567
- console.log(pc.red(`✗ Failed to push "${name}"`));
2568
- return true;
1998
+ console.log(pc.dim("Testing datasource connections..."));
1999
+ for (const name of foundDatasources) try {
2000
+ const result = await post("/api/datasources/test", { name });
2001
+ if (result.success) {
2002
+ const latency = result.details?.latencyMs ? pc.dim(` (${result.details.latencyMs}ms)`) : "";
2003
+ console.log(pc.green(`✓ ${name}${latency}`));
2004
+ } else {
2005
+ console.log(pc.red(`✗ ${name}: ${result.message}`));
2006
+ failed = true;
2569
2007
  }
2008
+ } catch (err) {
2009
+ console.log(pc.red(`✗ ${name}: ${err.message}`));
2010
+ failed = true;
2011
+ }
2012
+ console.log();
2013
+ if (failed) {
2014
+ console.log(pc.red("Connection tests failed. Fix datasource issues before deploying."));
2015
+ return true;
2570
2016
  }
2017
+ console.log(pc.green("✓ All datasources connected"));
2571
2018
  console.log();
2572
2019
  return false;
2573
2020
  }
@@ -3082,6 +2529,1397 @@ async function cubeQueryCommand(queryInput, options = {}) {
3082
2529
  }
3083
2530
  }
3084
2531
 
2532
+ //#endregion
2533
+ //#region src/lib/metabase/config.ts
2534
+ /**
2535
+ * Metabase config storage (.bon/metabase.yaml)
2536
+ *
2537
+ * Stores API key and URL for Metabase connectivity.
2538
+ * File has 0o600 permissions since it contains the API key.
2539
+ */
2540
+ const BON_DIR = ".bon";
2541
+ const CONFIG_FILE = "metabase.yaml";
2542
+ function getConfigPath(cwd = process.cwd()) {
2543
+ return path.join(cwd, BON_DIR, CONFIG_FILE);
2544
+ }
2545
+ function loadMetabaseConfig(cwd = process.cwd()) {
2546
+ const filePath = getConfigPath(cwd);
2547
+ if (!fs.existsSync(filePath)) return null;
2548
+ try {
2549
+ const content = fs.readFileSync(filePath, "utf-8");
2550
+ return YAML.parse(content)?.metabase ?? null;
2551
+ } catch {
2552
+ return null;
2553
+ }
2554
+ }
2555
+ function saveMetabaseConfig(config, cwd = process.cwd()) {
2556
+ ensureBonDir(cwd);
2557
+ const filePath = getConfigPath(cwd);
2558
+ const file = { metabase: config };
2559
+ const content = `# Bonnard Metabase configuration
2560
+ # This file contains an API key - add .bon/ to .gitignore
2561
+
2562
+ ` + YAML.stringify(file, { indent: 2 });
2563
+ fs.writeFileSync(filePath, content, { mode: 384 });
2564
+ }
2565
+
2566
+ //#endregion
2567
+ //#region src/lib/metabase/client.ts
2568
+ var MetabaseApiError = class extends Error {
2569
+ constructor(status, endpoint, message) {
2570
+ super(message);
2571
+ this.status = status;
2572
+ this.endpoint = endpoint;
2573
+ this.name = "MetabaseApiError";
2574
+ }
2575
+ };
2576
+ function createMetabaseClient(config) {
2577
+ const baseUrl = config.url.replace(/\/+$/, "");
2578
+ async function metabaseFetch(endpoint, options = {}) {
2579
+ const url = `${baseUrl}/api${endpoint}`;
2580
+ const res = await fetch(url, {
2581
+ ...options,
2582
+ headers: {
2583
+ "X-API-KEY": config.apiKey,
2584
+ "Content-Type": "application/json",
2585
+ ...options.headers
2586
+ }
2587
+ });
2588
+ if (!res.ok) {
2589
+ let message = `HTTP ${res.status}`;
2590
+ try {
2591
+ const body = await res.text();
2592
+ if (body) message = body;
2593
+ } catch {}
2594
+ throw new MetabaseApiError(res.status, endpoint, message);
2595
+ }
2596
+ return res.json();
2597
+ }
2598
+ return {
2599
+ async getCurrentUser() {
2600
+ return metabaseFetch("/user/current");
2601
+ },
2602
+ async getDatabases() {
2603
+ return (await metabaseFetch("/database")).data;
2604
+ },
2605
+ async getCollections() {
2606
+ return metabaseFetch("/collection");
2607
+ },
2608
+ async getCollectionTree() {
2609
+ return metabaseFetch("/collection/tree");
2610
+ },
2611
+ async getCards() {
2612
+ return metabaseFetch("/card");
2613
+ },
2614
+ async getCard(id) {
2615
+ return metabaseFetch(`/card/${id}`);
2616
+ },
2617
+ async getDashboards() {
2618
+ return metabaseFetch("/dashboard");
2619
+ },
2620
+ async getDashboard(id) {
2621
+ return metabaseFetch(`/dashboard/${id}`);
2622
+ },
2623
+ async convertToNativeSQL(datasetQuery) {
2624
+ return (await metabaseFetch("/dataset/native", {
2625
+ method: "POST",
2626
+ body: JSON.stringify(datasetQuery)
2627
+ })).query;
2628
+ },
2629
+ async getDatabaseMetadata(id) {
2630
+ return metabaseFetch(`/database/${id}/metadata`);
2631
+ },
2632
+ async getPermissionGroups() {
2633
+ return metabaseFetch("/permissions/group");
2634
+ },
2635
+ async getPermissionsGraph() {
2636
+ return metabaseFetch("/permissions/graph");
2637
+ },
2638
+ async getCollectionItems(id) {
2639
+ return (await metabaseFetch(`/collection/${id}/items?models=card&models=dataset&models=metric&models=dashboard`)).data;
2640
+ },
2641
+ async getPopularItems() {
2642
+ return metabaseFetch("/activity/popular_items");
2643
+ }
2644
+ };
2645
+ }
2646
+
2647
+ //#endregion
2648
+ //#region src/commands/metabase/connect.ts
2649
+ async function prompts() {
2650
+ return import("@inquirer/prompts");
2651
+ }
2652
+ async function metabaseConnectCommand(options) {
2653
+ const nonInteractive = !!(options.url && options.apiKey);
2654
+ if (loadMetabaseConfig()) if (options.force) console.log(pc.dim("Overwriting existing Metabase configuration"));
2655
+ else if (nonInteractive) {
2656
+ console.error(pc.red("Metabase is already configured. Use --force to overwrite."));
2657
+ process.exit(1);
2658
+ } else {
2659
+ const { confirm } = await prompts();
2660
+ if (!await confirm({
2661
+ message: "Metabase is already configured. Overwrite?",
2662
+ default: false
2663
+ })) {
2664
+ console.log(pc.yellow("Cancelled."));
2665
+ return;
2666
+ }
2667
+ }
2668
+ let url = options.url;
2669
+ let apiKey = options.apiKey;
2670
+ if (!nonInteractive) {
2671
+ const { input, password } = await prompts();
2672
+ if (!url) url = await input({
2673
+ message: "Metabase URL (e.g. https://metabase.example.com):",
2674
+ validate: (v) => {
2675
+ try {
2676
+ const u = new URL(v);
2677
+ if (u.protocol !== "https:" && u.protocol !== "http:") return "URL must use http or https";
2678
+ return true;
2679
+ } catch {
2680
+ return "Enter a valid URL";
2681
+ }
2682
+ }
2683
+ });
2684
+ if (!apiKey) apiKey = await password({ message: "API key:" });
2685
+ }
2686
+ if (!url || !apiKey) {
2687
+ console.error(pc.red("Both --url and --api-key are required in non-interactive mode."));
2688
+ process.exit(1);
2689
+ }
2690
+ try {
2691
+ new URL(url);
2692
+ } catch {
2693
+ console.error(pc.red(`Invalid URL: ${url}`));
2694
+ process.exit(1);
2695
+ }
2696
+ url = url.replace(/\/+$/, "");
2697
+ console.log();
2698
+ console.log(pc.dim("Testing connection..."));
2699
+ const client = createMetabaseClient({
2700
+ url,
2701
+ apiKey
2702
+ });
2703
+ try {
2704
+ const user = await client.getCurrentUser();
2705
+ console.log(pc.green("✓ Connected to Metabase"));
2706
+ console.log();
2707
+ console.log(` URL: ${url}`);
2708
+ console.log(` User: ${user.first_name} ${user.last_name} (${user.email})`);
2709
+ console.log(` Admin: ${user.is_superuser ? "Yes" : "No"}`);
2710
+ console.log();
2711
+ saveMetabaseConfig({
2712
+ url,
2713
+ apiKey
2714
+ });
2715
+ console.log(pc.green("✓ Configuration saved to .bon/metabase.yaml"));
2716
+ console.log();
2717
+ console.log(pc.dim("Explore your Metabase content: bon metabase explore"));
2718
+ } catch (err) {
2719
+ if (err instanceof MetabaseApiError) if (err.status === 401 || err.status === 403) {
2720
+ console.error(pc.red("Authentication failed. Check your API key."));
2721
+ console.log();
2722
+ console.log(pc.dim("Generate an API key in Metabase:"));
2723
+ console.log(pc.dim(" Admin > Settings > Authentication > API Keys"));
2724
+ } else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
2725
+ else if (err instanceof TypeError && (err.message.includes("fetch") || err.message.includes("ECONNREFUSED"))) {
2726
+ console.error(pc.red(`Could not connect to ${url}`));
2727
+ console.log(pc.dim("Check the URL and ensure Metabase is running."));
2728
+ } else console.error(pc.red(`Connection failed: ${err.message}`));
2729
+ process.exit(1);
2730
+ }
2731
+ }
2732
+
2733
+ //#endregion
2734
+ //#region src/commands/metabase/explore.ts
2735
+ function requireConfig() {
2736
+ const config = loadMetabaseConfig();
2737
+ if (!config) {
2738
+ console.error(pc.red("Metabase is not configured."));
2739
+ console.log(pc.dim("Run: bon metabase connect"));
2740
+ process.exit(1);
2741
+ }
2742
+ return createMetabaseClient(config);
2743
+ }
2744
+ function getCardType$1(card) {
2745
+ if (card.type === "model" || card.dataset) return "model";
2746
+ if (card.type === "metric") return "metric";
2747
+ return "question";
2748
+ }
2749
+ function padColumn(value, width) {
2750
+ return value.padEnd(width);
2751
+ }
2752
+ const INACTIVE_MONTHS$1 = 3;
2753
+ function isCardActive$1(card) {
2754
+ if (!card.last_used_at) return false;
2755
+ const cutoff = /* @__PURE__ */ new Date();
2756
+ cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS$1);
2757
+ return new Date(card.last_used_at) >= cutoff;
2758
+ }
2759
+ function activityScore$1(card) {
2760
+ const views = card.view_count || 0;
2761
+ return isCardActive$1(card) ? views : Math.round(views * .1);
2762
+ }
2763
+ function formatLastUsed$1(card) {
2764
+ if (!card.last_used_at) return "never";
2765
+ const d = new Date(card.last_used_at);
2766
+ const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
2767
+ const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
2768
+ if (diffDays === 0) return "today";
2769
+ if (diffDays === 1) return "yesterday";
2770
+ if (diffDays < 30) return `${diffDays}d ago`;
2771
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
2772
+ return `${Math.floor(diffDays / 365)}y ago`;
2773
+ }
2774
+ async function showOverview(client) {
2775
+ const [databases, collections, cards, dashboards] = await Promise.all([
2776
+ client.getDatabases(),
2777
+ client.getCollections(),
2778
+ client.getCards(),
2779
+ client.getDashboards()
2780
+ ]);
2781
+ const activeCards = cards.filter((c) => !c.archived);
2782
+ const models = activeCards.filter((c) => getCardType$1(c) === "model");
2783
+ const metrics = activeCards.filter((c) => getCardType$1(c) === "metric");
2784
+ const questions = activeCards.filter((c) => getCardType$1(c) === "question");
2785
+ const activeCollections = collections.filter((c) => !c.archived && c.personal_owner_id === null);
2786
+ const activeDashboards = dashboards.filter((d) => !d.archived);
2787
+ const recentlyActive = activeCards.filter(isCardActive$1).length;
2788
+ const inactive = activeCards.length - recentlyActive;
2789
+ console.log();
2790
+ console.log(pc.bold("Metabase Overview"));
2791
+ console.log();
2792
+ console.log(` Databases: ${databases.length}`);
2793
+ console.log(` Collections: ${activeCollections.length}`);
2794
+ console.log(` Models: ${models.length}`);
2795
+ console.log(` Metrics: ${metrics.length}`);
2796
+ console.log(` Questions: ${questions.length}`);
2797
+ console.log(` Dashboards: ${activeDashboards.length}`);
2798
+ console.log();
2799
+ console.log(` Active (last ${INACTIVE_MONTHS$1}mo): ${pc.green(String(recentlyActive))}`);
2800
+ console.log(` Inactive: ${pc.dim(String(inactive))}`);
2801
+ console.log();
2802
+ console.log(pc.dim("Explore further:"));
2803
+ console.log(pc.dim(" bon metabase explore databases"));
2804
+ console.log(pc.dim(" bon metabase explore collections"));
2805
+ console.log(pc.dim(" bon metabase explore cards"));
2806
+ console.log(pc.dim(" bon metabase explore dashboards"));
2807
+ console.log(pc.dim(" bon metabase explore card <id>"));
2808
+ console.log(pc.dim(" bon metabase explore dashboard <id>"));
2809
+ console.log(pc.dim(" bon metabase explore database <id>"));
2810
+ console.log(pc.dim(" bon metabase explore table <id>"));
2811
+ console.log(pc.dim(" bon metabase explore collection <id>"));
2812
+ }
2813
+ async function showDatabases(client) {
2814
+ const databases = await client.getDatabases();
2815
+ console.log();
2816
+ console.log(pc.bold("Databases"));
2817
+ console.log();
2818
+ if (databases.length === 0) {
2819
+ console.log(pc.dim(" No databases found."));
2820
+ return;
2821
+ }
2822
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("ENGINE", 16))}${pc.dim("SAMPLE")}`);
2823
+ for (const db of databases) console.log(` ${padColumn(String(db.id), 6)}${padColumn(db.name, 30)}${padColumn(db.engine, 16)}${db.is_sample ? "Yes" : ""}`);
2824
+ }
2825
+ function printTree(nodes, indent = 0) {
2826
+ for (const node of nodes) {
2827
+ if (node.personal_owner_id !== null) continue;
2828
+ const prefix = indent === 0 ? " " : " " + " ".repeat(indent);
2829
+ const icon = node.children.length > 0 ? "+" : "-";
2830
+ console.log(`${prefix}${pc.dim(icon)} ${node.name} ${pc.dim(`(${node.id})`)}`);
2831
+ if (node.children.length > 0) printTree(node.children, indent + 1);
2832
+ }
2833
+ }
2834
+ async function showCollections(client) {
2835
+ const tree = await client.getCollectionTree();
2836
+ console.log();
2837
+ console.log(pc.bold("Collections"));
2838
+ console.log();
2839
+ const filtered = tree.filter((n) => n.personal_owner_id === null);
2840
+ if (filtered.length === 0) {
2841
+ console.log(pc.dim(" No collections found."));
2842
+ return;
2843
+ }
2844
+ printTree(filtered);
2845
+ }
2846
+ async function showCards(client) {
2847
+ const active = (await client.getCards()).filter((c) => !c.archived);
2848
+ const models = active.filter((c) => getCardType$1(c) === "model");
2849
+ const metrics = active.filter((c) => getCardType$1(c) === "metric");
2850
+ const questions = active.filter((c) => getCardType$1(c) === "question");
2851
+ const CAP = 50;
2852
+ console.log();
2853
+ const groups = [
2854
+ {
2855
+ label: "Models",
2856
+ items: models
2857
+ },
2858
+ {
2859
+ label: "Metrics",
2860
+ items: metrics
2861
+ },
2862
+ {
2863
+ label: "Questions",
2864
+ items: questions
2865
+ }
2866
+ ];
2867
+ for (const group of groups) {
2868
+ if (group.items.length === 0) continue;
2869
+ const groupActive = group.items.filter(isCardActive$1).length;
2870
+ const groupInactive = group.items.length - groupActive;
2871
+ const sorted = [...group.items].sort((a, b) => activityScore$1(b) - activityScore$1(a));
2872
+ console.log(pc.bold(`${group.label} (${group.items.length})`) + pc.dim(` — ${groupActive} active, ${groupInactive} inactive`));
2873
+ console.log();
2874
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 36))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("VIEWS")}`);
2875
+ const display = sorted.slice(0, CAP);
2876
+ for (const card of display) {
2877
+ const lastUsed = formatLastUsed$1(card);
2878
+ const active = isCardActive$1(card);
2879
+ const name = card.name.slice(0, 34);
2880
+ const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 36)}${padColumn(card.display, 14)}${padColumn(lastUsed, 12)}${card.view_count || 0}`;
2881
+ console.log(active ? line : pc.dim(line));
2882
+ }
2883
+ if (group.items.length > CAP) console.log(pc.dim(` ... and ${group.items.length - CAP} more`));
2884
+ console.log();
2885
+ }
2886
+ if (models.length === 0 && metrics.length === 0 && questions.length === 0) console.log(pc.dim(" No cards found."));
2887
+ console.log(pc.dim("View details: bon metabase explore card <id>"));
2888
+ }
2889
+ async function showCardDetail(client, id) {
2890
+ const card = await client.getCard(id);
2891
+ const cardType = getCardType$1(card);
2892
+ console.log();
2893
+ console.log(pc.bold(card.name));
2894
+ if (card.description) console.log(pc.dim(card.description));
2895
+ console.log();
2896
+ const active = isCardActive$1(card);
2897
+ const lastUsed = formatLastUsed$1(card);
2898
+ const activityLabel = active ? pc.green("active") : pc.dim("inactive");
2899
+ console.log(` Type: ${cardType}`);
2900
+ console.log(` Display: ${card.display}`);
2901
+ console.log(` Database: ${card.database_id}`);
2902
+ console.log(` Views: ${card.view_count || 0}`);
2903
+ console.log(` Last used: ${lastUsed} (${activityLabel})`);
2904
+ console.log();
2905
+ let sql = null;
2906
+ const dq = card.dataset_query;
2907
+ if (dq.type === "native" && dq.native?.query) sql = dq.native.query;
2908
+ else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") sql = dq.stages[0].native;
2909
+ else if (dq.type === "query") try {
2910
+ sql = await client.convertToNativeSQL(dq);
2911
+ } catch (err) {
2912
+ if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
2913
+ }
2914
+ else if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") try {
2915
+ sql = await client.convertToNativeSQL(dq);
2916
+ } catch (err) {
2917
+ if (err instanceof MetabaseApiError) console.log(pc.dim(` Could not convert MBQL to SQL: ${err.message}`));
2918
+ }
2919
+ if (sql) {
2920
+ console.log(pc.bold("SQL"));
2921
+ console.log();
2922
+ for (const line of sql.trim().split("\n")) console.log(` ${line}`);
2923
+ console.log();
2924
+ }
2925
+ if (card.result_metadata && card.result_metadata.length > 0) {
2926
+ console.log(pc.bold("Columns"));
2927
+ console.log();
2928
+ console.log(` ${pc.dim(padColumn("NAME", 30))}${pc.dim(padColumn("DISPLAY NAME", 30))}${pc.dim("TYPE")}`);
2929
+ for (const col of card.result_metadata) console.log(` ${padColumn(col.name, 30)}${padColumn(col.display_name, 30)}${col.base_type}`);
2930
+ }
2931
+ }
2932
+ async function showDashboards(client) {
2933
+ const active = (await client.getDashboards()).filter((d) => !d.archived);
2934
+ console.log();
2935
+ console.log(pc.bold(`Dashboards (${active.length})`));
2936
+ console.log();
2937
+ if (active.length === 0) {
2938
+ console.log(pc.dim(" No dashboards found."));
2939
+ return;
2940
+ }
2941
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
2942
+ for (const d of active) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
2943
+ console.log();
2944
+ console.log(pc.dim("View details: bon metabase explore dashboard <id>"));
2945
+ }
2946
+ async function showDashboardDetail(client, id) {
2947
+ const [dashboard, allCards] = await Promise.all([client.getDashboard(id), client.getCards()]);
2948
+ const cardActivityMap = /* @__PURE__ */ new Map();
2949
+ for (const c of allCards) cardActivityMap.set(c.id, c);
2950
+ console.log();
2951
+ console.log(pc.bold(dashboard.name));
2952
+ if (dashboard.description) console.log(pc.dim(dashboard.description));
2953
+ console.log();
2954
+ if (dashboard.parameters.length > 0) {
2955
+ console.log(pc.bold("Parameters"));
2956
+ console.log();
2957
+ console.log(` ${pc.dim(padColumn("NAME", 25))}${pc.dim(padColumn("TYPE", 20))}${pc.dim("SLUG")}`);
2958
+ for (const p of dashboard.parameters) console.log(` ${padColumn(p.name, 25)}${padColumn(p.type, 20)}${p.slug}`);
2959
+ console.log();
2960
+ }
2961
+ const cardsOnDashboard = dashboard.dashcards.filter((dc) => dc.card?.id != null);
2962
+ if (cardsOnDashboard.length > 0) {
2963
+ const inactiveOnDash = cardsOnDashboard.filter((dc) => {
2964
+ const full = cardActivityMap.get(dc.card.id);
2965
+ return full ? !isCardActive$1(full) : true;
2966
+ });
2967
+ const activeLabel = cardsOnDashboard.length - inactiveOnDash.length;
2968
+ console.log(pc.bold(`Cards (${cardsOnDashboard.length})`) + pc.dim(` — ${activeLabel} active, ${inactiveOnDash.length} inactive`));
2969
+ console.log();
2970
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 31))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("POSITION")}`);
2971
+ for (const dc of cardsOnDashboard) {
2972
+ const card = dc.card;
2973
+ const name = (card.name ?? "(untitled)").slice(0, 29);
2974
+ const pos = `(${dc.col},${dc.row}) ${dc.size_x}x${dc.size_y}`;
2975
+ const full = cardActivityMap.get(card.id);
2976
+ const lastUsed = full ? formatLastUsed$1(full) : "?";
2977
+ const active = full ? isCardActive$1(full) : false;
2978
+ const line = ` ${padColumn(String(card.id), 6)}${padColumn(name, 31)}${padColumn(card.display ?? "", 14)}${padColumn(lastUsed, 12)}${pos}`;
2979
+ console.log(active ? line : pc.dim(line));
2980
+ }
2981
+ } else console.log(pc.dim(" No cards on this dashboard."));
2982
+ }
2983
+ async function showDatabaseDetail(client, id) {
2984
+ const meta = await client.getDatabaseMetadata(id);
2985
+ console.log();
2986
+ console.log(pc.bold(`${meta.name} (${meta.engine})`));
2987
+ console.log();
2988
+ const bySchema = /* @__PURE__ */ new Map();
2989
+ for (const t of meta.tables) {
2990
+ if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
2991
+ if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
2992
+ bySchema.get(t.schema).push(t);
2993
+ }
2994
+ for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
2995
+ console.log(pc.bold(` ${schema}`) + pc.dim(` (${tables.length} tables)`));
2996
+ const sorted = [...tables].sort((a, b) => a.name.localeCompare(b.name));
2997
+ for (const t of sorted) {
2998
+ const fieldCount = t.fields.length;
2999
+ const desc = t.description ? pc.dim(` — ${t.description.slice(0, 50)}`) : "";
3000
+ console.log(` ${padColumn(String(t.id), 8)}${padColumn(t.name, 40)}${fieldCount} fields${desc}`);
3001
+ }
3002
+ console.log();
3003
+ }
3004
+ console.log(pc.dim("View table fields: bon metabase explore table <id>"));
3005
+ }
3006
+ function classifyFieldType(field) {
3007
+ const bt = field.base_type || "";
3008
+ const st = field.semantic_type || "";
3009
+ if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date") return "time";
3010
+ if (st === "type/PK") return "pk";
3011
+ if (st === "type/FK") return "fk";
3012
+ if ([
3013
+ "type/Currency",
3014
+ "type/Percentage",
3015
+ "type/Quantity",
3016
+ "type/Score"
3017
+ ].includes(st)) return "measure";
3018
+ if ([
3019
+ "type/Category",
3020
+ "type/Source",
3021
+ "type/City",
3022
+ "type/Country",
3023
+ "type/State",
3024
+ "type/Name",
3025
+ "type/Email",
3026
+ "type/URL"
3027
+ ].includes(st)) return "dim";
3028
+ if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) return "numeric";
3029
+ if (bt.includes("Text") || bt.includes("String")) return "text";
3030
+ if (bt.includes("Boolean")) return "bool";
3031
+ return "";
3032
+ }
3033
+ async function showTableDetail(client, tableId) {
3034
+ const databases = await client.getDatabases();
3035
+ let foundTable = null;
3036
+ let dbName = "";
3037
+ let meta = null;
3038
+ for (const db of databases) {
3039
+ meta = await client.getDatabaseMetadata(db.id);
3040
+ const table = meta.tables.find((t) => t.id === tableId);
3041
+ if (table) {
3042
+ foundTable = table;
3043
+ dbName = db.name;
3044
+ break;
3045
+ }
3046
+ }
3047
+ if (!foundTable || !meta) {
3048
+ console.error(pc.red(`Table ${tableId} not found.`));
3049
+ process.exit(1);
3050
+ }
3051
+ const fieldIdLookup = /* @__PURE__ */ new Map();
3052
+ for (const t of meta.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
3053
+ table: t.name,
3054
+ field: f.name
3055
+ });
3056
+ console.log();
3057
+ console.log(pc.bold(`${foundTable.name}`) + pc.dim(` (${dbName} / ${foundTable.schema})`));
3058
+ if (foundTable.description) console.log(pc.dim(foundTable.description));
3059
+ console.log();
3060
+ const fields = foundTable.fields.filter((f) => f.visibility_type !== "hidden" && f.visibility_type !== "retired");
3061
+ console.log(` ${pc.dim(padColumn("FIELD", 30))}${pc.dim(padColumn("TYPE", 22))}${pc.dim(padColumn("SEMANTIC", 18))}${pc.dim(padColumn("CLASS", 8))}${pc.dim(padColumn("DISTINCT", 10))}${pc.dim(padColumn("NULL%", 8))}${pc.dim("FK TARGET")}`);
3062
+ for (const f of fields) {
3063
+ const cls = classifyFieldType(f);
3064
+ const distinct = f.fingerprint?.global?.["distinct-count"];
3065
+ const nilPct = f.fingerprint?.global?.["nil%"];
3066
+ const fkTarget = f.fk_target_field_id ? (() => {
3067
+ const target = fieldIdLookup.get(f.fk_target_field_id);
3068
+ return target ? `${target.table}.${target.field}` : `field:${f.fk_target_field_id}`;
3069
+ })() : "";
3070
+ console.log(` ${padColumn(f.name, 30)}${padColumn(f.base_type.replace("type/", ""), 22)}${padColumn(f.semantic_type?.replace("type/", "") || "", 18)}${padColumn(cls, 8)}${padColumn(distinct !== void 0 ? String(distinct) : "", 10)}${padColumn(nilPct !== void 0 ? `${Math.round(nilPct * 100)}%` : "", 8)}${fkTarget}`);
3071
+ }
3072
+ console.log();
3073
+ console.log(pc.dim(`${fields.length} fields (${fields.filter((f) => classifyFieldType(f) === "pk" || classifyFieldType(f) === "fk").length} keys, ${fields.filter((f) => classifyFieldType(f) === "time").length} time, ${fields.filter((f) => classifyFieldType(f) === "measure").length} measures)`));
3074
+ }
3075
+ async function showCollectionDetail(client, id) {
3076
+ const [items, allCards] = await Promise.all([client.getCollectionItems(id), client.getCards()]);
3077
+ const cardMap = /* @__PURE__ */ new Map();
3078
+ for (const c of allCards) cardMap.set(c.id, c);
3079
+ const cardItems = items.filter((i) => i.model === "card" || i.model === "dataset" || i.model === "metric");
3080
+ const dashboardItems = items.filter((i) => i.model === "dashboard");
3081
+ console.log();
3082
+ console.log(pc.bold(`Collection ${id}`));
3083
+ console.log();
3084
+ if (cardItems.length > 0) {
3085
+ const sorted = cardItems.sort((a, b) => {
3086
+ const ca = cardMap.get(a.id);
3087
+ const cb = cardMap.get(b.id);
3088
+ return (cb ? activityScore$1(cb) : 0) - (ca ? activityScore$1(ca) : 0);
3089
+ });
3090
+ const activeCount = sorted.filter((i) => {
3091
+ const c = cardMap.get(i.id);
3092
+ return c ? isCardActive$1(c) : false;
3093
+ }).length;
3094
+ console.log(pc.bold(`Cards (${sorted.length})`) + pc.dim(` — ${activeCount} active, ${sorted.length - activeCount} inactive`));
3095
+ console.log();
3096
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim(padColumn("NAME", 40))}${pc.dim(padColumn("TYPE", 10))}${pc.dim(padColumn("DISPLAY", 14))}${pc.dim(padColumn("LAST USED", 12))}${pc.dim("VIEWS")}`);
3097
+ for (const item of sorted) {
3098
+ const full = cardMap.get(item.id);
3099
+ const lastUsed = full ? formatLastUsed$1(full) : "?";
3100
+ const views = full ? full.view_count || 0 : 0;
3101
+ const active = full ? isCardActive$1(full) : false;
3102
+ const itemType = item.model === "dataset" ? "model" : item.model;
3103
+ const name = item.name.slice(0, 38);
3104
+ const line = ` ${padColumn(String(item.id), 6)}${padColumn(name, 40)}${padColumn(itemType, 10)}${padColumn(item.display || "", 14)}${padColumn(lastUsed, 12)}${views}`;
3105
+ console.log(active ? line : pc.dim(line));
3106
+ }
3107
+ console.log();
3108
+ }
3109
+ if (dashboardItems.length > 0) {
3110
+ console.log(pc.bold(`Dashboards (${dashboardItems.length})`));
3111
+ console.log();
3112
+ console.log(` ${pc.dim(padColumn("ID", 6))}${pc.dim("NAME")}`);
3113
+ for (const d of dashboardItems) console.log(` ${padColumn(String(d.id), 6)}${d.name}`);
3114
+ console.log();
3115
+ }
3116
+ if (cardItems.length === 0 && dashboardItems.length === 0) console.log(pc.dim(" No items in this collection."));
3117
+ console.log(pc.dim("View card SQL: bon metabase explore card <id>"));
3118
+ console.log(pc.dim("View dashboard: bon metabase explore dashboard <id>"));
3119
+ }
3120
+ const RESOURCES = [
3121
+ "databases",
3122
+ "collections",
3123
+ "cards",
3124
+ "dashboards",
3125
+ "card",
3126
+ "dashboard",
3127
+ "database",
3128
+ "table",
3129
+ "collection"
3130
+ ];
3131
+ async function metabaseExploreCommand(resource, id) {
3132
+ const client = requireConfig();
3133
+ try {
3134
+ if (!resource) {
3135
+ await showOverview(client);
3136
+ return;
3137
+ }
3138
+ if (!RESOURCES.includes(resource)) {
3139
+ console.error(pc.red(`Unknown resource: ${resource}`));
3140
+ console.log(pc.dim(`Valid resources: ${RESOURCES.join(", ")}`));
3141
+ process.exit(1);
3142
+ }
3143
+ switch (resource) {
3144
+ case "databases":
3145
+ await showDatabases(client);
3146
+ break;
3147
+ case "collections":
3148
+ await showCollections(client);
3149
+ break;
3150
+ case "cards":
3151
+ await showCards(client);
3152
+ break;
3153
+ case "dashboards":
3154
+ await showDashboards(client);
3155
+ break;
3156
+ case "card": {
3157
+ if (!id) {
3158
+ console.error(pc.red("Card ID required: bon metabase explore card <id>"));
3159
+ process.exit(1);
3160
+ }
3161
+ const cardId = parseInt(id, 10);
3162
+ if (isNaN(cardId)) {
3163
+ console.error(pc.red(`Invalid card ID: ${id}`));
3164
+ process.exit(1);
3165
+ }
3166
+ await showCardDetail(client, cardId);
3167
+ break;
3168
+ }
3169
+ case "dashboard": {
3170
+ if (!id) {
3171
+ console.error(pc.red("Dashboard ID required: bon metabase explore dashboard <id>"));
3172
+ process.exit(1);
3173
+ }
3174
+ const dashId = parseInt(id, 10);
3175
+ if (isNaN(dashId)) {
3176
+ console.error(pc.red(`Invalid dashboard ID: ${id}`));
3177
+ process.exit(1);
3178
+ }
3179
+ await showDashboardDetail(client, dashId);
3180
+ break;
3181
+ }
3182
+ case "database": {
3183
+ if (!id) {
3184
+ console.error(pc.red("Database ID required: bon metabase explore database <id>"));
3185
+ process.exit(1);
3186
+ }
3187
+ const dbId = parseInt(id, 10);
3188
+ if (isNaN(dbId)) {
3189
+ console.error(pc.red(`Invalid database ID: ${id}`));
3190
+ process.exit(1);
3191
+ }
3192
+ await showDatabaseDetail(client, dbId);
3193
+ break;
3194
+ }
3195
+ case "table": {
3196
+ if (!id) {
3197
+ console.error(pc.red("Table ID required: bon metabase explore table <id>"));
3198
+ process.exit(1);
3199
+ }
3200
+ const tableId = parseInt(id, 10);
3201
+ if (isNaN(tableId)) {
3202
+ console.error(pc.red(`Invalid table ID: ${id}`));
3203
+ process.exit(1);
3204
+ }
3205
+ await showTableDetail(client, tableId);
3206
+ break;
3207
+ }
3208
+ case "collection": {
3209
+ if (!id) {
3210
+ console.error(pc.red("Collection ID required: bon metabase explore collection <id>"));
3211
+ process.exit(1);
3212
+ }
3213
+ const colId = parseInt(id, 10);
3214
+ if (isNaN(colId)) {
3215
+ console.error(pc.red(`Invalid collection ID: ${id}`));
3216
+ process.exit(1);
3217
+ }
3218
+ await showCollectionDetail(client, colId);
3219
+ break;
3220
+ }
3221
+ }
3222
+ } catch (err) {
3223
+ if (err instanceof MetabaseApiError) {
3224
+ if (err.status === 401 || err.status === 403) console.error(pc.red("Authentication failed. Re-run: bon metabase connect"));
3225
+ else if (err.status === 404) console.error(pc.red(`Not found: ${err.endpoint}`));
3226
+ else console.error(pc.red(`Metabase API error (${err.status}): ${err.message}`));
3227
+ process.exit(1);
3228
+ }
3229
+ throw err;
3230
+ }
3231
+ }
3232
+
3233
+ //#endregion
3234
+ //#region src/commands/metabase/analyze.ts
3235
+ const OUTPUT_FILE = ".bon/metabase-analysis.md";
3236
+ const TOP_CARDS_LIMIT = 50;
3237
+ const TOP_DASHBOARDS_LIMIT = 10;
3238
+ function classifyField(field) {
3239
+ const bt = field.base_type || "";
3240
+ const st = field.semantic_type || "";
3241
+ if (bt.includes("Date") || bt.includes("Time") || st.includes("Timestamp") || st === "type/DateTime" || st === "type/Date" || st === "type/Time") return "time";
3242
+ if ([
3243
+ "type/Currency",
3244
+ "type/Percentage",
3245
+ "type/Quantity",
3246
+ "type/Score"
3247
+ ].includes(st)) return "measure";
3248
+ if (st === "type/PK" || st === "type/FK") return "dimension";
3249
+ if ([
3250
+ "type/Category",
3251
+ "type/Source",
3252
+ "type/City",
3253
+ "type/Country",
3254
+ "type/State",
3255
+ "type/Name",
3256
+ "type/Title",
3257
+ "type/Email",
3258
+ "type/URL",
3259
+ "type/ZipCode"
3260
+ ].includes(st)) return "dimension";
3261
+ if (bt.includes("Integer") || bt.includes("Float") || bt.includes("Decimal") || bt.includes("Number") || bt.includes("BigInteger")) {
3262
+ const distinct = field.fingerprint?.global?.["distinct-count"];
3263
+ if (distinct !== void 0 && distinct < 20) return "dimension";
3264
+ return "measure";
3265
+ }
3266
+ if (bt.includes("Text") || bt.includes("String")) return "dimension";
3267
+ if (bt.includes("Boolean")) return "dimension";
3268
+ return "other";
3269
+ }
3270
+ function extractSQL(card) {
3271
+ const dq = card.dataset_query;
3272
+ if (dq.type === "native" && dq.native?.query) return dq.native.query;
3273
+ if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/native" && typeof dq.stages[0].native === "string") return dq.stages[0].native;
3274
+ return null;
3275
+ }
3276
+ /**
3277
+ * Returns true if the card uses MBQL (query builder) rather than native SQL.
3278
+ */
3279
+ function isMbqlCard(card) {
3280
+ const dq = card.dataset_query;
3281
+ if (dq.type === "query") return true;
3282
+ if (dq.stages?.[0]?.["lib/type"] === "mbql.stage/mbql") return true;
3283
+ return false;
3284
+ }
3285
+ /**
3286
+ * Extracts table references from SQL (FROM and JOIN clauses).
3287
+ * Returns a map of table name -> reference count.
3288
+ * Excludes CTE names so only real tables are counted.
3289
+ */
3290
+ function extractTableReferences(sql) {
3291
+ const refs = /* @__PURE__ */ new Map();
3292
+ const cteNames = /* @__PURE__ */ new Set();
3293
+ const cteMatch = sql.match(/\bWITH\b[\s\S]*?(?=\bSELECT\b(?![\s\S]*\bWITH\b))/gi);
3294
+ if (cteMatch) {
3295
+ const cteDefPattern = /\b(\w+)\s+AS\s*\(/gi;
3296
+ for (const block of cteMatch) {
3297
+ let m;
3298
+ while ((m = cteDefPattern.exec(block)) !== null) cteNames.add(m[1].toLowerCase());
3299
+ }
3300
+ }
3301
+ const tableRefPattern = /(?:FROM|JOIN)\s+("?\w+"?\s*\.\s*"?\w+"?|"?\w+"?)(?:\s+(?:AS\s+)?\w+)?/gi;
3302
+ let match;
3303
+ while ((match = tableRefPattern.exec(sql)) !== null) {
3304
+ let tableName = match[1].replace(/"/g, "").replace(/\s/g, "").toLowerCase();
3305
+ const baseName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
3306
+ if (cteNames.has(baseName)) continue;
3307
+ if ([
3308
+ "select",
3309
+ "where",
3310
+ "group",
3311
+ "order",
3312
+ "having",
3313
+ "limit",
3314
+ "union",
3315
+ "values",
3316
+ "set",
3317
+ "update",
3318
+ "insert",
3319
+ "delete",
3320
+ "into",
3321
+ "table",
3322
+ "create",
3323
+ "alter",
3324
+ "drop",
3325
+ "index",
3326
+ "view",
3327
+ "as",
3328
+ "on",
3329
+ "and",
3330
+ "or",
3331
+ "not",
3332
+ "in",
3333
+ "is",
3334
+ "null",
3335
+ "true",
3336
+ "false",
3337
+ "case",
3338
+ "when",
3339
+ "then",
3340
+ "else",
3341
+ "end",
3342
+ "with",
3343
+ "the",
3344
+ "lateral",
3345
+ "generate_series",
3346
+ "unnest"
3347
+ ].includes(baseName)) continue;
3348
+ if (!refs.has(tableName)) refs.set(tableName, 0);
3349
+ refs.set(tableName, refs.get(tableName) + 1);
3350
+ }
3351
+ return refs;
3352
+ }
3353
+ /**
3354
+ * Classifies a card's SQL pattern:
3355
+ * - analytical: GROUP BY + aggregation (core semantic layer candidates)
3356
+ * - lookup: Single-row lookup (WHERE email={{x}}, no GROUP BY) — CRM/operational
3357
+ * - display: UNION-based formatting/layout without aggregation
3358
+ * - unknown: non-native or unclassifiable
3359
+ */
3360
+ function classifyCardPattern(card, sqlOverride) {
3361
+ const sql = sqlOverride ?? extractSQL(card);
3362
+ if (!sql) return "unknown";
3363
+ const upper = sql.toUpperCase();
3364
+ const hasGroupBy = /\bGROUP\s+BY\b/.test(upper);
3365
+ const hasAgg = /\b(COUNT|SUM|AVG|MIN|MAX|PERCENTILE)\s*\(/.test(upper);
3366
+ const hasUnion = /\bUNION\b/.test(upper);
3367
+ const hasTemplateVar = /\{\{[^}]+\}\}/.test(sql);
3368
+ const hasLookupVar = /\{\{(email|primary_mail|user|customer|name|phone|id)\}\}/i.test(sql);
3369
+ if (hasGroupBy && hasAgg) return "analytical";
3370
+ if (hasLookupVar && !hasGroupBy && !hasAgg) return "lookup";
3371
+ if (hasUnion && !hasGroupBy && !hasAgg) return "display";
3372
+ if (hasAgg && !hasGroupBy) return "analytical";
3373
+ if (hasTemplateVar && hasAgg) return "analytical";
3374
+ return "unknown";
3375
+ }
3376
+ /**
3377
+ * Extracts Metabase template variable names ({{var}}) from SQL.
3378
+ */
3379
+ function extractTemplateVars(sql) {
3380
+ const vars = /* @__PURE__ */ new Set();
3381
+ const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
3382
+ let m;
3383
+ while ((m = pattern.exec(sql)) !== null) vars.add(m[1].toLowerCase());
3384
+ return vars;
3385
+ }
3386
+ const INACTIVE_MONTHS = 3;
3387
+ function isCardActive(card) {
3388
+ if (!card.last_used_at) return false;
3389
+ const cutoff = /* @__PURE__ */ new Date();
3390
+ cutoff.setMonth(cutoff.getMonth() - INACTIVE_MONTHS);
3391
+ return new Date(card.last_used_at) >= cutoff;
3392
+ }
3393
+ /**
3394
+ * Score that weights view_count by recency.
3395
+ * Active cards (used in last 3 months) keep full view_count.
3396
+ * Inactive cards are penalized by 90%.
3397
+ */
3398
+ function activityScore(card) {
3399
+ const views = card.view_count || 0;
3400
+ return isCardActive(card) ? views : Math.round(views * .1);
3401
+ }
3402
+ function formatLastUsed(card) {
3403
+ if (!card.last_used_at) return "never";
3404
+ const d = new Date(card.last_used_at);
3405
+ const diffMs = (/* @__PURE__ */ new Date()).getTime() - d.getTime();
3406
+ const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
3407
+ if (diffDays === 0) return "today";
3408
+ if (diffDays === 1) return "yesterday";
3409
+ if (diffDays < 30) return `${diffDays}d ago`;
3410
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
3411
+ return `${Math.floor(diffDays / 365)}y ago`;
3412
+ }
3413
+ function getCardType(card) {
3414
+ if (card.type === "model" || card.dataset) return "model";
3415
+ if (card.type === "metric") return "metric";
3416
+ return "question";
3417
+ }
3418
+ function extractSchemaAccess(graph, groups, dbId) {
3419
+ const groupMap = new Map(groups.map((g) => [String(g.id), g.name]));
3420
+ const schemaAccessMap = /* @__PURE__ */ new Map();
3421
+ for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
3422
+ const groupName = groupMap.get(groupId) || `Group ${groupId}`;
3423
+ const perms = dbPerms[String(dbId)];
3424
+ if (!perms) continue;
3425
+ const createQueries = perms["create-queries"];
3426
+ if (!createQueries) continue;
3427
+ if (typeof createQueries === "string") {
3428
+ if (createQueries !== "no") {
3429
+ if (!schemaAccessMap.has("*")) schemaAccessMap.set("*", []);
3430
+ schemaAccessMap.get("*").push(groupName);
3431
+ }
3432
+ continue;
3433
+ }
3434
+ for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") {
3435
+ if (!schemaAccessMap.has(schema)) schemaAccessMap.set(schema, []);
3436
+ schemaAccessMap.get(schema).push(groupName);
3437
+ }
3438
+ }
3439
+ return Array.from(schemaAccessMap.entries()).map(([schema, grps]) => ({
3440
+ schema,
3441
+ groups: grps
3442
+ })).sort((a, b) => a.schema.localeCompare(b.schema));
3443
+ }
3444
+ /**
3445
+ * Returns the set of schemas accessible to non-admin groups for a given database.
3446
+ * Returns null if a non-admin group has wildcard access (all schemas are user-facing),
3447
+ * or if no explicit schema permissions are found.
3448
+ */
3449
+ function getUserFacingSchemas(graph, groups, dbId) {
3450
+ const adminGroupIds = new Set(groups.filter((g) => g.name === "Administrators").map((g) => String(g.id)));
3451
+ const schemas = /* @__PURE__ */ new Set();
3452
+ for (const [groupId, dbPerms] of Object.entries(graph.groups)) {
3453
+ if (adminGroupIds.has(groupId)) continue;
3454
+ const perms = dbPerms[String(dbId)];
3455
+ if (!perms) continue;
3456
+ const createQueries = perms["create-queries"];
3457
+ if (!createQueries) continue;
3458
+ if (typeof createQueries === "string") {
3459
+ if (createQueries !== "no") return null;
3460
+ continue;
3461
+ }
3462
+ for (const [schema, perm] of Object.entries(createQueries)) if (perm !== "no") schemas.add(schema);
3463
+ }
3464
+ return schemas.size > 0 ? schemas : null;
3465
+ }
3466
+ function flattenCollections(nodes, parentPath = "") {
3467
+ const result = [];
3468
+ for (const node of nodes) {
3469
+ if (node.personal_owner_id !== null) continue;
3470
+ const p = parentPath ? `${parentPath}/${node.name}` : node.name;
3471
+ result.push({
3472
+ id: node.id,
3473
+ path: p
3474
+ });
3475
+ if (node.children.length > 0) result.push(...flattenCollections(node.children, p));
3476
+ }
3477
+ return result;
3478
+ }
3479
+ const MAX_TREE_DEPTH = 2;
3480
+ function renderTree(nodes, indent = 0) {
3481
+ let out = "";
3482
+ for (const node of nodes) {
3483
+ if (node.personal_owner_id !== null) continue;
3484
+ const prefix = " ".repeat(indent);
3485
+ const icon = node.children.length > 0 ? "+" : "-";
3486
+ out += `${prefix}${icon} ${node.name} (${node.id})\n`;
3487
+ if (node.children.length > 0 && indent < MAX_TREE_DEPTH - 1) out += renderTree(node.children, indent + 1);
3488
+ else if (node.children.length > 0) {
3489
+ const childCount = node.children.filter((c) => c.personal_owner_id === null).length;
3490
+ if (childCount > 0) out += `${prefix} ... ${childCount} sub-collections\n`;
3491
+ }
3492
+ }
3493
+ return out;
3494
+ }
3495
+ function aggregateParameters(dashboards) {
3496
+ const paramMap = /* @__PURE__ */ new Map();
3497
+ for (const d of dashboards) for (const p of d.parameters) {
3498
+ const key = `${p.type}:${p.slug}`;
3499
+ if (!paramMap.has(key)) paramMap.set(key, {
3500
+ name: p.name,
3501
+ type: p.type,
3502
+ slug: p.slug,
3503
+ dashboardCount: 0,
3504
+ dashboards: []
3505
+ });
3506
+ const entry = paramMap.get(key);
3507
+ entry.dashboardCount++;
3508
+ entry.dashboards.push(d.name);
3509
+ }
3510
+ return Array.from(paramMap.values()).sort((a, b) => b.dashboardCount - a.dashboardCount);
3511
+ }
3512
+ function summarizeTable(table) {
3513
+ let dimensions = 0, measures = 0, timeDimensions = 0;
3514
+ for (const f of table.fields) {
3515
+ if (f.visibility_type === "hidden" || f.visibility_type === "retired") continue;
3516
+ const cls = classifyField(f);
3517
+ if (cls === "dimension") dimensions++;
3518
+ else if (cls === "measure") measures++;
3519
+ else if (cls === "time") timeDimensions++;
3520
+ }
3521
+ return {
3522
+ id: table.id,
3523
+ name: table.name,
3524
+ schema: table.schema,
3525
+ description: table.description,
3526
+ fieldCount: table.fields.length,
3527
+ dimensions,
3528
+ measures,
3529
+ timeDimensions,
3530
+ hasDescription: !!table.description
3531
+ };
3532
+ }
3533
+ function buildReport(data) {
3534
+ const { instanceUrl, user, databases, cards, dashboardList, dashboardDetails, collectionTree, collectionMap, permissionGroups, permissionsGraph, topCardsLimit, convertedSqlMap } = data;
3535
+ /** Get SQL for a card — native extraction first, then converted MBQL fallback. */
3536
+ function getCardSQL(card) {
3537
+ return extractSQL(card) || convertedSqlMap.get(card.id) || null;
3538
+ }
3539
+ const activeCards = cards.filter((c) => !c.archived);
3540
+ const activeDashboards = dashboardList.filter((d) => !d.archived);
3541
+ const models = activeCards.filter((c) => getCardType(c) === "model");
3542
+ const metrics = activeCards.filter((c) => getCardType(c) === "metric");
3543
+ const questions = activeCards.filter((c) => getCardType(c) === "question");
3544
+ const topCards = [...activeCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, topCardsLimit);
3545
+ const activeCount = activeCards.filter(isCardActive).length;
3546
+ const inactiveCount = activeCards.length - activeCount;
3547
+ const userFacingSchemasMap = /* @__PURE__ */ new Map();
3548
+ if (permissionGroups && permissionsGraph) for (const db of databases) userFacingSchemasMap.set(db.id, getUserFacingSchemas(permissionsGraph, permissionGroups, db.id));
3549
+ let report = "";
3550
+ report += `# Metabase Analysis Report\n\n`;
3551
+ report += `Generated: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n`;
3552
+ report += `Instance: ${instanceUrl}\n`;
3553
+ report += `User: ${user.name} (${user.email})\n\n`;
3554
+ report += `## How to Use This Report\n\n`;
3555
+ report += `This report maps your Metabase instance to inform semantic layer design:\n\n`;
3556
+ report += `1. **Most Referenced Tables** → Create cubes for these tables first\n`;
3557
+ report += `2. **Top Cards by Activity** → Replicate aggregations (GROUP BY + SUM/COUNT/AVG) as cube measures\n`;
3558
+ report += `3. **Common Filter Variables** → Ensure these are dimensions on relevant cubes\n`;
3559
+ report += `4. **Foreign Key Relationships** → Define joins between cubes\n`;
3560
+ report += `5. **Collection Structure** → Map collections to views (one view per business domain)\n`;
3561
+ report += `6. **Table Inventory** → Use field classification (dims/measures/time) to build each cube\n\n`;
3562
+ report += `Drill deeper with:\n`;
3563
+ report += `- \`bon metabase explore table <id>\` — field types and classification\n`;
3564
+ report += `- \`bon metabase explore card <id>\` — SQL and columns\n`;
3565
+ report += `- \`bon metabase explore collection <id>\` — cards in a collection\n`;
3566
+ report += `- \`bon metabase explore database <id>\` — schemas and tables\n\n`;
3567
+ report += `## Summary\n\n`;
3568
+ report += `| Metric | Count |\n|--------|-------|\n`;
3569
+ report += `| Databases | ${databases.length} |\n`;
3570
+ for (const db of databases) {
3571
+ const visibleTables = db.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
3572
+ report += `| Tables (${db.name}) | ${visibleTables.length} |\n`;
3573
+ }
3574
+ report += `| Models | ${models.length} |\n`;
3575
+ report += `| Metrics | ${metrics.length} |\n`;
3576
+ report += `| Questions | ${questions.length} |\n`;
3577
+ report += `| Dashboards | ${activeDashboards.length} |\n`;
3578
+ report += `| Collections | ${collectionMap.size} |\n`;
3579
+ report += `| Active cards (used in last ${INACTIVE_MONTHS}mo) | ${activeCount} |\n`;
3580
+ report += `| Inactive cards | ${inactiveCount} |\n`;
3581
+ report += `\n`;
3582
+ if (permissionGroups && permissionsGraph) {
3583
+ report += `## Schema Access\n\n`;
3584
+ report += `Schemas accessible to non-admin groups are user-facing and should be prioritized for modeling.\n\n`;
3585
+ for (const db of databases) {
3586
+ const access = extractSchemaAccess(permissionsGraph, permissionGroups, db.id);
3587
+ report += `### ${db.name} (${db.engine})\n\n`;
3588
+ report += `| Schema | Accessible To |\n|--------|---------------|\n`;
3589
+ for (const a of access) report += `| ${a.schema} | ${a.groups.join(", ")} |\n`;
3590
+ report += `\n`;
3591
+ }
3592
+ report += `### Permission Groups\n\n`;
3593
+ report += `| Group | Members |\n|-------|---------|\n`;
3594
+ for (const g of permissionGroups) report += `| ${g.name} | ${g.member_count} |\n`;
3595
+ report += `\n`;
3596
+ }
3597
+ report += `## Collection Structure (Business Domains)\n\n`;
3598
+ report += `Collections represent how users organize content by business area.\n`;
3599
+ report += `Map these to views in the semantic layer.\n\n`;
3600
+ report += "```\n";
3601
+ const filtered = collectionTree.filter((n) => n.personal_owner_id === null);
3602
+ report += renderTree(filtered);
3603
+ report += "```\n\n";
3604
+ report += `## Top ${topCards.length} Cards by Activity\n\n`;
3605
+ report += `Ranked by view count, weighted by recency. Cards not used in the last ${INACTIVE_MONTHS} months are penalized 90%.\n`;
3606
+ report += `Use \`bon metabase explore card <id>\` to view SQL and column details for any card.\n\n`;
3607
+ report += `| Rank | ID | Views | Last Used | Active | Pattern | Type | Display | Collection | Name |\n`;
3608
+ report += `|------|----|-------|-----------|--------|---------|------|---------|------------|------|\n`;
3609
+ for (let i = 0; i < topCards.length; i++) {
3610
+ const c = topCards[i];
3611
+ const ct = getCardType(c);
3612
+ const col = c.collection_id ? collectionMap.get(c.collection_id) || "?" : "Root";
3613
+ const active = isCardActive(c) ? "Yes" : "No";
3614
+ const lastUsed = formatLastUsed(c);
3615
+ const pattern = classifyCardPattern(c, getCardSQL(c));
3616
+ report += `| ${i + 1} | ${c.id} | ${c.view_count || 0} | ${lastUsed} | ${active} | ${pattern} | ${ct} | ${c.display} | ${col} | ${c.name} |\n`;
3617
+ }
3618
+ report += `\n`;
3619
+ if (dashboardDetails.length > 0) {
3620
+ const params = aggregateParameters(dashboardDetails);
3621
+ report += `## Dashboard Filter Parameters\n\n`;
3622
+ report += `Parameters used across dashboards. These represent the most important filter dimensions.\n\n`;
3623
+ if (params.length > 0) {
3624
+ report += `| Parameter | Type | Used In (dashboards) | Dashboard Names |\n`;
3625
+ report += `|-----------|------|----------------------|-----------------|\n`;
3626
+ for (const p of params) {
3627
+ const names = p.dashboards.slice(0, 3).join(", ");
3628
+ const more = p.dashboards.length > 3 ? ` +${p.dashboards.length - 3} more` : "";
3629
+ report += `| ${p.name} | ${p.type} | ${p.dashboardCount} | ${names}${more} |\n`;
3630
+ }
3631
+ report += `\n`;
3632
+ } else report += `No parameters found across analyzed dashboards.\n\n`;
3633
+ }
3634
+ const templateVarCounts = /* @__PURE__ */ new Map();
3635
+ for (const c of activeCards) {
3636
+ const sql = getCardSQL(c);
3637
+ if (!sql) continue;
3638
+ const vars = extractTemplateVars(sql);
3639
+ for (const v of vars) templateVarCounts.set(v, (templateVarCounts.get(v) || 0) + 1);
3640
+ }
3641
+ if (templateVarCounts.size > 0) {
3642
+ const sortedVars = Array.from(templateVarCounts.entries()).sort((a, b) => b[1] - a[1]).filter(([, count]) => count >= 3);
3643
+ if (sortedVars.length > 0) {
3644
+ const totalVars = templateVarCounts.size;
3645
+ report += `## Common Filter Variables (from SQL)\n\n`;
3646
+ report += `Template variables (\`{{var}}\`) used in 3+ cards. These represent key filter dimensions.\n`;
3647
+ report += `${totalVars} unique variables found; showing ${sortedVars.length} most common.\n\n`;
3648
+ report += `| Variable | Used In (cards) |\n|----------|-----------------|\n`;
3649
+ for (const [varName, count] of sortedVars) report += `| ${varName} | ${count} |\n`;
3650
+ report += `\n`;
3651
+ }
3652
+ }
3653
+ const globalTableRefs = /* @__PURE__ */ new Map();
3654
+ for (const c of activeCards) {
3655
+ const sql = getCardSQL(c);
3656
+ if (!sql) continue;
3657
+ const refs = extractTableReferences(sql);
3658
+ for (const [table, count] of refs) globalTableRefs.set(table, (globalTableRefs.get(table) || 0) + count);
3659
+ }
3660
+ if (globalTableRefs.size > 0) {
3661
+ const sortedRefs = Array.from(globalTableRefs.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20);
3662
+ report += `## Most Referenced Tables (from SQL)\n\n`;
3663
+ report += `Tables most frequently referenced in FROM/JOIN clauses across all cards.\n\n`;
3664
+ report += `| Table | References |\n|-------|------------|\n`;
3665
+ for (const [table, count] of sortedRefs) report += `| ${table} | ${count} |\n`;
3666
+ report += `\n`;
3667
+ }
3668
+ const fieldIdLookup = /* @__PURE__ */ new Map();
3669
+ for (const db of databases) for (const t of db.tables) for (const f of t.fields) fieldIdLookup.set(f.id, {
3670
+ schema: t.schema,
3671
+ table: t.name,
3672
+ field: f.name
3673
+ });
3674
+ const fkRelationships = [];
3675
+ for (const db of databases) {
3676
+ const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
3677
+ for (const t of db.tables) {
3678
+ if (userFacingSchemas !== null && !userFacingSchemas.has(t.schema)) continue;
3679
+ for (const f of t.fields) {
3680
+ if (!f.fk_target_field_id) continue;
3681
+ const target = fieldIdLookup.get(f.fk_target_field_id);
3682
+ if (!target) continue;
3683
+ if (userFacingSchemas !== null && !userFacingSchemas.has(target.schema)) continue;
3684
+ fkRelationships.push({
3685
+ fromSchema: t.schema,
3686
+ fromTable: t.name,
3687
+ fromField: f.name,
3688
+ toSchema: target.schema,
3689
+ toTable: target.table,
3690
+ toField: target.field
3691
+ });
3692
+ }
3693
+ }
3694
+ }
3695
+ if (fkRelationships.length > 0) {
3696
+ const referencedFKs = fkRelationships.filter((fk) => {
3697
+ const fromKey = `${fk.fromSchema}.${fk.fromTable}`.toLowerCase();
3698
+ const toKey = `${fk.toSchema}.${fk.toTable}`.toLowerCase();
3699
+ return globalTableRefs.has(fromKey) || globalTableRefs.has(toKey);
3700
+ });
3701
+ if (referencedFKs.length > 0) {
3702
+ report += `## Foreign Key Relationships\n\n`;
3703
+ report += `FK relationships involving tables referenced by cards. Use these to define cube joins.\n`;
3704
+ if (referencedFKs.length < fkRelationships.length) report += `${fkRelationships.length - referencedFKs.length} unreferenced FKs omitted.\n`;
3705
+ report += `\n`;
3706
+ report += `| From | Field | To | Field |\n`;
3707
+ report += `|------|-------|----|-------|\n`;
3708
+ for (const fk of referencedFKs) {
3709
+ const from = fk.fromSchema === fk.toSchema ? fk.fromTable : `${fk.fromSchema}.${fk.fromTable}`;
3710
+ const to = fk.fromSchema === fk.toSchema ? fk.toTable : `${fk.toSchema}.${fk.toTable}`;
3711
+ report += `| ${from} | ${fk.fromField} | ${to} | ${fk.toField} |\n`;
3712
+ }
3713
+ report += `\n`;
3714
+ }
3715
+ }
3716
+ report += `## Table Inventory\n\n`;
3717
+ if (permissionGroups && permissionsGraph) {
3718
+ report += `Showing tables from user-facing schemas only (accessible to non-admin groups).\n`;
3719
+ report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
3720
+ } else {
3721
+ report += `Permissions data unavailable — showing all schemas.\n`;
3722
+ report += `Staging (\`stg_\`) and intermediate (\`int_\`) tables are excluded.\n`;
3723
+ }
3724
+ report += `Use \`bon metabase explore databases\` for full database details.\n\n`;
3725
+ report += `Field classification: **Dims** = categorical/text/PKs/FKs, **Measures** = numeric, **Time** = date/datetime\n\n`;
3726
+ let skippedSchemas = 0;
3727
+ let skippedTables = 0;
3728
+ for (const db of databases) {
3729
+ const userFacingSchemas = userFacingSchemasMap.get(db.id) ?? null;
3730
+ const bySchema = /* @__PURE__ */ new Map();
3731
+ for (const t of db.tables) {
3732
+ if (t.visibility_type === "hidden" || t.visibility_type === "retired") continue;
3733
+ if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
3734
+ bySchema.get(t.schema).push(t);
3735
+ }
3736
+ for (const [schema, tables] of Array.from(bySchema.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
3737
+ if (userFacingSchemas !== null && !userFacingSchemas.has(schema)) {
3738
+ skippedSchemas++;
3739
+ skippedTables += tables.length;
3740
+ continue;
3741
+ }
3742
+ const filteredTables = tables.filter((t) => !t.name.startsWith("stg_") && !t.name.startsWith("int_"));
3743
+ skippedTables += tables.length - filteredTables.length;
3744
+ if (filteredTables.length === 0) continue;
3745
+ const summaries = filteredTables.map((t) => {
3746
+ const s = summarizeTable(t);
3747
+ const refKey1 = `${s.schema}.${s.name}`.toLowerCase();
3748
+ const refKey2 = s.name.toLowerCase();
3749
+ const refCount = globalTableRefs.get(refKey1) || globalTableRefs.get(refKey2) || 0;
3750
+ return {
3751
+ ...s,
3752
+ refCount
3753
+ };
3754
+ }).sort((a, b) => b.refCount - a.refCount || a.name.localeCompare(b.name));
3755
+ if (!summaries.some((s) => s.refCount > 0)) {
3756
+ skippedSchemas++;
3757
+ skippedTables += filteredTables.length;
3758
+ continue;
3759
+ }
3760
+ const referenced = summaries.filter((s) => s.refCount > 0);
3761
+ const unreferenced = summaries.length - referenced.length;
3762
+ report += `### ${db.name} / ${schema} (${referenced.length} referenced`;
3763
+ if (unreferenced > 0) report += `, ${unreferenced} unreferenced`;
3764
+ report += `)\n\n`;
3765
+ report += `| Table | Fields | Dims | Measures | Time | Refs |\n`;
3766
+ report += `|-------|--------|------|----------|------|------|\n`;
3767
+ for (const s of referenced) report += `| ${s.name} | ${s.fieldCount} | ${s.dimensions} | ${s.measures} | ${s.timeDimensions} | ${s.refCount} |\n`;
3768
+ if (unreferenced > 0) skippedTables += unreferenced;
3769
+ report += `\n`;
3770
+ }
3771
+ }
3772
+ if (skippedSchemas > 0 || skippedTables > 0) report += `*${skippedSchemas} admin-only schemas and ${skippedTables} staging/intermediate tables omitted.*\n\n`;
3773
+ report += `## Cards by Domain\n\n`;
3774
+ report += `Card counts and top questions per collection (by view count).\n\n`;
3775
+ const cardsByCollection = /* @__PURE__ */ new Map();
3776
+ for (const c of activeCards) {
3777
+ const col = c.collection_id ? collectionMap.get(c.collection_id) || "Uncategorized" : "Root";
3778
+ if (!cardsByCollection.has(col)) cardsByCollection.set(col, []);
3779
+ cardsByCollection.get(col).push(c);
3780
+ }
3781
+ const sortedCollections = Array.from(cardsByCollection.entries()).map(([col, colCards]) => ({
3782
+ col,
3783
+ colCards,
3784
+ activeCount: colCards.filter(isCardActive).length
3785
+ })).filter((c) => c.activeCount > 0).sort((a, b) => b.activeCount - a.activeCount);
3786
+ for (const { col, colCards, activeCount } of sortedCollections) {
3787
+ const topNames = [...colCards].sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 3).map((c) => c.name).join(", ");
3788
+ report += `- **${col}** (${colCards.length} cards, ${activeCount} active): ${topNames}\n`;
3789
+ }
3790
+ const inactiveCollections = cardsByCollection.size - sortedCollections.length;
3791
+ if (inactiveCollections > 0) report += `\n*${inactiveCollections} collections with no active cards omitted.*\n`;
3792
+ report += `\n`;
3793
+ return report;
3794
+ }
3795
+ async function metabaseAnalyzeCommand(options) {
3796
+ const config = loadMetabaseConfig();
3797
+ if (!config) {
3798
+ console.error(pc.red("Metabase is not configured."));
3799
+ console.log(pc.dim("Run: bon metabase connect"));
3800
+ process.exit(1);
3801
+ }
3802
+ const client = createMetabaseClient(config);
3803
+ const outputPath = options.output || OUTPUT_FILE;
3804
+ const topCardsLimit = options.topCards ? parseInt(options.topCards, 10) : TOP_CARDS_LIMIT;
3805
+ console.log();
3806
+ console.log(pc.dim("Fetching data from Metabase..."));
3807
+ const [user, cards, databases, collectionTree, dashboardList] = await Promise.all([
3808
+ client.getCurrentUser(),
3809
+ client.getCards(),
3810
+ client.getDatabases(),
3811
+ client.getCollectionTree(),
3812
+ client.getDashboards()
3813
+ ]);
3814
+ console.log(pc.dim(` ${cards.length} cards, ${databases.length} databases, ${dashboardList.filter((d) => !d.archived).length} dashboards`));
3815
+ let permissionGroups = null;
3816
+ let permissionsGraph = null;
3817
+ try {
3818
+ [permissionGroups, permissionsGraph] = await Promise.all([client.getPermissionGroups(), client.getPermissionsGraph()]);
3819
+ console.log(pc.dim(` ${permissionGroups.length} permission groups`));
3820
+ } catch (err) {
3821
+ if (err instanceof MetabaseApiError && (err.status === 401 || err.status === 403)) console.log(pc.dim(" Permissions: skipped (requires admin API key)"));
3822
+ else throw err;
3823
+ }
3824
+ console.log(pc.dim("Fetching database metadata..."));
3825
+ const dbMetadata = [];
3826
+ for (const db of databases) {
3827
+ const meta = await client.getDatabaseMetadata(db.id);
3828
+ const visibleTables = meta.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired");
3829
+ console.log(pc.dim(` ${db.name}: ${visibleTables.length} tables`));
3830
+ dbMetadata.push(meta);
3831
+ }
3832
+ console.log(pc.dim("Fetching top dashboard details..."));
3833
+ const activeDashboards = dashboardList.filter((d) => !d.archived);
3834
+ let popularDashboardIds = /* @__PURE__ */ new Set();
3835
+ try {
3836
+ const popular = await client.getPopularItems();
3837
+ for (const item of popular) if (item.model === "dashboard") popularDashboardIds.add(item.model_id);
3838
+ } catch {}
3839
+ const dashboardsToFetch = [...activeDashboards].sort((a, b) => {
3840
+ const aPopular = popularDashboardIds.has(a.id) ? 1 : 0;
3841
+ return (popularDashboardIds.has(b.id) ? 1 : 0) - aPopular;
3842
+ }).slice(0, TOP_DASHBOARDS_LIMIT);
3843
+ const dashboardDetails = [];
3844
+ for (const d of dashboardsToFetch) try {
3845
+ const detail = await client.getDashboard(d.id);
3846
+ dashboardDetails.push(detail);
3847
+ } catch {}
3848
+ console.log(pc.dim(` ${dashboardDetails.length} dashboards analyzed`));
3849
+ const MBQL_CONVERT_LIMIT = 100;
3850
+ const activeCards = cards.filter((c) => !c.archived);
3851
+ const mbqlCards = activeCards.filter(isMbqlCard).sort((a, b) => activityScore(b) - activityScore(a)).slice(0, MBQL_CONVERT_LIMIT);
3852
+ const convertedSqlMap = /* @__PURE__ */ new Map();
3853
+ if (mbqlCards.length > 0) {
3854
+ const totalMbql = activeCards.filter(isMbqlCard).length;
3855
+ console.log(pc.dim(`Converting top ${mbqlCards.length} query-builder cards to SQL (${totalMbql} total, capped at ${MBQL_CONVERT_LIMIT})...`));
3856
+ const BATCH_SIZE = 10;
3857
+ for (let i = 0; i < mbqlCards.length; i += BATCH_SIZE) {
3858
+ const batch = mbqlCards.slice(i, i + BATCH_SIZE);
3859
+ const results = await Promise.allSettled(batch.map(async (c) => {
3860
+ const sql = await client.convertToNativeSQL(c.dataset_query);
3861
+ return {
3862
+ id: c.id,
3863
+ sql
3864
+ };
3865
+ }));
3866
+ for (const r of results) if (r.status === "fulfilled") convertedSqlMap.set(r.value.id, r.value.sql);
3867
+ }
3868
+ console.log(pc.dim(` ${convertedSqlMap.size}/${mbqlCards.length} converted successfully`));
3869
+ }
3870
+ const flatCollections = flattenCollections(collectionTree.filter((n) => n.personal_owner_id === null));
3871
+ const collectionMap = new Map(flatCollections.map((c) => [c.id, c.path]));
3872
+ console.log(pc.dim("Building report..."));
3873
+ const report = buildReport({
3874
+ instanceUrl: config.url,
3875
+ user: {
3876
+ name: `${user.first_name} ${user.last_name}`.trim(),
3877
+ email: user.email,
3878
+ admin: user.is_superuser
3879
+ },
3880
+ databases: dbMetadata,
3881
+ cards,
3882
+ dashboardList,
3883
+ dashboardDetails,
3884
+ collectionTree,
3885
+ collectionMap,
3886
+ permissionGroups,
3887
+ permissionsGraph,
3888
+ topCardsLimit,
3889
+ convertedSqlMap
3890
+ });
3891
+ ensureBonDir();
3892
+ const fullPath = path.resolve(outputPath);
3893
+ fs.writeFileSync(fullPath, report, "utf-8");
3894
+ const allActive = cards.filter((c) => !c.archived);
3895
+ const recentlyUsed = allActive.filter(isCardActive);
3896
+ const topCards = allActive.sort((a, b) => activityScore(b) - activityScore(a)).slice(0, 5);
3897
+ console.log();
3898
+ console.log(pc.green(`✓ Analysis written to ${outputPath}`));
3899
+ console.log();
3900
+ console.log(pc.bold("Key findings:"));
3901
+ console.log();
3902
+ console.log(` ${databases.length} database(s), ${dbMetadata.reduce((sum, db) => sum + db.tables.filter((t) => t.visibility_type !== "hidden" && t.visibility_type !== "retired").length, 0)} tables`);
3903
+ console.log(` ${allActive.length} cards (${recentlyUsed.length} active in last ${INACTIVE_MONTHS}mo, ${allActive.length - recentlyUsed.length} inactive)`);
3904
+ console.log(` ${activeDashboards.length} dashboards across ${collectionMap.size} collections`);
3905
+ if (permissionGroups) {
3906
+ const nonAdminGroups = permissionGroups.filter((g) => g.name !== "Administrators");
3907
+ if (permissionsGraph) for (const db of databases) {
3908
+ const userSchemas = extractSchemaAccess(permissionsGraph, nonAdminGroups, db.id).filter((a) => a.schema !== "*");
3909
+ if (userSchemas.length > 0) console.log(` User-facing schemas: ${userSchemas.map((s) => s.schema).join(", ")}`);
3910
+ }
3911
+ }
3912
+ console.log();
3913
+ console.log(pc.bold("Top cards (by activity):"));
3914
+ for (const c of topCards) {
3915
+ const lastUsed = formatLastUsed(c);
3916
+ const flag = isCardActive(c) ? "" : pc.dim(" (inactive)");
3917
+ console.log(` ${String(c.view_count || 0).padStart(6)} views ${lastUsed.padEnd(12)} ${c.name}${flag}`);
3918
+ }
3919
+ console.log();
3920
+ console.log(pc.dim(`Full report: ${outputPath}`));
3921
+ }
3922
+
3085
3923
  //#endregion
3086
3924
  //#region src/bin/bon.ts
3087
3925
  const { version } = createRequire(import.meta.url)("../../package.json");
@@ -3093,18 +3931,21 @@ program.command("whoami").description("Show current login status").option("--ver
3093
3931
  const datasource = program.command("datasource").description("Manage warehouse data source connections");
3094
3932
  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);
3095
3933
  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);
3096
- datasource.command("test").description("Test data source connectivity by connecting directly to the warehouse").argument("<name>", "Data source name from .bon/datasources.yaml").option("--remote", "Test via Bonnard API instead of direct connection (requires login)").action(datasourceTestCommand);
3934
+ datasource.command("test").description("Test data source connectivity via Bonnard API (requires login)").argument("<name>", "Data source name from .bon/datasources.yaml").action(datasourceTestCommand);
3097
3935
  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);
3098
3936
  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);
3099
- program.command("preview").description("Preview data from a local warehouse using raw SQL (for development/exploration)").argument("<datasource>", "Data source name from .bon/datasources.yaml").argument("<sql>", "SQL query to execute").option("--schema <schema>", "Override schema").option("--database <database>", "Override database").option("--limit <limit>", "Max rows to return", "1000").option("--format <format>", "Output format: toon or json", "toon").action(previewCommand);
3100
- program.command("validate").description("Validate YAML syntax in bonnard/cubes/ and bonnard/views/").option("--test-connection", "Also test datasource connections (warns on failure, doesn't block)").action(validateCommand);
3101
- program.command("deploy").description("Deploy cubes and views to Bonnard. Requires login, validates, tests connections (fails on error)").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);
3937
+ program.command("validate").description("Validate YAML syntax in bonnard/cubes/ and bonnard/views/").action(validateCommand);
3938
+ 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);
3102
3939
  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);
3103
3940
  program.command("diff").description("Show changes in a deployment").argument("<id>", "Deployment ID").option("--format <format>", "Output format: table or json", "table").option("--breaking", "Show only breaking changes").action(diffCommand);
3104
3941
  program.command("annotate").description("Annotate deployment changes with reasoning").argument("<id>", "Deployment ID").option("--data <json>", "Annotations JSON").action(annotateCommand);
3105
3942
  program.command("mcp").description("MCP connection info and setup instructions").action(mcpCommand).command("test").description("Test MCP server connectivity").action(mcpTestCommand);
3106
3943
  program.command("query").description("Execute a query against the deployed semantic layer").argument("<query>", "JSON query or SQL (with --sql flag)").option("--sql", "Use SQL API instead of JSON format").option("--limit <limit>", "Max rows to return").option("--format <format>", "Output format: toon or json", "toon").action(cubeQueryCommand);
3107
3944
  program.command("docs").description("Browse documentation for building cubes and views").argument("[topic]", "Topic to display (e.g., cubes, cubes.measures)").option("-r, --recursive", "Show topic and all child topics").option("-s, --search <query>", "Search topics for a keyword").option("-f, --format <format>", "Output format: markdown or json", "markdown").action(docsCommand).command("schema").description("Show JSON schema for a type (cube, view, measure, etc.)").argument("<type>", "Schema type to display").action(docsSchemaCommand);
3945
+ const metabase = program.command("metabase").description("Connect to and explore Metabase content");
3946
+ metabase.command("connect").description("Configure Metabase API connection").option("--url <url>", "Metabase instance URL").option("--api-key <key>", "Metabase API key").option("--force", "Overwrite existing configuration").action(metabaseConnectCommand);
3947
+ metabase.command("explore").description("Browse Metabase databases, collections, cards, and dashboards").argument("[resource]", "databases, collections, cards, dashboards, card, dashboard, database, table, collection").argument("[id]", "Resource ID (e.g. card <id>, dashboard <id>, database <id>, table <id>, collection <id>)").action(metabaseExploreCommand);
3948
+ metabase.command("analyze").description("Analyze Metabase instance and generate a structured report for semantic layer planning").option("--output <path>", "Output file path", ".bon/metabase-analysis.md").option("--top-cards <n>", "Number of top cards to include in report", "50").action(metabaseAnalyzeCommand);
3108
3949
  program.parse();
3109
3950
 
3110
3951
  //#endregion