@metabase/cli 0.1.18 → 0.2.1-alpha.bcm-ghy-4075-spawn-default-profile.eb120d1
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/README.md +283 -45
- package/dist/add-collection-CCpC-GUz.mjs +9 -0
- package/dist/{add-collection-CRZAFCZy.mjs → add-collection-CLXfrgp4.mjs} +4 -5
- package/dist/append-DMcxP1LM.mjs +42 -0
- package/dist/archive-4WqMsPWB.mjs +38 -0
- package/dist/{archive-De4yVEV8.mjs → archive-5inmbMDL.mjs} +5 -7
- package/dist/{archive-3mEhK9_H.mjs → archive-BggM_AuL.mjs} +5 -7
- package/dist/{archive-Z8ZM-ilB.mjs → archive-BmhehbS0.mjs} +8 -9
- package/dist/{archive-d74nDcp4.mjs → archive-BwIZzWGe.mjs} +5 -7
- package/dist/{archive-CFHYRF65.mjs → archive-DErhQn8A.mjs} +5 -7
- package/dist/archive-DTnz1suV.mjs +38 -0
- package/dist/{archive-C3RWdM-2.mjs → archive-Q1387APe.mjs} +7 -8
- package/dist/{archive-errg-EKw.mjs → archive-h-3H5b4K.mjs} +5 -7
- package/dist/auth-DzCAiArV.mjs +22 -0
- package/dist/{body-BS2s2zDl.mjs → body-5snfmiwR.mjs} +3 -3
- package/dist/{branches-mcyT7wI-.mjs → branches-PL170CYS.mjs} +5 -7
- package/dist/{cancel-B2l-i2NS.mjs → cancel-EFKcF5J_.mjs} +4 -6
- package/dist/{cancel-task-CugcIeIi.mjs → cancel-task-mFFx0d5m.mjs} +5 -7
- package/dist/capabilities-CiaAfUgX.mjs +684 -0
- package/dist/{card-DEmcRlNO.mjs → card-B8G5jQQT.mjs} +24 -18
- package/dist/card-BsfdXK0O.mjs +26 -0
- package/dist/{cards-BGkz5BeZ.mjs → cards-BI7zKb9M.mjs} +7 -8
- package/dist/cli.mjs +178 -29
- package/dist/collection-2h_by9Ue.mjs +23 -0
- package/dist/{collection-DrLpA1SO.mjs → collection-C52Ndi_O.mjs} +5 -3
- package/dist/{collection-namespace-BP_LJrAD.mjs → collection-namespace-uNA5WhLB.mjs} +2 -2
- package/dist/{create-Cztp5kES.mjs → create-BLD0YniB.mjs} +6 -7
- package/dist/create-BP8EIWrP.mjs +161 -0
- package/dist/{create-CkRFKtFG.mjs → create-BpizGzgQ.mjs} +8 -9
- package/dist/create-BxCsAdqc.mjs +45 -0
- package/dist/{create-DzyMvAVy.mjs → create-CBKT7SE_.mjs} +8 -9
- package/dist/{create-D8KA4zJA.mjs → create-CQ32FwVe.mjs} +11 -11
- package/dist/{create-YfGzANNB.mjs → create-CU3bYpsT.mjs} +8 -9
- package/dist/create-DAH24ltu.mjs +45 -0
- package/dist/{create-BOmmMSjF.mjs → create-DutkaD7P.mjs} +11 -12
- package/dist/{create-B4HAEEE0.mjs → create-FuZa4j1N.mjs} +8 -9
- package/dist/{create-wpvDXSSY.mjs → create-KveE3j3p.mjs} +12 -13
- package/dist/{create-DhrftYre.mjs → create-VrSowEbj.mjs} +14 -14
- package/dist/{create-CD9Rp8qs.mjs → create-acWKw4Y1.mjs} +14 -14
- package/dist/{create-branch-DsoMfx_2.mjs → create-branch-CP1Y4_El.mjs} +5 -7
- package/dist/{create-kWzqvTGR.mjs → create-xPfmI1Tu.mjs} +11 -12
- package/dist/csv-CZyYi0vi.mjs +58 -0
- package/dist/{current-task-WuKPZpZ6.mjs → current-task-BR4koo9z.mjs} +5 -7
- package/dist/dashboard-ARgJfamb.mjs +35 -0
- package/dist/{dashboard-DOplbKyQ.mjs → dashboard-BLihjDXg.mjs} +35 -15
- package/dist/{database-D9fftP-i.mjs → database-BOqzcexD.mjs} +2 -2
- package/dist/db-dbnxMPSR.mjs +27 -0
- package/dist/{delete-Buai0_e_.mjs → delete-BpL3vaaT.mjs} +6 -8
- package/dist/{delete-BCitmugk.mjs → delete-C5DqDwev.mjs} +6 -8
- package/dist/delete-DCDFLHFa.mjs +49 -0
- package/dist/delete-P2Qlsr_s.mjs +49 -0
- package/dist/{delete-CmGh0jo1.mjs → delete-ea_ns1uM.mjs} +6 -8
- package/dist/{delete-runtime-B0ha5QR4.mjs → delete-runtime-DdBn6poA.mjs} +2 -3
- package/dist/{delete-table-BOkYxdjA.mjs → delete-table-tM048btr.mjs} +6 -8
- package/dist/{dependencies-XZrEvHUA.mjs → dependencies-DdOTClO2.mjs} +5 -7
- package/dist/destroy-B51EPvOJ.mjs +168 -0
- package/dist/{dirty-B-5NDbG9.mjs → dirty-CEQYmhNF.mjs} +5 -7
- package/dist/{document-1W7NRaO_.mjs → document-D4zB-5a0.mjs} +20 -4
- package/dist/document-DR1FLOn9.mjs +22 -0
- package/dist/{eid-BCuJRv7a.mjs → eid-CtKGjAkz.mjs} +10 -11
- package/dist/embedding-CKdw8q7p.mjs +11 -0
- package/dist/events-C26DZ6yZ.mjs +44 -0
- package/dist/{export-Dod2gdyE.mjs → export-CippuWZJ.mjs} +5 -10
- package/dist/export-Cy2DFtR2.mjs +10 -0
- package/dist/field-CXDndCbB.mjs +21 -0
- package/dist/{field-CMY_LWUe.mjs → field-DsXTeqBy.mjs} +6 -4
- package/dist/{fields-B0yttrzR.mjs → fields-Xm_HVNcX.mjs} +6 -8
- package/dist/get-3O7hv4zv.mjs +67 -0
- package/dist/{get-D-pNrQGA.mjs → get-B0cq1uGy.mjs} +5 -7
- package/dist/{get-k6M5nGMC.mjs → get-B90bXbpw.mjs} +5 -7
- package/dist/{get-D7QMqPOK.mjs → get-BQBu2c94.mjs} +5 -7
- package/dist/{get-CfeYa0v5.mjs → get-BQz45IBF.mjs} +6 -7
- package/dist/get-BSfKWZNJ.mjs +35 -0
- package/dist/get-BdNC8WM8.mjs +38 -0
- package/dist/{get-DBY5esTW.mjs → get-Bj66bcuU.mjs} +5 -7
- package/dist/{get-BpZ81hX8.mjs → get-BrKqMM1d.mjs} +5 -7
- package/dist/{get-9p0Gt1c7.mjs → get-CjZWjRa5.mjs} +5 -7
- package/dist/{get-DISgP66L.mjs → get-CwGqARAf.mjs} +16 -9
- package/dist/{get-BkGh9BPd.mjs → get-D5crrFSf.mjs} +5 -7
- package/dist/{get-Dz7fcqoG.mjs → get-DTkvGu_J.mjs} +8 -9
- package/dist/{get-u5Uq9vms.mjs → get-DX2fp6BB.mjs} +4 -6
- package/dist/get-DYuJVlP-.mjs +35 -0
- package/dist/{get-DJ8huA8y.mjs → get-FIE88hj9.mjs} +7 -8
- package/dist/{get-B0OsW6jk.mjs → get-Ihx_XaaI.mjs} +5 -7
- package/dist/{get-run-DQJVpDw1.mjs → get-run-lVwhQVFx.mjs} +5 -7
- package/dist/{get-CbpHt4Hs.mjs → get-z0BKkiRX.mjs} +4 -6
- package/dist/git-sync-CmB4YBmS.mjs +31 -0
- package/dist/{group-BNE_RiH5.mjs → group-CILtQqCL.mjs} +2 -1
- package/dist/{has-remote-changes-DvQBXudi.mjs → has-remote-changes-DHrYSnTg.mjs} +5 -7
- package/dist/{import-PGS8-DwE.mjs → import-BU40xXSF.mjs} +7 -9
- package/dist/{input-7Sj85_K7.mjs → input-BGyHn0Kr.mjs} +6 -3
- package/dist/{is-dirty-DyEVFQVJ.mjs → is-dirty-BPluqPPP.mjs} +3 -4
- package/dist/is-dirty-DOy1TRcf.mjs +8 -0
- package/dist/{items-CkFEy2Du.mjs → items-BZpYkImP.mjs} +6 -8
- package/dist/{key-bltP32Pm.mjs → key-B8UAsQmB.mjs} +1 -1
- package/dist/{library-1AAVbk-K.mjs → library-BGWDIfEM.mjs} +6 -6
- package/dist/list-B037kJtH.mjs +39 -0
- package/dist/{list-82NkvRpI.mjs → list-B7Ihu6XI.mjs} +7 -8
- package/dist/{list-pQ22nXhQ.mjs → list-BCeZlfpQ.mjs} +4 -6
- package/dist/{list-CKzpoTgP.mjs → list-Bct00qku.mjs} +6 -8
- package/dist/{list-D-rgDFa5.mjs → list-Be8xMtS3.mjs} +8 -9
- package/dist/{list-Bb8YRON_.mjs → list-CjBPVJqd.mjs} +4 -6
- package/dist/{list-BCYTTCFD.mjs → list-CoqHnusK.mjs} +4 -6
- package/dist/{list-B9wXg3qi.mjs → list-CvaoHnl-.mjs} +3 -5
- package/dist/{list-BVzu2RIZ.mjs → list-DamRE5Ds.mjs} +4 -6
- package/dist/{list-BWAYDSbQ.mjs → list-DmopauvJ.mjs} +4 -6
- package/dist/{list-CJx5q-Yn.mjs → list-M_1sdYNH.mjs} +6 -8
- package/dist/{list-CK0p7vvK.mjs → list-Ov6bYzT9.mjs} +7 -8
- package/dist/{list-Sgo3RfDY.mjs → list-SfpASSFx.mjs} +4 -6
- package/dist/{list-BBxRjuMn.mjs → list-fPfgG0PY.mjs} +4 -6
- package/dist/list-lsPeumLP.mjs +34 -0
- package/dist/{list-BY4S32Lg.mjs → list-sn9M7QQX.mjs} +4 -6
- package/dist/{list-CjF12k1G.mjs → list-wxIVl45_.mjs} +5 -7
- package/dist/listing-wFZuS5li.mjs +10 -0
- package/dist/{login-C6ZAnGHz.mjs → login-CRlmR4op.mjs} +61 -23
- package/dist/{logout-CWjyY3Y8.mjs → logout-DSZEUxSQ.mjs} +5 -7
- package/dist/{measure-DoJvtCaA.mjs → measure-C3G1srWb.mjs} +5 -4
- package/dist/measure-Ka0n3426.mjs +25 -0
- package/dist/{parameter-values-DmDOuE-j.mjs → parameter-values-CwW8Xes7.mjs} +5 -7
- package/dist/{parse-enum-BL9i_brN.mjs → parse-enum-ooCsJWn6.mjs} +1 -1
- package/dist/parse-id-xS1tWJAq.mjs +18 -0
- package/dist/{parse-ref-CB_KvF9h.mjs → parse-ref-j4lxaQmd.mjs} +1 -1
- package/dist/{path-D8IJ4YrW.mjs → path-CVmg-bmf.mjs} +4 -6
- package/dist/{poll-hgnrHBoh.mjs → poll-CuTlWBwI.mjs} +2 -2
- package/dist/{poll-task-NQNLT_aA.mjs → poll-task-DhsYs8Hx.mjs} +2 -2
- package/dist/{preflight-DvaPQHHf.mjs → preflight-D3VgjEg7.mjs} +4 -5
- package/dist/{process-j8UHMHc2.mjs → process-7c8BcQEi.mjs} +1 -1
- package/dist/profile-name-CZ1-Vtrh.mjs +7 -0
- package/dist/{prompt-C85xd9HR.mjs → prompt-jQnNKtlx.mjs} +1 -1
- package/dist/{publish-BntmFR6g.mjs → publish-DFTCsjHU.mjs} +13 -10
- package/dist/{query-DSKQZu91.mjs → query-CPq-dhFV.mjs} +15 -15
- package/dist/{query-BQfgKV68.mjs → query-Dfx69iJ4.mjs} +9 -10
- package/dist/{query-result-D6mfoVfQ.mjs → query-result-Cg7odyR_.mjs} +1 -1
- package/dist/{remove-collection-BguaI3-6.mjs → remove-collection-DUAMhx5L.mjs} +7 -9
- package/dist/replace-DZWd6avZ.mjs +42 -0
- package/dist/requests-DYrDe-D0.mjs +96 -0
- package/dist/{rescan-values-DjD6IW3J.mjs → rescan-values-DZSnEFA0.mjs} +7 -9
- package/dist/{resolve-Dj2MTBkn.mjs → resolve-BTZuO3Yq.mjs} +22 -15
- package/dist/{run-Bp1yxkBN.mjs → run-B2b3Bjmv.mjs} +4 -6
- package/dist/{run-RQfQj7Rk.mjs → run-ryZ4sXK5.mjs} +7 -9
- package/dist/{runs-CSsatfWb.mjs → runs-F82HVSie.mjs} +5 -7
- package/dist/{runtime-oxjmrYoP.mjs → runtime-C6ASrgVA.mjs} +944 -909
- package/dist/{schema-tables-5I5pCxHl.mjs → schema-tables-CCOKb715.mjs} +6 -8
- package/dist/{schemas-CfzFCfBt.mjs → schemas-LtVrNECH.mjs} +4 -6
- package/dist/{search-Vo-BqliA.mjs → search-9GSZHgJQ.mjs} +4 -6
- package/dist/segment-C40bORNZ.mjs +25 -0
- package/dist/{segment-TXktTCfU.mjs → segment-CxRYEXeO.mjs} +6 -5
- package/dist/{selectors-DBnJsKlW.mjs → selectors-B2nDmrIV.mjs} +3 -3
- package/dist/{set-1Vh0AF-T.mjs → set-C57KDMGM.mjs} +9 -9
- package/dist/{set-active-C0mUZlNN.mjs → set-active-BDHGHtj2.mjs} +4 -6
- package/dist/setting-zrJwy48C.mjs +20 -0
- package/dist/{setup-CzYFvFK5.mjs → setup-Doq8atTG.mjs} +11 -12
- package/dist/{skills-B6gfH0iR.mjs → skills-y-KsUqT9.mjs} +1 -1
- package/dist/{skills-CR2xyV33.mjs → skills-yuzH_o8s.mjs} +3 -3
- package/dist/{snippet-CtA2Pkoa.mjs → snippet-C8PVaPY9.mjs} +7 -4
- package/dist/snippet-ScFM8re8.mjs +22 -0
- package/dist/{stash-CQHXwBu_.mjs → stash-D_u6z4q6.mjs} +7 -9
- package/dist/{status-C_7aqTvB.mjs → status--Dm2Jv6k.mjs} +5 -7
- package/dist/{status-DXQkM18v.mjs → status-CDPhDX5I.mjs} +27 -13
- package/dist/{summary-1aNpQc8j.mjs → summary-C9a2cBTE.mjs} +5 -7
- package/dist/{sync-schema-BqLp5uTS.mjs → sync-schema-CDIWNCGI.mjs} +9 -11
- package/dist/sync-scope-DW2QpGwv.mjs +55 -0
- package/dist/{table-DE3i82T_.mjs → table-3onHpwcb.mjs} +16 -4
- package/dist/table-BKehBm_E.mjs +21 -0
- package/dist/timeline-DEaiuB5V.mjs +157 -0
- package/dist/timeline-event-CLWMxvE0.mjs +22 -0
- package/dist/timeline-n3Nw9Egt.mjs +24 -0
- package/dist/transform-CrOOqKen.mjs +31 -0
- package/dist/{transform-DEF38FWe.mjs → transform-DP6J2ber.mjs} +31 -5
- package/dist/transform-job-J-E0Mec7.mjs +25 -0
- package/dist/transform-tag-Bkyasy_s.mjs +21 -0
- package/dist/{transforms-BelyllUL.mjs → transforms-w2a5qsjJ.mjs} +5 -7
- package/dist/{tree-YvmwGql7.mjs → tree-JZC6vD9q.mjs} +4 -6
- package/dist/{unpublish-CHjGLMu9.mjs → unpublish-BsfIH46y.mjs} +5 -7
- package/dist/{update-D7vc8GBF.mjs → update-B8hn6VxO.mjs} +12 -13
- package/dist/{update-BKhekuqr.mjs → update-BSNK36fk.mjs} +15 -15
- package/dist/update-BTFj6WHo.mjs +52 -0
- package/dist/{update-BACl_bJS.mjs → update-BfIGjAVA.mjs} +9 -10
- package/dist/{update-CrRz47aj.mjs → update-C1_aIL6X.mjs} +15 -15
- package/dist/{update-K-jjQ0Aw.mjs → update-CXKWHOIN.mjs} +10 -11
- package/dist/{update-BnHYSu96.mjs → update-Cg09lBb5.mjs} +13 -14
- package/dist/update-D01S8hnw.mjs +52 -0
- package/dist/{update-txUfAJxy.mjs → update-DKQjMsuE.mjs} +9 -10
- package/dist/{update-Zz01Woxj.mjs → update-DSlSr7Ky.mjs} +9 -10
- package/dist/{update-C9fndCqb.mjs → update-QaBVvlz9.mjs} +9 -10
- package/dist/{update-BGMJTpA6.mjs → update-WLb9099y.mjs} +12 -13
- package/dist/{update-BWlN3QDo.mjs → update-bFNKpay8.mjs} +9 -10
- package/dist/{update-dashcard-Dvth-yLC.mjs → update-dashcard-CKZC7scy.mjs} +11 -11
- package/dist/{upgrade-DsXlfPef.mjs → upgrade-DkK1ZbEY.mjs} +5 -7
- package/dist/upload-DOMoaR9f.mjs +16 -0
- package/dist/{uuid-ByJmWV7b.mjs → uuid-DDbLKnbe.mjs} +3 -5
- package/dist/{validate-BqNW4Sk1.mjs → validate-DqQ7DQnP.mjs} +1 -2
- package/dist/{validate-query-CcZVKYPV.mjs → validate-query-BYG7lyaY.mjs} +2 -3
- package/dist/{values-CIjJI4sz.mjs → values-Bc99Kkuv.mjs} +5 -7
- package/dist/{verify-Jsuc2dat.mjs → verify-WvMbaT8W.mjs} +2 -2
- package/dist/{wait-5ltTFvSa.mjs → wait-DmgEPnHW.mjs} +6 -8
- package/dist/{wait-flags-D6kd_G7c.mjs → wait-flags-BpzyrpAL.mjs} +2 -2
- package/dist/workspace-BgH_4xFO.mjs +75 -0
- package/dist/workspace-LFtHxcw9.mjs +19 -0
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +31 -23
- package/skill-data/dashboard/SKILL.md +26 -3
- package/skill-data/data-workflow/SKILL.md +17 -15
- package/skill-data/git-sync/SKILL.md +20 -5
- package/skill-data/visualization/SKILL.md +1 -0
- package/dist/add-collection-DbalcC3_.mjs +0 -11
- package/dist/auth--PWX0oj5.mjs +0 -22
- package/dist/capabilities-N0jo5U7S.mjs +0 -239
- package/dist/card-D07WYCQY.mjs +0 -26
- package/dist/collection-Dss2qSEF.mjs +0 -23
- package/dist/dashboard-Crh4v6Fq.mjs +0 -35
- package/dist/db-BCAURQei.mjs +0 -28
- package/dist/document-upxL2nKH.mjs +0 -22
- package/dist/error-BaBm-UrT.mjs +0 -227
- package/dist/field-CloFa1oe.mjs +0 -21
- package/dist/get-B8tugTku.mjs +0 -50
- package/dist/git-sync-C9m-OHac.mjs +0 -31
- package/dist/is-dirty-DpyKeGAJ.mjs +0 -10
- package/dist/manifest-BVf8P4bl.mjs +0 -126
- package/dist/measure-Cbly1r0E.mjs +0 -25
- package/dist/metadata-CW5Lfw5d.mjs +0 -39
- package/dist/metadata-aQAqseCm.mjs +0 -38
- package/dist/notice-DyVl5aYB.mjs +0 -206
- package/dist/parse-id-DlXnOcmP.mjs +0 -12
- package/dist/segment-BuN_IoM8.mjs +0 -25
- package/dist/setting-BbdKR-lO.mjs +0 -20
- package/dist/snippet-Bzo2U9Fv.mjs +0 -22
- package/dist/table-BwGOz97O.mjs +0 -22
- package/dist/transform-B65ZD9-e.mjs +0 -31
- package/dist/transform-job-BWVKXSV6.mjs +0 -25
- package/dist/transform-tag-C4qvmicL.mjs +0 -21
- /package/dist/{body-flags-DWTTxJpP.mjs → body-flags-CgHgJBRC.mjs} +0 -0
- /package/dist/{command-augment-DdZIfx1V.mjs → command-augment-D9pI9Vbh.mjs} +0 -0
- /package/dist/{parameter-CiJ4CwWE.mjs → parameter-BZ6y-Gfs.mjs} +0 -0
- /package/dist/{render-BTKnWL0d.mjs → render-CkuFkWlQ.mjs} +0 -0
- /package/dist/{transform-job-CtixL4An.mjs → transform-job-B5x8aYfV.mjs} +0 -0
- /package/dist/{transform-tag-rsIrckCM.mjs → transform-tag-BRwfWeHf.mjs} +0 -0
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { AbortError, ConfigError, MetabaseError, NetworkError, ResponseShapeError, TimeoutError, ValidationError, errorMessage, flagConsumesValue, isNotFoundError, normalizeFlag, toAliasArray, warn } from "./
|
|
2
|
-
import {
|
|
3
|
-
import { setMetabaseAugment } from "./command-augment-DdZIfx1V.mjs";
|
|
4
|
-
import { BASELINE_CAPABILITIES, isPlainObject } from "./capabilities-N0jo5U7S.mjs";
|
|
1
|
+
import { AbortError, BASELINE_CAPABILITIES, ConfigError, DEFAULT_MAX_BYTES, ENV_API_KEY, ENV_DISABLE_KEYRING, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, MetabaseError, NetworkError, ResponseShapeError, TimeoutError, ValidationError, consumeLegacyEnvWarnings, errorMessage, flagConsumesValue, isNotFoundError, isPlainObject, normalizeFlag, package_default, readEnv, reportError, toAliasArray, warn } from "./capabilities-CiaAfUgX.mjs";
|
|
2
|
+
import { setMetabaseAugment } from "./command-augment-D9pI9Vbh.mjs";
|
|
5
3
|
import { defineCommand } from "citty";
|
|
6
4
|
import { z } from "zod";
|
|
7
5
|
import { promises } from "node:fs";
|
|
8
6
|
import { dirname, join } from "node:path";
|
|
9
7
|
import { Entry } from "@napi-rs/keyring";
|
|
10
8
|
import { homedir } from "node:os";
|
|
11
|
-
import { parse } from "semver";
|
|
12
9
|
import { setTimeout } from "node:timers/promises";
|
|
10
|
+
import { parse } from "semver";
|
|
13
11
|
|
|
14
12
|
//#region src/runtime/json.ts
|
|
15
13
|
const JSON_CONTENT_TYPE = "application/json";
|
|
@@ -79,720 +77,372 @@ const SessionProperties = z.object({
|
|
|
79
77
|
}).loose();
|
|
80
78
|
|
|
81
79
|
//#endregion
|
|
82
|
-
//#region src/core/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
88
|
-
function tryParseTag(tag) {
|
|
89
|
-
const parsed = parse(tag);
|
|
90
|
-
if (parsed === null || parsed.major !== 0 && parsed.major !== 1) return null;
|
|
91
|
-
return {
|
|
92
|
-
tag,
|
|
93
|
-
major: parsed.minor,
|
|
94
|
-
patch: parsed.patch
|
|
95
|
-
};
|
|
80
|
+
//#region src/core/url.ts
|
|
81
|
+
function normalizeUrl(input) {
|
|
82
|
+
const trimmed = input.trim().replace(/\/+$/, "");
|
|
83
|
+
if (!/^https?:\/\//i.test(trimmed)) throw new ConfigError("URL must start with http:// or https://");
|
|
84
|
+
return trimmed;
|
|
96
85
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
version: ParsedVersionSchema.nullable(),
|
|
108
|
-
tokenFeatures: TokenFeatures.nullable(),
|
|
109
|
-
user: ProbedUser
|
|
110
|
-
});
|
|
111
|
-
const ProfileFailureKind = z.enum([
|
|
112
|
-
"auth",
|
|
113
|
-
"network",
|
|
114
|
-
"server"
|
|
86
|
+
function displayUrl(input) {
|
|
87
|
+
const parsed = new URL(input);
|
|
88
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
89
|
+
return `${parsed.origin}${path}`;
|
|
90
|
+
}
|
|
91
|
+
const LOOPBACK_HOSTS = new Set([
|
|
92
|
+
"localhost",
|
|
93
|
+
"127.0.0.1",
|
|
94
|
+
"::1",
|
|
95
|
+
"[::1]"
|
|
115
96
|
]);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
name: z.string(),
|
|
130
|
-
url: z.string(),
|
|
131
|
-
apiKey: z.string().nullable(),
|
|
132
|
-
oauth: ProfileOAuth.nullable().default(null),
|
|
133
|
-
lastProbe: ProfileLastProbe.nullable(),
|
|
134
|
-
lastFailure: ProfileLastFailure.nullable()
|
|
135
|
-
}).loose();
|
|
136
|
-
const ProfilesFile = z.object({ profiles: z.array(ProfileRecord) }).loose();
|
|
137
|
-
function profileAuthMethod(record) {
|
|
138
|
-
return record.oauth !== null ? "oauth" : "apiKey";
|
|
97
|
+
function isLoopbackHost(hostname) {
|
|
98
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
function assertEndpointOrigin(endpoint, baseUrl, label) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = new URL(endpoint);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new ConfigError(`OAuth ${label} is not a valid URL: ${endpoint}`);
|
|
106
|
+
}
|
|
107
|
+
const base = new URL(baseUrl);
|
|
108
|
+
if (parsed.protocol !== "https:" && !isLoopbackHost(parsed.hostname)) throw new ConfigError(`OAuth ${label} must use https: ${endpoint}`);
|
|
109
|
+
if (parsed.origin !== base.origin) throw new ConfigError(`OAuth ${label} origin (${parsed.origin}) does not match the Metabase URL (${base.origin})`);
|
|
139
110
|
}
|
|
140
111
|
|
|
141
112
|
//#endregion
|
|
142
|
-
//#region src/
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
profileOAuthAccess: (profile) => `profile:${profile}:oauthAccess`,
|
|
155
|
-
profileOAuthRefresh: (profile) => `profile:${profile}:oauthRefresh`
|
|
156
|
-
};
|
|
157
|
-
let legacyWarningPending = false;
|
|
158
|
-
let keychainResidualPending = false;
|
|
159
|
-
let keyringDowngradeNotice = null;
|
|
160
|
-
function profilesFilePath() {
|
|
161
|
-
return join(configDir(), PROFILES_FILE);
|
|
113
|
+
//#region src/runtime/signal.ts
|
|
114
|
+
function createProcessAbortHandler() {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const handler = () => {
|
|
117
|
+
if (!controller.signal.aborted) controller.abort(new AbortError("interrupted"));
|
|
118
|
+
process.off("SIGINT", handler);
|
|
119
|
+
};
|
|
120
|
+
process.on("SIGINT", handler);
|
|
121
|
+
return {
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
uninstall: () => process.off("SIGINT", handler)
|
|
124
|
+
};
|
|
162
125
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return
|
|
126
|
+
let singleton = null;
|
|
127
|
+
function getProcessAbortSignal() {
|
|
128
|
+
if (singleton === null) singleton = createProcessAbortHandler().signal;
|
|
129
|
+
return singleton;
|
|
167
130
|
}
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
131
|
+
function combineAborts(timeoutSignal, callerSignal) {
|
|
132
|
+
const processSignal = getProcessAbortSignal();
|
|
133
|
+
const signals = [timeoutSignal, processSignal];
|
|
134
|
+
if (callerSignal) signals.push(callerSignal);
|
|
135
|
+
return {
|
|
136
|
+
combined: AbortSignal.any(signals),
|
|
137
|
+
processSignal
|
|
138
|
+
};
|
|
172
139
|
}
|
|
173
|
-
function
|
|
174
|
-
const
|
|
175
|
-
keyringDowngradeNotice = null;
|
|
176
|
-
return message;
|
|
140
|
+
function throwIfAborted(...signals) {
|
|
141
|
+
for (const signal of signals) if (signal?.aborted) throw abortReason(signal);
|
|
177
142
|
}
|
|
178
|
-
function
|
|
179
|
-
|
|
143
|
+
function abortReason(signal) {
|
|
144
|
+
const reason = signal.reason;
|
|
145
|
+
if (reason instanceof MetabaseError) return reason;
|
|
146
|
+
if (reason instanceof Error || typeof reason === "string") return new AbortError(errorMessage(reason) || "aborted");
|
|
147
|
+
return new AbortError("aborted");
|
|
180
148
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/core/auth/credential.ts
|
|
152
|
+
const API_KEY_HEADER = "x-api-key";
|
|
153
|
+
const AUTHORIZATION_HEADER = "authorization";
|
|
154
|
+
const BEARER_PREFIX = "Bearer ";
|
|
155
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
156
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
157
|
+
function credentialAuthHeader(credential) {
|
|
158
|
+
if (credential.kind === "apiKey") return {
|
|
159
|
+
name: API_KEY_HEADER,
|
|
160
|
+
value: credential.apiKey
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
name: AUTHORIZATION_HEADER,
|
|
164
|
+
value: BEARER_PREFIX + credential.accessToken
|
|
165
|
+
};
|
|
189
166
|
}
|
|
190
|
-
function
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
return new Entry(KEYRING_SERVICE, key).getPassword();
|
|
194
|
-
} catch {
|
|
195
|
-
return void 0;
|
|
196
|
-
}
|
|
167
|
+
function credentialSecrets(credential) {
|
|
168
|
+
if (credential.kind === "apiKey") return [credential.apiKey];
|
|
169
|
+
return [credential.accessToken, credential.refreshToken];
|
|
197
170
|
}
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
} catch {
|
|
203
|
-
return "failed";
|
|
204
|
-
}
|
|
171
|
+
function isOAuthExpired(credential, nowMs) {
|
|
172
|
+
const expiryMs = Date.parse(credential.expiresAt);
|
|
173
|
+
if (Number.isNaN(expiryMs)) return true;
|
|
174
|
+
return expiryMs - EXPIRY_SKEW_MS <= nowMs;
|
|
205
175
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
let raw;
|
|
209
|
-
try {
|
|
210
|
-
raw = await promises.readFile(path, "utf8");
|
|
211
|
-
} catch (error) {
|
|
212
|
-
if (isNotFoundError(error)) {
|
|
213
|
-
await detectLegacyArtifacts();
|
|
214
|
-
return { profiles: [] };
|
|
215
|
-
}
|
|
216
|
-
throw error;
|
|
217
|
-
}
|
|
218
|
-
const parsed = parseJsonResult(raw, ProfilesFile, { source: path });
|
|
219
|
-
if (parsed.ok) return parsed.value;
|
|
220
|
-
if (parsed.error instanceof ValidationError) {
|
|
221
|
-
legacyWarningPending = true;
|
|
222
|
-
return { profiles: [] };
|
|
223
|
-
}
|
|
224
|
-
throw parsed.error;
|
|
176
|
+
function expiresAtFromNow(expiresInSeconds, nowMs) {
|
|
177
|
+
return new Date(nowMs + expiresInSeconds * 1e3).toISOString();
|
|
225
178
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
179
|
+
function oauthCredentialFromTokens(tokens, refreshToken, clientId, scope, nowMs) {
|
|
180
|
+
return {
|
|
181
|
+
kind: "oauth",
|
|
182
|
+
accessToken: tokens.access_token,
|
|
183
|
+
refreshToken,
|
|
184
|
+
expiresAt: expiresAtFromNow(tokens.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, nowMs),
|
|
185
|
+
clientId,
|
|
186
|
+
scope
|
|
187
|
+
};
|
|
231
188
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/core/http/sanitize.ts
|
|
192
|
+
const SECRET_HEADER_NAMES = new Set([
|
|
193
|
+
"authorization",
|
|
194
|
+
"x-api-key",
|
|
195
|
+
"x-metabase-session",
|
|
196
|
+
"cookie",
|
|
197
|
+
"set-cookie",
|
|
198
|
+
"proxy-authorization"
|
|
199
|
+
]);
|
|
200
|
+
const REDACTED = "[REDACTED]";
|
|
201
|
+
function redactHeaders(headers) {
|
|
202
|
+
const result = {};
|
|
203
|
+
const entries = headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
204
|
+
for (const [key, value] of entries) result[key] = SECRET_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value;
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
function redactBody(body, ctx) {
|
|
208
|
+
let result = body;
|
|
209
|
+
for (const secret of ctx.knownSecrets) {
|
|
210
|
+
if (secret.length === 0) continue;
|
|
211
|
+
result = result.replaceAll(secret, REDACTED);
|
|
238
212
|
}
|
|
213
|
+
return result;
|
|
239
214
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
215
|
+
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/core/http/errors.ts
|
|
218
|
+
const ROUTE_MISSING_LITERAL = "API endpoint does not exist.";
|
|
219
|
+
const RESOURCE_MISSING_LITERAL = "Not found.";
|
|
220
|
+
const STATUS_CLASSIFICATIONS = {
|
|
221
|
+
401: { retryable: false },
|
|
222
|
+
403: { retryable: false },
|
|
223
|
+
404: { retryable: false },
|
|
224
|
+
408: {
|
|
225
|
+
retryable: true,
|
|
226
|
+
message: "Metabase timed out responding."
|
|
227
|
+
},
|
|
228
|
+
425: { retryable: true },
|
|
229
|
+
429: {
|
|
230
|
+
retryable: true,
|
|
231
|
+
message: "Metabase rate-limited the request."
|
|
232
|
+
},
|
|
233
|
+
500: { retryable: true },
|
|
234
|
+
502: { retryable: true },
|
|
235
|
+
503: { retryable: true },
|
|
236
|
+
504: {
|
|
237
|
+
retryable: true,
|
|
238
|
+
message: "Metabase timed out responding."
|
|
246
239
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
240
|
+
};
|
|
241
|
+
const ErrorEnvelope = z.object({
|
|
242
|
+
message: z.string().optional(),
|
|
243
|
+
error: z.string().optional(),
|
|
244
|
+
"error-message": z.string().optional(),
|
|
245
|
+
via: z.array(z.object({ message: z.string().optional() }).loose()).optional(),
|
|
246
|
+
"specific-errors": z.unknown().optional(),
|
|
247
|
+
errors: z.unknown().optional()
|
|
248
|
+
}).loose();
|
|
249
|
+
const MAX_EXTRACTED_MESSAGE_LEN = 500;
|
|
250
|
+
const ELLIPSIS = "…";
|
|
251
|
+
var HttpError = class extends MetabaseError {
|
|
252
|
+
category = "http";
|
|
253
|
+
exitCode = 1;
|
|
254
|
+
status;
|
|
255
|
+
kind;
|
|
256
|
+
developerDetail;
|
|
257
|
+
constructor(input) {
|
|
258
|
+
const sanitizedBody = sanitizeBody(input.rawBody, input.redactionContext);
|
|
259
|
+
const redactedHeaders = redactHeaders(input.responseHeaders);
|
|
260
|
+
const kind = classifyKind(input.status, sanitizedBody, redactedHeaders);
|
|
261
|
+
super(input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody));
|
|
262
|
+
this.name = "HttpError";
|
|
263
|
+
this.status = input.status;
|
|
264
|
+
this.kind = kind;
|
|
265
|
+
this.developerDetail = {
|
|
266
|
+
status: input.status,
|
|
267
|
+
statusText: input.statusText,
|
|
268
|
+
method: input.method,
|
|
269
|
+
url: input.url,
|
|
270
|
+
responseHeaders: redactedHeaders,
|
|
271
|
+
body: sanitizedBody
|
|
272
|
+
};
|
|
259
273
|
}
|
|
260
|
-
|
|
274
|
+
get isRetryable() {
|
|
275
|
+
return isRetryableStatus(this.status);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
function isRetryableStatus(status) {
|
|
279
|
+
return STATUS_CLASSIFICATIONS[status]?.retryable === true;
|
|
261
280
|
}
|
|
262
|
-
|
|
263
|
-
|
|
281
|
+
function sanitizeBody(rawBody, ctx) {
|
|
282
|
+
if (rawBody === null) return null;
|
|
283
|
+
if (ctx === void 0) return rawBody;
|
|
284
|
+
return redactBody(rawBody, ctx);
|
|
264
285
|
}
|
|
265
|
-
function
|
|
266
|
-
|
|
286
|
+
function classifyKind(status, sanitizedBody, redactedHeaders) {
|
|
287
|
+
if (status === 401 || status === 403) return "auth";
|
|
288
|
+
if (status === 404) return isRouteMissingResponse(sanitizedBody, redactedHeaders) ? "route-missing" : "resource-missing";
|
|
289
|
+
if (status === 429) return "rate-limit";
|
|
290
|
+
if (status >= 500 && status < 600) return "server-error";
|
|
291
|
+
return "generic";
|
|
267
292
|
}
|
|
268
|
-
function
|
|
269
|
-
|
|
293
|
+
function isRouteMissingResponse(sanitizedBody, redactedHeaders) {
|
|
294
|
+
if (sanitizedBody?.includes(ROUTE_MISSING_LITERAL)) return true;
|
|
295
|
+
if (sanitizedBody?.includes(RESOURCE_MISSING_LITERAL)) return false;
|
|
296
|
+
if (redactedHeaders["content-type"]?.includes(JSON_CONTENT_TYPE)) return false;
|
|
297
|
+
if (sanitizedBody === null || sanitizedBody.trim() === "") return true;
|
|
298
|
+
return !parseJsonResult(sanitizedBody, ErrorEnvelope).ok;
|
|
270
299
|
}
|
|
271
|
-
function
|
|
272
|
-
if (
|
|
273
|
-
if (
|
|
300
|
+
function buildUserMessage(kind, input, sanitizedBody) {
|
|
301
|
+
if (kind === "route-missing") return buildRouteMissingMessage(input);
|
|
302
|
+
if (kind === "resource-missing") return `Not found: ${input.method} ${pathFromUrl(input.url)}.`;
|
|
303
|
+
const fromBody = parseEnvelopeMessage(sanitizedBody);
|
|
304
|
+
if (fromBody !== null) return fromBody;
|
|
305
|
+
if (kind === "auth") return `Invalid or unauthorized API key (host: ${hostFromUrl(input.url)}).`;
|
|
306
|
+
return defaultMessageForStatus(input.status);
|
|
274
307
|
}
|
|
275
|
-
function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
account: key,
|
|
280
|
-
reason: keyringEnabled() ? "unavailable" : "disabled"
|
|
281
|
-
};
|
|
308
|
+
function buildRouteMissingMessage(input) {
|
|
309
|
+
const path = pathFromUrl(input.url);
|
|
310
|
+
if (!input.serverTag) return `This endpoint is not available on the connected Metabase: ${input.method} ${path}.`;
|
|
311
|
+
return `This endpoint is not available on Metabase ${input.serverTag}: ${input.method} ${path}. The command may require a newer Metabase major version. Run 'mb auth list' to see this server's version.`;
|
|
282
312
|
}
|
|
283
|
-
function
|
|
284
|
-
const
|
|
285
|
-
return
|
|
313
|
+
function pathFromUrl(url) {
|
|
314
|
+
const parsed = new URL(url);
|
|
315
|
+
return parsed.pathname + parsed.search;
|
|
286
316
|
}
|
|
287
|
-
function
|
|
288
|
-
|
|
289
|
-
backend: "keyring",
|
|
290
|
-
service: KEYRING_SERVICE,
|
|
291
|
-
account: key
|
|
292
|
-
};
|
|
293
|
-
removeKeyringEntry(key);
|
|
294
|
-
return fileLocation(key);
|
|
317
|
+
function hostFromUrl(url) {
|
|
318
|
+
return new URL(url).host;
|
|
295
319
|
}
|
|
296
|
-
function
|
|
297
|
-
if (
|
|
298
|
-
|
|
320
|
+
function parseEnvelopeMessage(sanitizedBody) {
|
|
321
|
+
if (!sanitizedBody) return null;
|
|
322
|
+
const result = parseJsonResult(sanitizedBody, ErrorEnvelope);
|
|
323
|
+
if (!result.ok) return null;
|
|
324
|
+
const envelope = result.value;
|
|
325
|
+
const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"];
|
|
326
|
+
if (topLevel) return capLength(topLevel);
|
|
327
|
+
const viaMessage = envelope.via?.find((entry) => entry.message)?.message;
|
|
328
|
+
if (viaMessage) return capLength(viaMessage);
|
|
329
|
+
const specific = formatErrorTree(envelope["specific-errors"]);
|
|
330
|
+
if (specific) return capLength(specific);
|
|
331
|
+
const generic = formatErrorTree(envelope.errors);
|
|
332
|
+
if (generic) return capLength(generic);
|
|
333
|
+
return null;
|
|
299
334
|
}
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return resolveRecordCredential(record);
|
|
335
|
+
function formatErrorTree(value) {
|
|
336
|
+
const entries = collectLeafEntries(value, []);
|
|
337
|
+
if (entries.length === 0) return null;
|
|
338
|
+
return entries.map(formatLeafEntry).join("; ");
|
|
305
339
|
}
|
|
306
|
-
function
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
refreshToken,
|
|
317
|
-
expiresAt: record.oauth.expiresAt,
|
|
318
|
-
clientId: record.oauth.clientId
|
|
319
|
-
}
|
|
320
|
-
};
|
|
340
|
+
function formatLeafEntry(entry) {
|
|
341
|
+
return entry.path === "" ? entry.message : `${entry.path}: ${entry.message}`;
|
|
342
|
+
}
|
|
343
|
+
function collectLeafEntries(value, path) {
|
|
344
|
+
if (typeof value === "string") {
|
|
345
|
+
const trimmed = value.trim();
|
|
346
|
+
return trimmed === "" ? [] : [{
|
|
347
|
+
path: path.join("."),
|
|
348
|
+
message: trimmed
|
|
349
|
+
}];
|
|
321
350
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
351
|
+
if (Array.isArray(value)) {
|
|
352
|
+
const messages = value.filter((entry) => typeof entry === "string" && entry.trim() !== "");
|
|
353
|
+
if (messages.length === 0) return [];
|
|
354
|
+
return [{
|
|
355
|
+
path: path.join("."),
|
|
356
|
+
message: messages.join("; ")
|
|
357
|
+
}];
|
|
358
|
+
}
|
|
359
|
+
if (isPlainObject(value)) return Object.entries(value).flatMap(([key, child]) => collectLeafEntries(child, [...path, key]));
|
|
360
|
+
return [];
|
|
331
361
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return
|
|
362
|
+
function capLength(message) {
|
|
363
|
+
if (message.length <= MAX_EXTRACTED_MESSAGE_LEN) return message;
|
|
364
|
+
return message.slice(0, MAX_EXTRACTED_MESSAGE_LEN - ELLIPSIS.length) + ELLIPSIS;
|
|
335
365
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return file.profiles;
|
|
366
|
+
function defaultMessageForStatus(status) {
|
|
367
|
+
return STATUS_CLASSIFICATIONS[status]?.message ?? `Metabase returned ${status}.`;
|
|
339
368
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/core/http/network-error.ts
|
|
372
|
+
const NETWORK_HINTS = {
|
|
373
|
+
ECONNREFUSED: (t) => `Connection refused by ${t.host} — is Metabase running and is the port correct?`,
|
|
374
|
+
ENOTFOUND: (t) => `Host not found: ${t.hostname} — check the URL.`,
|
|
375
|
+
EAI_AGAIN: (t) => `Could not resolve ${t.hostname} — check your network connection and the URL.`,
|
|
376
|
+
ECONNRESET: (t) => `Connection to ${t.host} was reset — the server may have closed it, or http/https may be mismatched.`,
|
|
377
|
+
ETIMEDOUT: (t) => `Connection to ${t.host} timed out — check the host, port, and your network.`
|
|
378
|
+
};
|
|
379
|
+
const TLS_ERROR_CODES = new Set([
|
|
380
|
+
"EPROTO",
|
|
381
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
382
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
383
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
384
|
+
"CERT_HAS_EXPIRED",
|
|
385
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
386
|
+
]);
|
|
387
|
+
function buildNetworkError(error, method, url) {
|
|
388
|
+
const fallback = errorMessage(error);
|
|
389
|
+
const code = causeCode(error);
|
|
390
|
+
const target = networkTarget(url);
|
|
391
|
+
return new NetworkError(networkMessage(code, target, fallback), {
|
|
392
|
+
method,
|
|
393
|
+
url,
|
|
394
|
+
cause: code ?? fallback
|
|
346
395
|
});
|
|
347
396
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const updated = existing === null ? {
|
|
355
|
-
name,
|
|
356
|
-
url: profile.url,
|
|
357
|
-
apiKey: inlineApiKey,
|
|
358
|
-
oauth: null,
|
|
359
|
-
lastProbe: null,
|
|
360
|
-
lastFailure: null
|
|
361
|
-
} : {
|
|
362
|
-
...existing,
|
|
363
|
-
url: profile.url,
|
|
364
|
-
apiKey: inlineApiKey,
|
|
365
|
-
oauth: null
|
|
366
|
-
};
|
|
367
|
-
await upsertRecord(file, name, updated);
|
|
368
|
-
return location;
|
|
369
|
-
}
|
|
370
|
-
async function writeOAuthProfile(url, credential, name = DEFAULT_PROFILE) {
|
|
371
|
-
const accessKey = account.profileOAuthAccess(name);
|
|
372
|
-
const refreshKey = account.profileOAuthRefresh(name);
|
|
373
|
-
const accessLocation = persistSecret(accessKey, credential.accessToken);
|
|
374
|
-
const refreshLocation = persistSecret(refreshKey, credential.refreshToken);
|
|
375
|
-
const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file";
|
|
376
|
-
const oauth = {
|
|
377
|
-
accessToken: onFile ? credential.accessToken : null,
|
|
378
|
-
refreshToken: onFile ? credential.refreshToken : null,
|
|
379
|
-
expiresAt: credential.expiresAt,
|
|
380
|
-
clientId: credential.clientId
|
|
381
|
-
};
|
|
382
|
-
const file = await readProfilesFile();
|
|
383
|
-
const existing = findRecord(file, name);
|
|
384
|
-
if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey));
|
|
385
|
-
flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]);
|
|
386
|
-
const updated = existing === null ? {
|
|
387
|
-
name,
|
|
388
|
-
url,
|
|
389
|
-
apiKey: null,
|
|
390
|
-
oauth,
|
|
391
|
-
lastProbe: null,
|
|
392
|
-
lastFailure: null
|
|
393
|
-
} : {
|
|
394
|
-
...existing,
|
|
395
|
-
url,
|
|
396
|
-
apiKey: null,
|
|
397
|
-
oauth
|
|
398
|
-
};
|
|
399
|
-
await upsertRecord(file, name, updated);
|
|
400
|
-
return onFile ? fileLocation(accessKey) : {
|
|
401
|
-
backend: "keyring",
|
|
402
|
-
service: KEYRING_SERVICE,
|
|
403
|
-
account: accessKey
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
async function writeProbeResult(name, input) {
|
|
407
|
-
const probe = ProfileLastProbe.parse({
|
|
408
|
-
at: new Date().toISOString(),
|
|
409
|
-
version: input.server.version,
|
|
410
|
-
tokenFeatures: input.server.tokenFeatures,
|
|
411
|
-
user: input.user
|
|
412
|
-
});
|
|
413
|
-
const file = await readProfilesFile();
|
|
414
|
-
const existing = findRecord(file, name);
|
|
415
|
-
if (existing === null) return null;
|
|
416
|
-
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
417
|
-
...entry,
|
|
418
|
-
lastProbe: probe,
|
|
419
|
-
lastFailure: null
|
|
420
|
-
} : entry);
|
|
421
|
-
await writeProfilesFile({
|
|
422
|
-
...file,
|
|
423
|
-
profiles
|
|
424
|
-
});
|
|
425
|
-
return probe;
|
|
426
|
-
}
|
|
427
|
-
async function writeProbeFailure(name, input) {
|
|
428
|
-
const failure$1 = ProfileLastFailure.parse({
|
|
429
|
-
at: new Date().toISOString(),
|
|
430
|
-
kind: input.kind,
|
|
431
|
-
reason: input.reason
|
|
432
|
-
});
|
|
433
|
-
const file = await readProfilesFile();
|
|
434
|
-
const existing = findRecord(file, name);
|
|
435
|
-
if (existing === null) return null;
|
|
436
|
-
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
437
|
-
...entry,
|
|
438
|
-
lastFailure: failure$1
|
|
439
|
-
} : entry);
|
|
440
|
-
await writeProfilesFile({
|
|
441
|
-
...file,
|
|
442
|
-
profiles
|
|
443
|
-
});
|
|
444
|
-
return failure$1;
|
|
445
|
-
}
|
|
446
|
-
async function clearProfile(name = DEFAULT_PROFILE) {
|
|
447
|
-
const file = await readProfilesFile();
|
|
448
|
-
const existing = findRecord(file, name);
|
|
449
|
-
const removals = [
|
|
450
|
-
removeKeyringEntry(account.profileApiKey(name)),
|
|
451
|
-
removeKeyringEntry(account.profileOAuthAccess(name)),
|
|
452
|
-
removeKeyringEntry(account.profileOAuthRefresh(name))
|
|
453
|
-
];
|
|
454
|
-
if (existing !== null) flagResidualIfUnconfirmed(existing, existing.oauth !== null ? "oauth" : "apiKey", removals);
|
|
455
|
-
if (existing === null) return false;
|
|
456
|
-
await writeProfilesFile({
|
|
457
|
-
...file,
|
|
458
|
-
profiles: file.profiles.filter((entry) => entry.name !== name)
|
|
459
|
-
});
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
//#endregion
|
|
464
|
-
//#region src/runtime/signal.ts
|
|
465
|
-
function createProcessAbortHandler() {
|
|
466
|
-
const controller = new AbortController();
|
|
467
|
-
const handler = () => {
|
|
468
|
-
if (!controller.signal.aborted) controller.abort(new AbortError("interrupted"));
|
|
469
|
-
process.off("SIGINT", handler);
|
|
470
|
-
};
|
|
471
|
-
process.on("SIGINT", handler);
|
|
472
|
-
return {
|
|
473
|
-
signal: controller.signal,
|
|
474
|
-
uninstall: () => process.off("SIGINT", handler)
|
|
475
|
-
};
|
|
397
|
+
function networkMessage(code, target, fallback) {
|
|
398
|
+
if (code === null) return `Could not reach Metabase: ${fallback}`;
|
|
399
|
+
if (TLS_ERROR_CODES.has(code)) return `Could not reach Metabase: TLS error contacting ${target.host} (${code}) — the certificate could not be verified, or https:// was used against a plain-HTTP server.`;
|
|
400
|
+
const hint = NETWORK_HINTS[code];
|
|
401
|
+
if (hint === void 0) return `Could not reach Metabase: ${fallback} (${code})`;
|
|
402
|
+
return `Could not reach Metabase: ${hint(target)}`;
|
|
476
403
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (
|
|
480
|
-
return
|
|
404
|
+
function causeCode(error) {
|
|
405
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
406
|
+
if (cause instanceof Error && "code" in cause && typeof cause.code === "string") return cause.code;
|
|
407
|
+
return null;
|
|
481
408
|
}
|
|
482
|
-
function
|
|
483
|
-
const
|
|
484
|
-
const signals = [timeoutSignal, processSignal];
|
|
485
|
-
if (callerSignal) signals.push(callerSignal);
|
|
409
|
+
function networkTarget(url) {
|
|
410
|
+
const parsed = new URL(url);
|
|
486
411
|
return {
|
|
487
|
-
|
|
488
|
-
|
|
412
|
+
host: parsed.host,
|
|
413
|
+
hostname: parsed.hostname
|
|
489
414
|
};
|
|
490
415
|
}
|
|
491
|
-
function throwIfAborted(...signals) {
|
|
492
|
-
for (const signal of signals) if (signal?.aborted) throw abortReason(signal);
|
|
493
|
-
}
|
|
494
|
-
function abortReason(signal) {
|
|
495
|
-
const reason = signal.reason;
|
|
496
|
-
if (reason instanceof MetabaseError) return reason;
|
|
497
|
-
if (reason instanceof Error || typeof reason === "string") return new AbortError(errorMessage(reason) || "aborted");
|
|
498
|
-
return new AbortError("aborted");
|
|
499
|
-
}
|
|
500
416
|
|
|
501
417
|
//#endregion
|
|
502
|
-
//#region src/core/
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
value: credential.apiKey
|
|
512
|
-
};
|
|
513
|
-
return {
|
|
514
|
-
name: AUTHORIZATION_HEADER,
|
|
515
|
-
value: BEARER_PREFIX + credential.accessToken
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
function credentialSecrets(credential) {
|
|
519
|
-
if (credential.kind === "apiKey") return [credential.apiKey];
|
|
520
|
-
return [credential.accessToken, credential.refreshToken];
|
|
521
|
-
}
|
|
522
|
-
function isOAuthExpired(credential, nowMs) {
|
|
523
|
-
const expiryMs = Date.parse(credential.expiresAt);
|
|
524
|
-
if (Number.isNaN(expiryMs)) return true;
|
|
525
|
-
return expiryMs - EXPIRY_SKEW_MS <= nowMs;
|
|
526
|
-
}
|
|
527
|
-
function expiresAtFromNow(expiresInSeconds, nowMs) {
|
|
528
|
-
return new Date(nowMs + expiresInSeconds * 1e3).toISOString();
|
|
418
|
+
//#region src/core/http/retry.ts
|
|
419
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
420
|
+
const BASE_DELAY_MS = 250;
|
|
421
|
+
const MAX_DELAY_MS = 8e3;
|
|
422
|
+
const MS_PER_SECOND = 1e3;
|
|
423
|
+
function backoffDelay({ attempt, retryAfterHeader }) {
|
|
424
|
+
const fromHeader = parseRetryAfter(retryAfterHeader);
|
|
425
|
+
if (fromHeader !== null) return fromHeader;
|
|
426
|
+
return Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
529
427
|
}
|
|
530
|
-
function
|
|
531
|
-
return
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
};
|
|
428
|
+
function parseRetryAfter(header) {
|
|
429
|
+
if (!header) return null;
|
|
430
|
+
const numeric = Number(header);
|
|
431
|
+
if (Number.isFinite(numeric) && numeric >= 0) return Math.round(numeric * MS_PER_SECOND);
|
|
432
|
+
const dateMs = Date.parse(header);
|
|
433
|
+
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
434
|
+
return null;
|
|
538
435
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
//#region src/core/http/sanitize.ts
|
|
542
|
-
const SECRET_HEADER_NAMES = new Set([
|
|
543
|
-
"authorization",
|
|
544
|
-
"x-api-key",
|
|
545
|
-
"x-metabase-session",
|
|
546
|
-
"cookie",
|
|
547
|
-
"set-cookie",
|
|
548
|
-
"proxy-authorization"
|
|
549
|
-
]);
|
|
550
|
-
const REDACTED = "[REDACTED]";
|
|
551
|
-
function redactHeaders(headers) {
|
|
552
|
-
const result = {};
|
|
553
|
-
const entries = headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
554
|
-
for (const [key, value] of entries) result[key] = SECRET_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value;
|
|
555
|
-
return result;
|
|
436
|
+
function sleep(ms, signal) {
|
|
437
|
+
return setTimeout(ms, void 0, { signal });
|
|
556
438
|
}
|
|
557
|
-
function
|
|
558
|
-
let
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
//#endregion
|
|
567
|
-
//#region src/core/http/errors.ts
|
|
568
|
-
const ROUTE_MISSING_LITERAL = "API endpoint does not exist.";
|
|
569
|
-
const RESOURCE_MISSING_LITERAL = "Not found.";
|
|
570
|
-
const STATUS_CLASSIFICATIONS = {
|
|
571
|
-
401: { retryable: false },
|
|
572
|
-
403: { retryable: false },
|
|
573
|
-
404: { retryable: false },
|
|
574
|
-
408: {
|
|
575
|
-
retryable: true,
|
|
576
|
-
message: "Metabase timed out responding."
|
|
577
|
-
},
|
|
578
|
-
425: { retryable: true },
|
|
579
|
-
429: {
|
|
580
|
-
retryable: true,
|
|
581
|
-
message: "Metabase rate-limited the request."
|
|
582
|
-
},
|
|
583
|
-
500: { retryable: true },
|
|
584
|
-
502: { retryable: true },
|
|
585
|
-
503: { retryable: true },
|
|
586
|
-
504: {
|
|
587
|
-
retryable: true,
|
|
588
|
-
message: "Metabase timed out responding."
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
const ErrorEnvelope = z.object({
|
|
592
|
-
message: z.string().optional(),
|
|
593
|
-
error: z.string().optional(),
|
|
594
|
-
"error-message": z.string().optional(),
|
|
595
|
-
via: z.array(z.object({ message: z.string().optional() }).loose()).optional(),
|
|
596
|
-
"specific-errors": z.unknown().optional(),
|
|
597
|
-
errors: z.unknown().optional()
|
|
598
|
-
}).loose();
|
|
599
|
-
const MAX_EXTRACTED_MESSAGE_LEN = 500;
|
|
600
|
-
const ELLIPSIS = "…";
|
|
601
|
-
var HttpError = class extends MetabaseError {
|
|
602
|
-
category = "http";
|
|
603
|
-
exitCode = 1;
|
|
604
|
-
status;
|
|
605
|
-
kind;
|
|
606
|
-
developerDetail;
|
|
607
|
-
constructor(input) {
|
|
608
|
-
const sanitizedBody = sanitizeBody(input.rawBody, input.redactionContext);
|
|
609
|
-
const redactedHeaders = redactHeaders(input.responseHeaders);
|
|
610
|
-
const kind = classifyKind(input.status, sanitizedBody, redactedHeaders);
|
|
611
|
-
super(input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody));
|
|
612
|
-
this.name = "HttpError";
|
|
613
|
-
this.status = input.status;
|
|
614
|
-
this.kind = kind;
|
|
615
|
-
this.developerDetail = {
|
|
616
|
-
status: input.status,
|
|
617
|
-
statusText: input.statusText,
|
|
618
|
-
method: input.method,
|
|
619
|
-
url: input.url,
|
|
620
|
-
responseHeaders: redactedHeaders,
|
|
621
|
-
body: sanitizedBody
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
get isRetryable() {
|
|
625
|
-
return isRetryableStatus(this.status);
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
function isRetryableStatus(status) {
|
|
629
|
-
return STATUS_CLASSIFICATIONS[status]?.retryable === true;
|
|
630
|
-
}
|
|
631
|
-
function sanitizeBody(rawBody, ctx) {
|
|
632
|
-
if (rawBody === null) return null;
|
|
633
|
-
if (ctx === void 0) return rawBody;
|
|
634
|
-
return redactBody(rawBody, ctx);
|
|
635
|
-
}
|
|
636
|
-
function classifyKind(status, sanitizedBody, redactedHeaders) {
|
|
637
|
-
if (status === 401 || status === 403) return "auth";
|
|
638
|
-
if (status === 404) return isRouteMissingResponse(sanitizedBody, redactedHeaders) ? "route-missing" : "resource-missing";
|
|
639
|
-
if (status === 429) return "rate-limit";
|
|
640
|
-
if (status >= 500 && status < 600) return "server-error";
|
|
641
|
-
return "generic";
|
|
642
|
-
}
|
|
643
|
-
function isRouteMissingResponse(sanitizedBody, redactedHeaders) {
|
|
644
|
-
if (sanitizedBody?.includes(ROUTE_MISSING_LITERAL)) return true;
|
|
645
|
-
if (sanitizedBody?.includes(RESOURCE_MISSING_LITERAL)) return false;
|
|
646
|
-
if (redactedHeaders["content-type"]?.includes(JSON_CONTENT_TYPE)) return false;
|
|
647
|
-
if (sanitizedBody === null || sanitizedBody.trim() === "") return true;
|
|
648
|
-
return !parseJsonResult(sanitizedBody, ErrorEnvelope).ok;
|
|
649
|
-
}
|
|
650
|
-
function buildUserMessage(kind, input, sanitizedBody) {
|
|
651
|
-
if (kind === "route-missing") return buildRouteMissingMessage(input);
|
|
652
|
-
if (kind === "resource-missing") return `Not found: ${input.method} ${pathFromUrl(input.url)}.`;
|
|
653
|
-
const fromBody = parseEnvelopeMessage(sanitizedBody);
|
|
654
|
-
if (fromBody !== null) return fromBody;
|
|
655
|
-
if (kind === "auth") return `Invalid or unauthorized API key (host: ${hostFromUrl(input.url)}).`;
|
|
656
|
-
return defaultMessageForStatus(input.status);
|
|
657
|
-
}
|
|
658
|
-
function buildRouteMissingMessage(input) {
|
|
659
|
-
const path = pathFromUrl(input.url);
|
|
660
|
-
if (!input.serverTag) return `This endpoint is not available on the connected Metabase: ${input.method} ${path}.`;
|
|
661
|
-
return `This endpoint is not available on Metabase ${input.serverTag}: ${input.method} ${path}. The command may require a newer Metabase major version. Run 'mb auth list' to see this server's version.`;
|
|
662
|
-
}
|
|
663
|
-
function pathFromUrl(url) {
|
|
664
|
-
const parsed = new URL(url);
|
|
665
|
-
return parsed.pathname + parsed.search;
|
|
666
|
-
}
|
|
667
|
-
function hostFromUrl(url) {
|
|
668
|
-
return new URL(url).host;
|
|
669
|
-
}
|
|
670
|
-
function parseEnvelopeMessage(sanitizedBody) {
|
|
671
|
-
if (!sanitizedBody) return null;
|
|
672
|
-
const result = parseJsonResult(sanitizedBody, ErrorEnvelope);
|
|
673
|
-
if (!result.ok) return null;
|
|
674
|
-
const envelope = result.value;
|
|
675
|
-
const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"];
|
|
676
|
-
if (topLevel) return capLength(topLevel);
|
|
677
|
-
const viaMessage = envelope.via?.find((entry) => entry.message)?.message;
|
|
678
|
-
if (viaMessage) return capLength(viaMessage);
|
|
679
|
-
const specific = formatErrorTree(envelope["specific-errors"]);
|
|
680
|
-
if (specific) return capLength(specific);
|
|
681
|
-
const generic = formatErrorTree(envelope.errors);
|
|
682
|
-
if (generic) return capLength(generic);
|
|
683
|
-
return null;
|
|
684
|
-
}
|
|
685
|
-
function formatErrorTree(value) {
|
|
686
|
-
const entries = collectLeafEntries(value, []);
|
|
687
|
-
if (entries.length === 0) return null;
|
|
688
|
-
return entries.map(formatLeafEntry).join("; ");
|
|
689
|
-
}
|
|
690
|
-
function formatLeafEntry(entry) {
|
|
691
|
-
return entry.path === "" ? entry.message : `${entry.path}: ${entry.message}`;
|
|
692
|
-
}
|
|
693
|
-
function collectLeafEntries(value, path) {
|
|
694
|
-
if (typeof value === "string") {
|
|
695
|
-
const trimmed = value.trim();
|
|
696
|
-
return trimmed === "" ? [] : [{
|
|
697
|
-
path: path.join("."),
|
|
698
|
-
message: trimmed
|
|
699
|
-
}];
|
|
700
|
-
}
|
|
701
|
-
if (Array.isArray(value)) {
|
|
702
|
-
const messages = value.filter((entry) => typeof entry === "string" && entry.trim() !== "");
|
|
703
|
-
if (messages.length === 0) return [];
|
|
704
|
-
return [{
|
|
705
|
-
path: path.join("."),
|
|
706
|
-
message: messages.join("; ")
|
|
707
|
-
}];
|
|
708
|
-
}
|
|
709
|
-
if (isPlainObject(value)) return Object.entries(value).flatMap(([key, child]) => collectLeafEntries(child, [...path, key]));
|
|
710
|
-
return [];
|
|
711
|
-
}
|
|
712
|
-
function capLength(message) {
|
|
713
|
-
if (message.length <= MAX_EXTRACTED_MESSAGE_LEN) return message;
|
|
714
|
-
return message.slice(0, MAX_EXTRACTED_MESSAGE_LEN - ELLIPSIS.length) + ELLIPSIS;
|
|
715
|
-
}
|
|
716
|
-
function defaultMessageForStatus(status) {
|
|
717
|
-
return STATUS_CLASSIFICATIONS[status]?.message ?? `Metabase returned ${status}.`;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
//#endregion
|
|
721
|
-
//#region src/core/http/network-error.ts
|
|
722
|
-
const NETWORK_HINTS = {
|
|
723
|
-
ECONNREFUSED: (t) => `Connection refused by ${t.host} — is Metabase running and is the port correct?`,
|
|
724
|
-
ENOTFOUND: (t) => `Host not found: ${t.hostname} — check the URL.`,
|
|
725
|
-
EAI_AGAIN: (t) => `Could not resolve ${t.hostname} — check your network connection and the URL.`,
|
|
726
|
-
ECONNRESET: (t) => `Connection to ${t.host} was reset — the server may have closed it, or http/https may be mismatched.`,
|
|
727
|
-
ETIMEDOUT: (t) => `Connection to ${t.host} timed out — check the host, port, and your network.`
|
|
728
|
-
};
|
|
729
|
-
const TLS_ERROR_CODES = new Set([
|
|
730
|
-
"EPROTO",
|
|
731
|
-
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
732
|
-
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
733
|
-
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
734
|
-
"CERT_HAS_EXPIRED",
|
|
735
|
-
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
736
|
-
]);
|
|
737
|
-
function buildNetworkError(error, method, url) {
|
|
738
|
-
const fallback = errorMessage(error);
|
|
739
|
-
const code = causeCode(error);
|
|
740
|
-
const target = networkTarget(url);
|
|
741
|
-
return new NetworkError(networkMessage(code, target, fallback), {
|
|
742
|
-
method,
|
|
743
|
-
url,
|
|
744
|
-
cause: code ?? fallback
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
function networkMessage(code, target, fallback) {
|
|
748
|
-
if (code === null) return `Could not reach Metabase: ${fallback}`;
|
|
749
|
-
if (TLS_ERROR_CODES.has(code)) return `Could not reach Metabase: TLS error contacting ${target.host} (${code}) — the certificate could not be verified, or https:// was used against a plain-HTTP server.`;
|
|
750
|
-
const hint = NETWORK_HINTS[code];
|
|
751
|
-
if (hint === void 0) return `Could not reach Metabase: ${fallback} (${code})`;
|
|
752
|
-
return `Could not reach Metabase: ${hint(target)}`;
|
|
753
|
-
}
|
|
754
|
-
function causeCode(error) {
|
|
755
|
-
const cause = error instanceof Error ? error.cause : void 0;
|
|
756
|
-
if (cause instanceof Error && "code" in cause && typeof cause.code === "string") return cause.code;
|
|
757
|
-
return null;
|
|
758
|
-
}
|
|
759
|
-
function networkTarget(url) {
|
|
760
|
-
const parsed = new URL(url);
|
|
761
|
-
return {
|
|
762
|
-
host: parsed.host,
|
|
763
|
-
hostname: parsed.hostname
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
//#endregion
|
|
768
|
-
//#region src/core/http/retry.ts
|
|
769
|
-
const DEFAULT_MAX_RETRIES = 3;
|
|
770
|
-
const BASE_DELAY_MS = 250;
|
|
771
|
-
const MAX_DELAY_MS = 8e3;
|
|
772
|
-
const MS_PER_SECOND = 1e3;
|
|
773
|
-
function backoffDelay({ attempt, retryAfterHeader }) {
|
|
774
|
-
const fromHeader = parseRetryAfter(retryAfterHeader);
|
|
775
|
-
if (fromHeader !== null) return fromHeader;
|
|
776
|
-
return Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
777
|
-
}
|
|
778
|
-
function parseRetryAfter(header) {
|
|
779
|
-
if (!header) return null;
|
|
780
|
-
const numeric = Number(header);
|
|
781
|
-
if (Number.isFinite(numeric) && numeric >= 0) return Math.round(numeric * MS_PER_SECOND);
|
|
782
|
-
const dateMs = Date.parse(header);
|
|
783
|
-
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
784
|
-
return null;
|
|
785
|
-
}
|
|
786
|
-
function sleep(ms, signal) {
|
|
787
|
-
return setTimeout(ms, void 0, { signal });
|
|
788
|
-
}
|
|
789
|
-
async function runWithRetries(attempt, signal) {
|
|
790
|
-
let attemptIndex = 0;
|
|
791
|
-
while (true) {
|
|
792
|
-
const outcome = await attempt(attemptIndex);
|
|
793
|
-
if (outcome.kind === "success") return outcome.response;
|
|
794
|
-
await sleep(outcome.delayMs, signal);
|
|
795
|
-
attemptIndex += 1;
|
|
439
|
+
async function runWithRetries(attempt, signal) {
|
|
440
|
+
let attemptIndex = 0;
|
|
441
|
+
while (true) {
|
|
442
|
+
const outcome = await attempt(attemptIndex);
|
|
443
|
+
if (outcome.kind === "success") return outcome.response;
|
|
444
|
+
await sleep(outcome.delayMs, signal);
|
|
445
|
+
attemptIndex += 1;
|
|
796
446
|
}
|
|
797
447
|
}
|
|
798
448
|
|
|
@@ -992,38 +642,612 @@ function buildUrl(baseUrl, path, query) {
|
|
|
992
642
|
}
|
|
993
643
|
return target.toString();
|
|
994
644
|
}
|
|
995
|
-
function acceptHeader(expected) {
|
|
996
|
-
if (expected === "json") return JSON_CONTENT_TYPE;
|
|
997
|
-
if (expected === "text") return "text/*";
|
|
998
|
-
return "*/*";
|
|
645
|
+
function acceptHeader(expected) {
|
|
646
|
+
if (expected === "json") return JSON_CONTENT_TYPE;
|
|
647
|
+
if (expected === "text") return "text/*";
|
|
648
|
+
return "*/*";
|
|
649
|
+
}
|
|
650
|
+
function assertContentType(response, prepared) {
|
|
651
|
+
if (prepared.expectContentType === "binary") return;
|
|
652
|
+
const contentType = response.headers.get("content-type");
|
|
653
|
+
if (contentType === null) throwContentTypeMismatch(response, prepared, prepared.expectContentType);
|
|
654
|
+
if (prepared.expectContentType === "json" && !contentType.includes(JSON_CONTENT_TYPE)) throwContentTypeMismatch(response, prepared, "json");
|
|
655
|
+
if (prepared.expectContentType === "text" && !contentType.startsWith(TEXT_CONTENT_TYPE_PREFIX)) throwContentTypeMismatch(response, prepared, "text");
|
|
656
|
+
}
|
|
657
|
+
function throwContentTypeMismatch(response, prepared, expected) {
|
|
658
|
+
const actual = response.headers.get("content-type") ?? "no content-type";
|
|
659
|
+
throw new HttpError({
|
|
660
|
+
status: response.status,
|
|
661
|
+
statusText: response.statusText,
|
|
662
|
+
method: prepared.method,
|
|
663
|
+
url: prepared.url,
|
|
664
|
+
responseHeaders: response.headers,
|
|
665
|
+
rawBody: null,
|
|
666
|
+
overrideUserMessage: `Expected ${expected} response but got ${actual}`
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
async function readBodyForError(response) {
|
|
670
|
+
try {
|
|
671
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
672
|
+
return buffer.subarray(0, ERROR_BODY_BYTE_CAP).toString("utf8");
|
|
673
|
+
} catch (error) {
|
|
674
|
+
return `[body read failed: ${errorMessage(error)}]`;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/core/http/oauth.ts
|
|
680
|
+
const DISCOVERY_PATH = "/.well-known/oauth-authorization-server";
|
|
681
|
+
const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
682
|
+
const OAUTH_TIMEOUT_MS = 3e4;
|
|
683
|
+
async function oauthFetch(request) {
|
|
684
|
+
const timeoutSignal = AbortSignal.timeout(OAUTH_TIMEOUT_MS);
|
|
685
|
+
try {
|
|
686
|
+
return await fetch(request.url, {
|
|
687
|
+
method: request.method,
|
|
688
|
+
headers: {
|
|
689
|
+
...request.headers,
|
|
690
|
+
"user-agent": USER_AGENT
|
|
691
|
+
},
|
|
692
|
+
...request.body !== void 0 ? { body: request.body } : {},
|
|
693
|
+
signal: timeoutSignal
|
|
694
|
+
});
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (timeoutSignal.aborted) throw new TimeoutError(`OAuth request timed out after ${OAUTH_TIMEOUT_MS}ms`, {
|
|
697
|
+
kind: "http",
|
|
698
|
+
method: request.method,
|
|
699
|
+
url: request.url,
|
|
700
|
+
timeoutMs: OAUTH_TIMEOUT_MS
|
|
701
|
+
});
|
|
702
|
+
throw buildNetworkError(error, request.method, request.url);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const OAUTH_SCOPE = "mb:full";
|
|
706
|
+
const WORKSPACE_MANAGER_SCOPE = "mb:workspace-manager";
|
|
707
|
+
const OAuthServerMetadata = z.object({
|
|
708
|
+
issuer: z.string(),
|
|
709
|
+
authorization_endpoint: z.string(),
|
|
710
|
+
token_endpoint: z.string(),
|
|
711
|
+
registration_endpoint: z.string().optional(),
|
|
712
|
+
revocation_endpoint: z.string().optional(),
|
|
713
|
+
scopes_supported: z.array(z.string()).optional()
|
|
714
|
+
}).loose();
|
|
715
|
+
const RegisteredClient = z.object({ client_id: z.string() }).loose();
|
|
716
|
+
const OAuthTokens = z.object({
|
|
717
|
+
access_token: z.string(),
|
|
718
|
+
token_type: z.string(),
|
|
719
|
+
expires_in: z.number().int().optional(),
|
|
720
|
+
refresh_token: z.string().optional()
|
|
721
|
+
}).loose();
|
|
722
|
+
const OAuthErrorBody = z.object({
|
|
723
|
+
error: z.string(),
|
|
724
|
+
error_description: z.string().optional()
|
|
725
|
+
}).loose();
|
|
726
|
+
async function readJson(response, schema, source) {
|
|
727
|
+
let raw;
|
|
728
|
+
try {
|
|
729
|
+
raw = await response.json();
|
|
730
|
+
} catch (error) {
|
|
731
|
+
throw new ConfigError(`${source}: invalid JSON response (${errorMessage(error)})`);
|
|
732
|
+
}
|
|
733
|
+
const parsed = schema.safeParse(raw);
|
|
734
|
+
if (!parsed.success) throw new ConfigError(`${source}: unexpected response shape`);
|
|
735
|
+
return parsed.data;
|
|
736
|
+
}
|
|
737
|
+
async function failure(response, source, method, url) {
|
|
738
|
+
const rawBody = await response.text().catch(() => "");
|
|
739
|
+
if (isRetryableStatus(response.status)) return new HttpError({
|
|
740
|
+
status: response.status,
|
|
741
|
+
statusText: response.statusText,
|
|
742
|
+
method,
|
|
743
|
+
url,
|
|
744
|
+
responseHeaders: response.headers,
|
|
745
|
+
rawBody
|
|
746
|
+
});
|
|
747
|
+
const parsed = parseJsonResult(rawBody, OAuthErrorBody);
|
|
748
|
+
if (parsed.ok) {
|
|
749
|
+
const detail = parsed.value.error_description ?? parsed.value.error;
|
|
750
|
+
return new ConfigError(`${source} failed (${response.status}): ${detail}`);
|
|
751
|
+
}
|
|
752
|
+
return new ConfigError(`${source} failed (${response.status})`);
|
|
753
|
+
}
|
|
754
|
+
async function postForm(url, params, schema, source) {
|
|
755
|
+
const response = await oauthFetch({
|
|
756
|
+
url,
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers: {
|
|
759
|
+
"content-type": FORM_CONTENT_TYPE,
|
|
760
|
+
accept: JSON_CONTENT_TYPE
|
|
761
|
+
},
|
|
762
|
+
body: new URLSearchParams(params)
|
|
763
|
+
});
|
|
764
|
+
if (!response.ok) throw await failure(response, source, "POST", url);
|
|
765
|
+
return readJson(response, schema, source);
|
|
766
|
+
}
|
|
767
|
+
const OAUTH_UNSUPPORTED_MESSAGE = "this Metabase does not support OAuth login (requires Metabase v63 or newer)";
|
|
768
|
+
async function tryDiscoverMetadata(baseUrl, requiredScope = OAUTH_SCOPE) {
|
|
769
|
+
const url = `${baseUrl}${DISCOVERY_PATH}`;
|
|
770
|
+
const response = await oauthFetch({
|
|
771
|
+
url,
|
|
772
|
+
method: "GET",
|
|
773
|
+
headers: { accept: JSON_CONTENT_TYPE }
|
|
774
|
+
});
|
|
775
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
776
|
+
if (!response.ok || !contentType.includes("json")) {
|
|
777
|
+
await response.body?.cancel().catch(() => void 0);
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
const metadata = await readJson(response, OAuthServerMetadata, "OAuth discovery");
|
|
781
|
+
const scopes = metadata.scopes_supported;
|
|
782
|
+
if (scopes !== void 0 && !scopes.includes(requiredScope)) return null;
|
|
783
|
+
assertEndpointOrigin(metadata.issuer, baseUrl, "issuer");
|
|
784
|
+
assertEndpointOrigin(metadata.authorization_endpoint, baseUrl, "authorization endpoint");
|
|
785
|
+
assertEndpointOrigin(metadata.token_endpoint, baseUrl, "token endpoint");
|
|
786
|
+
if (metadata.registration_endpoint !== void 0) assertEndpointOrigin(metadata.registration_endpoint, baseUrl, "registration endpoint");
|
|
787
|
+
if (metadata.revocation_endpoint !== void 0) assertEndpointOrigin(metadata.revocation_endpoint, baseUrl, "revocation endpoint");
|
|
788
|
+
return metadata;
|
|
789
|
+
}
|
|
790
|
+
async function discoverMetadata(baseUrl, requiredScope = OAUTH_SCOPE) {
|
|
791
|
+
const metadata = await tryDiscoverMetadata(baseUrl, requiredScope);
|
|
792
|
+
if (metadata === null) throw new ConfigError(OAUTH_UNSUPPORTED_MESSAGE);
|
|
793
|
+
return metadata;
|
|
794
|
+
}
|
|
795
|
+
async function registerClient(input) {
|
|
796
|
+
const response = await oauthFetch({
|
|
797
|
+
url: input.registrationEndpoint,
|
|
798
|
+
method: "POST",
|
|
799
|
+
headers: {
|
|
800
|
+
"content-type": JSON_CONTENT_TYPE,
|
|
801
|
+
accept: JSON_CONTENT_TYPE
|
|
802
|
+
},
|
|
803
|
+
body: JSON.stringify({
|
|
804
|
+
application_type: "native",
|
|
805
|
+
client_name: input.clientName,
|
|
806
|
+
redirect_uris: [input.redirectUri],
|
|
807
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
808
|
+
response_types: ["code"],
|
|
809
|
+
token_endpoint_auth_method: "none",
|
|
810
|
+
scope: input.scope
|
|
811
|
+
})
|
|
812
|
+
});
|
|
813
|
+
if (!response.ok) throw await failure(response, "OAuth client registration", "POST", input.registrationEndpoint);
|
|
814
|
+
return readJson(response, RegisteredClient, "OAuth client registration");
|
|
815
|
+
}
|
|
816
|
+
function exchangeCode(input) {
|
|
817
|
+
return postForm(input.tokenEndpoint, {
|
|
818
|
+
grant_type: "authorization_code",
|
|
819
|
+
code: input.code,
|
|
820
|
+
redirect_uri: input.redirectUri,
|
|
821
|
+
client_id: input.clientId,
|
|
822
|
+
code_verifier: input.codeVerifier
|
|
823
|
+
}, OAuthTokens, "OAuth token exchange");
|
|
824
|
+
}
|
|
825
|
+
function refreshTokens(input) {
|
|
826
|
+
return postForm(input.tokenEndpoint, {
|
|
827
|
+
grant_type: "refresh_token",
|
|
828
|
+
refresh_token: input.refreshToken,
|
|
829
|
+
client_id: input.clientId
|
|
830
|
+
}, OAuthTokens, "OAuth token refresh");
|
|
831
|
+
}
|
|
832
|
+
async function revokeToken(input) {
|
|
833
|
+
const response = await oauthFetch({
|
|
834
|
+
url: input.revocationEndpoint,
|
|
835
|
+
method: "POST",
|
|
836
|
+
headers: { "content-type": FORM_CONTENT_TYPE },
|
|
837
|
+
body: new URLSearchParams({
|
|
838
|
+
token: input.token,
|
|
839
|
+
client_id: input.clientId
|
|
840
|
+
})
|
|
841
|
+
});
|
|
842
|
+
if (!response.ok) throw await failure(response, "OAuth token revocation", "POST", input.revocationEndpoint);
|
|
843
|
+
await response.body?.cancel().catch(() => void 0);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/core/version/tag.ts
|
|
848
|
+
const ParsedVersionSchema = z.object({
|
|
849
|
+
tag: z.string(),
|
|
850
|
+
major: z.number().int().nonnegative(),
|
|
851
|
+
patch: z.number().int().nonnegative()
|
|
852
|
+
});
|
|
853
|
+
function tryParseTag(tag) {
|
|
854
|
+
const parsed = parse(tag);
|
|
855
|
+
if (parsed === null || parsed.major !== 0 && parsed.major !== 1) return null;
|
|
856
|
+
return {
|
|
857
|
+
tag,
|
|
858
|
+
major: parsed.minor,
|
|
859
|
+
patch: parsed.patch
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/core/auth/profile-record.ts
|
|
865
|
+
const ProbedUser = z.object({
|
|
866
|
+
id: z.number().int(),
|
|
867
|
+
name: z.string(),
|
|
868
|
+
isAdmin: z.boolean()
|
|
869
|
+
});
|
|
870
|
+
const ProfileLastProbe = z.object({
|
|
871
|
+
at: z.iso.datetime(),
|
|
872
|
+
version: ParsedVersionSchema.nullable(),
|
|
873
|
+
tokenFeatures: TokenFeatures.nullable(),
|
|
874
|
+
user: ProbedUser
|
|
875
|
+
});
|
|
876
|
+
const ProfileFailureKind = z.enum([
|
|
877
|
+
"auth",
|
|
878
|
+
"network",
|
|
879
|
+
"server"
|
|
880
|
+
]);
|
|
881
|
+
const ProfileLastFailure = z.object({
|
|
882
|
+
at: z.iso.datetime(),
|
|
883
|
+
kind: ProfileFailureKind,
|
|
884
|
+
reason: z.string()
|
|
885
|
+
});
|
|
886
|
+
const ProfileOAuth = z.object({
|
|
887
|
+
accessToken: z.string().nullable(),
|
|
888
|
+
refreshToken: z.string().nullable(),
|
|
889
|
+
expiresAt: z.iso.datetime(),
|
|
890
|
+
clientId: z.string(),
|
|
891
|
+
scope: z.string().default(OAUTH_SCOPE)
|
|
892
|
+
}).loose();
|
|
893
|
+
const ProfileAuthMethod = z.enum(["oauth", "apiKey"]);
|
|
894
|
+
const ProfileRecord = z.object({
|
|
895
|
+
name: z.string(),
|
|
896
|
+
url: z.string(),
|
|
897
|
+
apiKey: z.string().nullable(),
|
|
898
|
+
oauth: ProfileOAuth.nullable().default(null),
|
|
899
|
+
lastProbe: ProfileLastProbe.nullable(),
|
|
900
|
+
lastFailure: ProfileLastFailure.nullable()
|
|
901
|
+
}).loose();
|
|
902
|
+
const ProfilesFile = z.object({
|
|
903
|
+
profiles: z.array(ProfileRecord),
|
|
904
|
+
defaultProfile: z.string().nullable().default(null)
|
|
905
|
+
}).loose();
|
|
906
|
+
function profileAuthMethod(record) {
|
|
907
|
+
return record.oauth !== null ? "oauth" : "apiKey";
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
//#endregion
|
|
911
|
+
//#region src/core/auth/storage.ts
|
|
912
|
+
const KEYRING_SERVICE = "metabase-cli";
|
|
913
|
+
const PROFILES_FILE = "profiles.json";
|
|
914
|
+
const LEGACY_CREDENTIALS_FILE = "credentials.json";
|
|
915
|
+
const LEGACY_REJECTIONS_FILE = "rejections.json";
|
|
916
|
+
const PROFILES_FILE_MODE = 384;
|
|
917
|
+
const PROFILES_DIR_MODE = 448;
|
|
918
|
+
const DEFAULT_PROFILE = "default";
|
|
919
|
+
const LEGACY_STORAGE_NOTICE = "Old profile storage detected and ignored; re-run `mb auth login` for each profile.";
|
|
920
|
+
const KEYCHAIN_RESIDUAL_NOTICE = "warning: could not remove one or more secrets from the OS keychain; a stored token may remain — remove it manually or retry.";
|
|
921
|
+
const account = {
|
|
922
|
+
profileApiKey: (profile) => `profile:${profile}:apiKey`,
|
|
923
|
+
profileOAuthAccess: (profile) => `profile:${profile}:oauthAccess`,
|
|
924
|
+
profileOAuthRefresh: (profile) => `profile:${profile}:oauthRefresh`
|
|
925
|
+
};
|
|
926
|
+
let legacyWarningPending = false;
|
|
927
|
+
let keychainResidualPending = false;
|
|
928
|
+
let keyringDowngradeNotice = null;
|
|
929
|
+
function profilesFilePath() {
|
|
930
|
+
return join(configDir(), PROFILES_FILE);
|
|
931
|
+
}
|
|
932
|
+
function consumeLegacyStorageWarning() {
|
|
933
|
+
if (!legacyWarningPending) return null;
|
|
934
|
+
legacyWarningPending = false;
|
|
935
|
+
return LEGACY_STORAGE_NOTICE;
|
|
936
|
+
}
|
|
937
|
+
function consumeKeychainResidualWarning() {
|
|
938
|
+
if (!keychainResidualPending) return null;
|
|
939
|
+
keychainResidualPending = false;
|
|
940
|
+
return KEYCHAIN_RESIDUAL_NOTICE;
|
|
941
|
+
}
|
|
942
|
+
function consumeKeyringDowngradeWarning() {
|
|
943
|
+
const message = keyringDowngradeNotice;
|
|
944
|
+
keyringDowngradeNotice = null;
|
|
945
|
+
return message;
|
|
946
|
+
}
|
|
947
|
+
function keyringEnabled() {
|
|
948
|
+
return readEnv(ENV_DISABLE_KEYRING) !== "1";
|
|
949
|
+
}
|
|
950
|
+
function trySetKeyring(key, value) {
|
|
951
|
+
if (!keyringEnabled()) return false;
|
|
952
|
+
try {
|
|
953
|
+
new Entry(KEYRING_SERVICE, key).setPassword(value);
|
|
954
|
+
return true;
|
|
955
|
+
} catch {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function tryReadKeyring(key) {
|
|
960
|
+
if (!keyringEnabled()) return void 0;
|
|
961
|
+
try {
|
|
962
|
+
return new Entry(KEYRING_SERVICE, key).getPassword();
|
|
963
|
+
} catch {
|
|
964
|
+
return void 0;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function removeKeyringEntry(key) {
|
|
968
|
+
if (!keyringEnabled()) return "skipped";
|
|
969
|
+
try {
|
|
970
|
+
return new Entry(KEYRING_SERVICE, key).deletePassword() ? "removed" : "absent";
|
|
971
|
+
} catch {
|
|
972
|
+
return "failed";
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
async function readProfilesFile() {
|
|
976
|
+
const path = profilesFilePath();
|
|
977
|
+
let raw;
|
|
978
|
+
try {
|
|
979
|
+
raw = await promises.readFile(path, "utf8");
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (isNotFoundError(error)) {
|
|
982
|
+
await detectLegacyArtifacts();
|
|
983
|
+
return {
|
|
984
|
+
profiles: [],
|
|
985
|
+
defaultProfile: null
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
throw error;
|
|
989
|
+
}
|
|
990
|
+
const parsed = parseJsonResult(raw, ProfilesFile, { source: path });
|
|
991
|
+
if (parsed.ok) return parsed.value;
|
|
992
|
+
if (parsed.error instanceof ValidationError) {
|
|
993
|
+
legacyWarningPending = true;
|
|
994
|
+
return {
|
|
995
|
+
profiles: [],
|
|
996
|
+
defaultProfile: null
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
throw parsed.error;
|
|
1000
|
+
}
|
|
1001
|
+
async function detectLegacyArtifacts() {
|
|
1002
|
+
const legacyCredentials = join(configDir(), LEGACY_CREDENTIALS_FILE);
|
|
1003
|
+
const legacyRejections = join(configDir(), LEGACY_REJECTIONS_FILE);
|
|
1004
|
+
const [credentialsExists, rejectionsExists] = await Promise.all([fileExists(legacyCredentials), fileExists(legacyRejections)]);
|
|
1005
|
+
if (credentialsExists || rejectionsExists) legacyWarningPending = true;
|
|
1006
|
+
}
|
|
1007
|
+
async function fileExists(path) {
|
|
1008
|
+
try {
|
|
1009
|
+
await promises.access(path);
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
async function writeProfilesFile(file) {
|
|
1016
|
+
const path = profilesFilePath();
|
|
1017
|
+
if (file.profiles.length === 0) {
|
|
1018
|
+
await promises.unlink(path).catch(() => void 0);
|
|
1019
|
+
await cleanupLegacyFiles();
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
await promises.mkdir(dirname(path), {
|
|
1023
|
+
recursive: true,
|
|
1024
|
+
mode: PROFILES_DIR_MODE
|
|
1025
|
+
});
|
|
1026
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
1027
|
+
await promises.writeFile(tmpPath, JSON.stringify(file, null, 2) + "\n", { mode: PROFILES_FILE_MODE });
|
|
1028
|
+
if (process.platform !== "win32") await promises.chmod(tmpPath, PROFILES_FILE_MODE);
|
|
1029
|
+
try {
|
|
1030
|
+
await promises.rename(tmpPath, path);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
await promises.unlink(tmpPath).catch(() => void 0);
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
await cleanupLegacyFiles();
|
|
1036
|
+
}
|
|
1037
|
+
async function cleanupLegacyFiles() {
|
|
1038
|
+
await Promise.all([promises.unlink(join(configDir(), LEGACY_CREDENTIALS_FILE)).catch(() => void 0), promises.unlink(join(configDir(), LEGACY_REJECTIONS_FILE)).catch(() => void 0)]);
|
|
1039
|
+
}
|
|
1040
|
+
function findRecord(file, name) {
|
|
1041
|
+
return file.profiles.find((entry) => entry.name === name) ?? null;
|
|
1042
|
+
}
|
|
1043
|
+
function sideIsKeyringBacked(record, side) {
|
|
1044
|
+
return side === "oauth" ? record.oauth !== null && record.oauth.accessToken === null : record.oauth === null && record.apiKey === null;
|
|
1045
|
+
}
|
|
1046
|
+
function flagResidualIfUnconfirmed(existing, cleared, removals) {
|
|
1047
|
+
if (existing === null || !removals.includes("failed")) return;
|
|
1048
|
+
if (sideIsKeyringBacked(existing, cleared)) keychainResidualPending = true;
|
|
1049
|
+
}
|
|
1050
|
+
function fileLocation(key) {
|
|
1051
|
+
return {
|
|
1052
|
+
backend: "file",
|
|
1053
|
+
path: profilesFilePath(),
|
|
1054
|
+
account: key,
|
|
1055
|
+
reason: keyringEnabled() ? "unavailable" : "disabled"
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function keyringFallbackWarning(location) {
|
|
1059
|
+
const cause = location.reason === "disabled" ? `OS keychain disabled via ${ENV_DISABLE_KEYRING}` : "OS keychain unavailable";
|
|
1060
|
+
return `warning: ${cause}; credentials stored as plaintext at ${location.path}`;
|
|
1061
|
+
}
|
|
1062
|
+
function persistSecret(key, value) {
|
|
1063
|
+
if (trySetKeyring(key, value)) return {
|
|
1064
|
+
backend: "keyring",
|
|
1065
|
+
service: KEYRING_SERVICE,
|
|
1066
|
+
account: key
|
|
1067
|
+
};
|
|
1068
|
+
removeKeyringEntry(key);
|
|
1069
|
+
return fileLocation(key);
|
|
1070
|
+
}
|
|
1071
|
+
function resolveSecret(key, inline) {
|
|
1072
|
+
if (inline !== null) return inline;
|
|
1073
|
+
return tryReadKeyring(key) ?? null;
|
|
1074
|
+
}
|
|
1075
|
+
async function readProfileCredential(name = DEFAULT_PROFILE) {
|
|
1076
|
+
const file = await readProfilesFile();
|
|
1077
|
+
const record = findRecord(file, name);
|
|
1078
|
+
if (record === null) return null;
|
|
1079
|
+
return resolveRecordCredential(record);
|
|
1080
|
+
}
|
|
1081
|
+
function resolveRecordCredential(record) {
|
|
1082
|
+
if (record.oauth !== null) {
|
|
1083
|
+
const accessToken = resolveSecret(account.profileOAuthAccess(record.name), record.oauth.accessToken);
|
|
1084
|
+
const refreshToken = resolveSecret(account.profileOAuthRefresh(record.name), record.oauth.refreshToken);
|
|
1085
|
+
if (accessToken === null || refreshToken === null) return null;
|
|
1086
|
+
return {
|
|
1087
|
+
url: record.url,
|
|
1088
|
+
credential: {
|
|
1089
|
+
kind: "oauth",
|
|
1090
|
+
accessToken,
|
|
1091
|
+
refreshToken,
|
|
1092
|
+
expiresAt: record.oauth.expiresAt,
|
|
1093
|
+
clientId: record.oauth.clientId,
|
|
1094
|
+
scope: record.oauth.scope
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const apiKey = resolveSecret(account.profileApiKey(record.name), record.apiKey);
|
|
1099
|
+
if (apiKey === null) return null;
|
|
1100
|
+
return {
|
|
1101
|
+
url: record.url,
|
|
1102
|
+
credential: {
|
|
1103
|
+
kind: "apiKey",
|
|
1104
|
+
apiKey
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
async function readProfileRecord(name = DEFAULT_PROFILE) {
|
|
1109
|
+
const file = await readProfilesFile();
|
|
1110
|
+
return findRecord(file, name);
|
|
1111
|
+
}
|
|
1112
|
+
async function listProfileRecords() {
|
|
1113
|
+
const file = await readProfilesFile();
|
|
1114
|
+
return file.profiles;
|
|
1115
|
+
}
|
|
1116
|
+
async function upsertRecord(file, name, updated) {
|
|
1117
|
+
const exists = findRecord(file, name) !== null;
|
|
1118
|
+
const profiles = exists ? file.profiles.map((entry) => entry.name === name ? updated : entry) : [...file.profiles, updated];
|
|
1119
|
+
await writeProfilesFile({
|
|
1120
|
+
...file,
|
|
1121
|
+
profiles
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
async function writeProfile(profile, name = DEFAULT_PROFILE) {
|
|
1125
|
+
const location = persistSecret(account.profileApiKey(name), profile.apiKey);
|
|
1126
|
+
const inlineApiKey = location.backend === "file" ? profile.apiKey : null;
|
|
1127
|
+
const file = await readProfilesFile();
|
|
1128
|
+
const existing = findRecord(file, name);
|
|
1129
|
+
flagResidualIfUnconfirmed(existing, "oauth", [removeKeyringEntry(account.profileOAuthAccess(name)), removeKeyringEntry(account.profileOAuthRefresh(name))]);
|
|
1130
|
+
const updated = existing === null ? {
|
|
1131
|
+
name,
|
|
1132
|
+
url: profile.url,
|
|
1133
|
+
apiKey: inlineApiKey,
|
|
1134
|
+
oauth: null,
|
|
1135
|
+
lastProbe: null,
|
|
1136
|
+
lastFailure: null
|
|
1137
|
+
} : {
|
|
1138
|
+
...existing,
|
|
1139
|
+
url: profile.url,
|
|
1140
|
+
apiKey: inlineApiKey,
|
|
1141
|
+
oauth: null
|
|
1142
|
+
};
|
|
1143
|
+
await upsertRecord(file, name, updated);
|
|
1144
|
+
return location;
|
|
1145
|
+
}
|
|
1146
|
+
async function writeOAuthProfile(url, credential, name = DEFAULT_PROFILE) {
|
|
1147
|
+
const accessKey = account.profileOAuthAccess(name);
|
|
1148
|
+
const refreshKey = account.profileOAuthRefresh(name);
|
|
1149
|
+
const accessLocation = persistSecret(accessKey, credential.accessToken);
|
|
1150
|
+
const refreshLocation = persistSecret(refreshKey, credential.refreshToken);
|
|
1151
|
+
const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file";
|
|
1152
|
+
const oauth = {
|
|
1153
|
+
accessToken: onFile ? credential.accessToken : null,
|
|
1154
|
+
refreshToken: onFile ? credential.refreshToken : null,
|
|
1155
|
+
expiresAt: credential.expiresAt,
|
|
1156
|
+
clientId: credential.clientId,
|
|
1157
|
+
scope: credential.scope
|
|
1158
|
+
};
|
|
1159
|
+
const file = await readProfilesFile();
|
|
1160
|
+
const existing = findRecord(file, name);
|
|
1161
|
+
if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey));
|
|
1162
|
+
flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]);
|
|
1163
|
+
const updated = existing === null ? {
|
|
1164
|
+
name,
|
|
1165
|
+
url,
|
|
1166
|
+
apiKey: null,
|
|
1167
|
+
oauth,
|
|
1168
|
+
lastProbe: null,
|
|
1169
|
+
lastFailure: null
|
|
1170
|
+
} : {
|
|
1171
|
+
...existing,
|
|
1172
|
+
url,
|
|
1173
|
+
apiKey: null,
|
|
1174
|
+
oauth
|
|
1175
|
+
};
|
|
1176
|
+
await upsertRecord(file, name, updated);
|
|
1177
|
+
return onFile ? fileLocation(accessKey) : {
|
|
1178
|
+
backend: "keyring",
|
|
1179
|
+
service: KEYRING_SERVICE,
|
|
1180
|
+
account: accessKey
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
async function writeProbeResult(name, input) {
|
|
1184
|
+
const probe = ProfileLastProbe.parse({
|
|
1185
|
+
at: new Date().toISOString(),
|
|
1186
|
+
version: input.server.version,
|
|
1187
|
+
tokenFeatures: input.server.tokenFeatures,
|
|
1188
|
+
user: input.user
|
|
1189
|
+
});
|
|
1190
|
+
const file = await readProfilesFile();
|
|
1191
|
+
const existing = findRecord(file, name);
|
|
1192
|
+
if (existing === null) return null;
|
|
1193
|
+
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
1194
|
+
...entry,
|
|
1195
|
+
lastProbe: probe,
|
|
1196
|
+
lastFailure: null
|
|
1197
|
+
} : entry);
|
|
1198
|
+
await writeProfilesFile({
|
|
1199
|
+
...file,
|
|
1200
|
+
profiles
|
|
1201
|
+
});
|
|
1202
|
+
return probe;
|
|
1203
|
+
}
|
|
1204
|
+
async function writeProbeFailure(name, input) {
|
|
1205
|
+
const failure$1 = ProfileLastFailure.parse({
|
|
1206
|
+
at: new Date().toISOString(),
|
|
1207
|
+
kind: input.kind,
|
|
1208
|
+
reason: input.reason
|
|
1209
|
+
});
|
|
1210
|
+
const file = await readProfilesFile();
|
|
1211
|
+
const existing = findRecord(file, name);
|
|
1212
|
+
if (existing === null) return null;
|
|
1213
|
+
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
1214
|
+
...entry,
|
|
1215
|
+
lastFailure: failure$1
|
|
1216
|
+
} : entry);
|
|
1217
|
+
await writeProfilesFile({
|
|
1218
|
+
...file,
|
|
1219
|
+
profiles
|
|
1220
|
+
});
|
|
1221
|
+
return failure$1;
|
|
1222
|
+
}
|
|
1223
|
+
async function clearProfile(name = DEFAULT_PROFILE) {
|
|
1224
|
+
const file = await readProfilesFile();
|
|
1225
|
+
const existing = findRecord(file, name);
|
|
1226
|
+
const removals = [
|
|
1227
|
+
removeKeyringEntry(account.profileApiKey(name)),
|
|
1228
|
+
removeKeyringEntry(account.profileOAuthAccess(name)),
|
|
1229
|
+
removeKeyringEntry(account.profileOAuthRefresh(name))
|
|
1230
|
+
];
|
|
1231
|
+
if (existing !== null) flagResidualIfUnconfirmed(existing, existing.oauth !== null ? "oauth" : "apiKey", removals);
|
|
1232
|
+
if (existing === null) return false;
|
|
1233
|
+
await writeProfilesFile({
|
|
1234
|
+
...file,
|
|
1235
|
+
profiles: file.profiles.filter((entry) => entry.name !== name),
|
|
1236
|
+
defaultProfile: file.defaultProfile === name ? null : file.defaultProfile
|
|
1237
|
+
});
|
|
1238
|
+
return true;
|
|
999
1239
|
}
|
|
1000
|
-
function
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if (contentType === null) throwContentTypeMismatch(response, prepared, prepared.expectContentType);
|
|
1004
|
-
if (prepared.expectContentType === "json" && !contentType.includes(JSON_CONTENT_TYPE)) throwContentTypeMismatch(response, prepared, "json");
|
|
1005
|
-
if (prepared.expectContentType === "text" && !contentType.startsWith(TEXT_CONTENT_TYPE_PREFIX)) throwContentTypeMismatch(response, prepared, "text");
|
|
1240
|
+
async function readDefaultProfileName() {
|
|
1241
|
+
const file = await readProfilesFile();
|
|
1242
|
+
return file.defaultProfile;
|
|
1006
1243
|
}
|
|
1007
|
-
function
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
method: prepared.method,
|
|
1013
|
-
url: prepared.url,
|
|
1014
|
-
responseHeaders: response.headers,
|
|
1015
|
-
rawBody: null,
|
|
1016
|
-
overrideUserMessage: `Expected ${expected} response but got ${actual}`
|
|
1244
|
+
async function setDefaultProfile(name) {
|
|
1245
|
+
const file = await readProfilesFile();
|
|
1246
|
+
await writeProfilesFile({
|
|
1247
|
+
...file,
|
|
1248
|
+
defaultProfile: name
|
|
1017
1249
|
});
|
|
1018
1250
|
}
|
|
1019
|
-
async function readBodyForError(response) {
|
|
1020
|
-
try {
|
|
1021
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1022
|
-
return buffer.subarray(0, ERROR_BODY_BYTE_CAP).toString("utf8");
|
|
1023
|
-
} catch (error) {
|
|
1024
|
-
return `[body read failed: ${errorMessage(error)}]`;
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
1251
|
|
|
1028
1252
|
//#endregion
|
|
1029
1253
|
//#region src/core/version/probe.ts
|
|
@@ -1041,219 +1265,19 @@ async function probeServer(client, opts = {}) {
|
|
|
1041
1265
|
};
|
|
1042
1266
|
}
|
|
1043
1267
|
|
|
1044
|
-
//#endregion
|
|
1045
|
-
//#region src/core/url.ts
|
|
1046
|
-
function normalizeUrl(input) {
|
|
1047
|
-
const trimmed = input.trim().replace(/\/+$/, "");
|
|
1048
|
-
if (!/^https?:\/\//i.test(trimmed)) throw new ConfigError("URL must start with http:// or https://");
|
|
1049
|
-
return trimmed;
|
|
1050
|
-
}
|
|
1051
|
-
function displayUrl(input) {
|
|
1052
|
-
const parsed = new URL(input);
|
|
1053
|
-
const path = parsed.pathname.replace(/\/+$/, "");
|
|
1054
|
-
return `${parsed.origin}${path}`;
|
|
1055
|
-
}
|
|
1056
|
-
const LOOPBACK_HOSTS = new Set([
|
|
1057
|
-
"localhost",
|
|
1058
|
-
"127.0.0.1",
|
|
1059
|
-
"::1",
|
|
1060
|
-
"[::1]"
|
|
1061
|
-
]);
|
|
1062
|
-
function isLoopbackHost(hostname) {
|
|
1063
|
-
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
1064
|
-
}
|
|
1065
|
-
function assertEndpointOrigin(endpoint, baseUrl, label) {
|
|
1066
|
-
let parsed;
|
|
1067
|
-
try {
|
|
1068
|
-
parsed = new URL(endpoint);
|
|
1069
|
-
} catch {
|
|
1070
|
-
throw new ConfigError(`OAuth ${label} is not a valid URL: ${endpoint}`);
|
|
1071
|
-
}
|
|
1072
|
-
const base = new URL(baseUrl);
|
|
1073
|
-
if (parsed.protocol !== "https:" && !isLoopbackHost(parsed.hostname)) throw new ConfigError(`OAuth ${label} must use https: ${endpoint}`);
|
|
1074
|
-
if (parsed.origin !== base.origin) throw new ConfigError(`OAuth ${label} origin (${parsed.origin}) does not match the Metabase URL (${base.origin})`);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
//#endregion
|
|
1078
|
-
//#region src/core/http/oauth.ts
|
|
1079
|
-
const DISCOVERY_PATH = "/.well-known/oauth-authorization-server";
|
|
1080
|
-
const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
1081
|
-
const OAUTH_TIMEOUT_MS = 3e4;
|
|
1082
|
-
async function oauthFetch(request) {
|
|
1083
|
-
const timeoutSignal = AbortSignal.timeout(OAUTH_TIMEOUT_MS);
|
|
1084
|
-
try {
|
|
1085
|
-
return await fetch(request.url, {
|
|
1086
|
-
method: request.method,
|
|
1087
|
-
headers: {
|
|
1088
|
-
...request.headers,
|
|
1089
|
-
"user-agent": USER_AGENT
|
|
1090
|
-
},
|
|
1091
|
-
...request.body !== void 0 ? { body: request.body } : {},
|
|
1092
|
-
signal: timeoutSignal
|
|
1093
|
-
});
|
|
1094
|
-
} catch (error) {
|
|
1095
|
-
if (timeoutSignal.aborted) throw new TimeoutError(`OAuth request timed out after ${OAUTH_TIMEOUT_MS}ms`, {
|
|
1096
|
-
kind: "http",
|
|
1097
|
-
method: request.method,
|
|
1098
|
-
url: request.url,
|
|
1099
|
-
timeoutMs: OAUTH_TIMEOUT_MS
|
|
1100
|
-
});
|
|
1101
|
-
throw buildNetworkError(error, request.method, request.url);
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
const OAUTH_SCOPE = "mb:full";
|
|
1105
|
-
const OAuthServerMetadata = z.object({
|
|
1106
|
-
issuer: z.string(),
|
|
1107
|
-
authorization_endpoint: z.string(),
|
|
1108
|
-
token_endpoint: z.string(),
|
|
1109
|
-
registration_endpoint: z.string().optional(),
|
|
1110
|
-
revocation_endpoint: z.string().optional(),
|
|
1111
|
-
scopes_supported: z.array(z.string()).optional()
|
|
1112
|
-
}).loose();
|
|
1113
|
-
const RegisteredClient = z.object({ client_id: z.string() }).loose();
|
|
1114
|
-
const OAuthTokens = z.object({
|
|
1115
|
-
access_token: z.string(),
|
|
1116
|
-
token_type: z.string(),
|
|
1117
|
-
expires_in: z.number().int().optional(),
|
|
1118
|
-
refresh_token: z.string().optional()
|
|
1119
|
-
}).loose();
|
|
1120
|
-
const OAuthErrorBody = z.object({
|
|
1121
|
-
error: z.string(),
|
|
1122
|
-
error_description: z.string().optional()
|
|
1123
|
-
}).loose();
|
|
1124
|
-
async function readJson(response, schema, source) {
|
|
1125
|
-
let raw;
|
|
1126
|
-
try {
|
|
1127
|
-
raw = await response.json();
|
|
1128
|
-
} catch (error) {
|
|
1129
|
-
throw new ConfigError(`${source}: invalid JSON response (${errorMessage(error)})`);
|
|
1130
|
-
}
|
|
1131
|
-
const parsed = schema.safeParse(raw);
|
|
1132
|
-
if (!parsed.success) throw new ConfigError(`${source}: unexpected response shape`);
|
|
1133
|
-
return parsed.data;
|
|
1134
|
-
}
|
|
1135
|
-
async function failure(response, source, method, url) {
|
|
1136
|
-
const rawBody = await response.text().catch(() => "");
|
|
1137
|
-
if (isRetryableStatus(response.status)) return new HttpError({
|
|
1138
|
-
status: response.status,
|
|
1139
|
-
statusText: response.statusText,
|
|
1140
|
-
method,
|
|
1141
|
-
url,
|
|
1142
|
-
responseHeaders: response.headers,
|
|
1143
|
-
rawBody
|
|
1144
|
-
});
|
|
1145
|
-
const parsed = parseJsonResult(rawBody, OAuthErrorBody);
|
|
1146
|
-
if (parsed.ok) {
|
|
1147
|
-
const detail = parsed.value.error_description ?? parsed.value.error;
|
|
1148
|
-
return new ConfigError(`${source} failed (${response.status}): ${detail}`);
|
|
1149
|
-
}
|
|
1150
|
-
return new ConfigError(`${source} failed (${response.status})`);
|
|
1151
|
-
}
|
|
1152
|
-
async function postForm(url, params, schema, source) {
|
|
1153
|
-
const response = await oauthFetch({
|
|
1154
|
-
url,
|
|
1155
|
-
method: "POST",
|
|
1156
|
-
headers: {
|
|
1157
|
-
"content-type": FORM_CONTENT_TYPE,
|
|
1158
|
-
accept: JSON_CONTENT_TYPE
|
|
1159
|
-
},
|
|
1160
|
-
body: new URLSearchParams(params)
|
|
1161
|
-
});
|
|
1162
|
-
if (!response.ok) throw await failure(response, source, "POST", url);
|
|
1163
|
-
return readJson(response, schema, source);
|
|
1164
|
-
}
|
|
1165
|
-
const OAUTH_UNSUPPORTED_MESSAGE = "this Metabase does not support OAuth login (requires Metabase v63 or newer)";
|
|
1166
|
-
async function tryDiscoverMetadata(baseUrl) {
|
|
1167
|
-
const url = `${baseUrl}${DISCOVERY_PATH}`;
|
|
1168
|
-
const response = await oauthFetch({
|
|
1169
|
-
url,
|
|
1170
|
-
method: "GET",
|
|
1171
|
-
headers: { accept: JSON_CONTENT_TYPE }
|
|
1172
|
-
});
|
|
1173
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
1174
|
-
if (!response.ok || !contentType.includes("json")) {
|
|
1175
|
-
await response.body?.cancel().catch(() => void 0);
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
const metadata = await readJson(response, OAuthServerMetadata, "OAuth discovery");
|
|
1179
|
-
const scopes = metadata.scopes_supported;
|
|
1180
|
-
if (scopes !== void 0 && !scopes.includes(OAUTH_SCOPE)) return null;
|
|
1181
|
-
assertEndpointOrigin(metadata.issuer, baseUrl, "issuer");
|
|
1182
|
-
assertEndpointOrigin(metadata.authorization_endpoint, baseUrl, "authorization endpoint");
|
|
1183
|
-
assertEndpointOrigin(metadata.token_endpoint, baseUrl, "token endpoint");
|
|
1184
|
-
if (metadata.registration_endpoint !== void 0) assertEndpointOrigin(metadata.registration_endpoint, baseUrl, "registration endpoint");
|
|
1185
|
-
if (metadata.revocation_endpoint !== void 0) assertEndpointOrigin(metadata.revocation_endpoint, baseUrl, "revocation endpoint");
|
|
1186
|
-
return metadata;
|
|
1187
|
-
}
|
|
1188
|
-
async function discoverMetadata(baseUrl) {
|
|
1189
|
-
const metadata = await tryDiscoverMetadata(baseUrl);
|
|
1190
|
-
if (metadata === null) throw new ConfigError(OAUTH_UNSUPPORTED_MESSAGE);
|
|
1191
|
-
return metadata;
|
|
1192
|
-
}
|
|
1193
|
-
async function registerClient(input) {
|
|
1194
|
-
const response = await oauthFetch({
|
|
1195
|
-
url: input.registrationEndpoint,
|
|
1196
|
-
method: "POST",
|
|
1197
|
-
headers: {
|
|
1198
|
-
"content-type": JSON_CONTENT_TYPE,
|
|
1199
|
-
accept: JSON_CONTENT_TYPE
|
|
1200
|
-
},
|
|
1201
|
-
body: JSON.stringify({
|
|
1202
|
-
application_type: "native",
|
|
1203
|
-
client_name: input.clientName,
|
|
1204
|
-
redirect_uris: [input.redirectUri],
|
|
1205
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
1206
|
-
response_types: ["code"],
|
|
1207
|
-
token_endpoint_auth_method: "none",
|
|
1208
|
-
scope: OAUTH_SCOPE
|
|
1209
|
-
})
|
|
1210
|
-
});
|
|
1211
|
-
if (!response.ok) throw await failure(response, "OAuth client registration", "POST", input.registrationEndpoint);
|
|
1212
|
-
return readJson(response, RegisteredClient, "OAuth client registration");
|
|
1213
|
-
}
|
|
1214
|
-
function exchangeCode(input) {
|
|
1215
|
-
return postForm(input.tokenEndpoint, {
|
|
1216
|
-
grant_type: "authorization_code",
|
|
1217
|
-
code: input.code,
|
|
1218
|
-
redirect_uri: input.redirectUri,
|
|
1219
|
-
client_id: input.clientId,
|
|
1220
|
-
code_verifier: input.codeVerifier
|
|
1221
|
-
}, OAuthTokens, "OAuth token exchange");
|
|
1222
|
-
}
|
|
1223
|
-
function refreshTokens(input) {
|
|
1224
|
-
return postForm(input.tokenEndpoint, {
|
|
1225
|
-
grant_type: "refresh_token",
|
|
1226
|
-
refresh_token: input.refreshToken,
|
|
1227
|
-
client_id: input.clientId
|
|
1228
|
-
}, OAuthTokens, "OAuth token refresh");
|
|
1229
|
-
}
|
|
1230
|
-
async function revokeToken(input) {
|
|
1231
|
-
const response = await oauthFetch({
|
|
1232
|
-
url: input.revocationEndpoint,
|
|
1233
|
-
method: "POST",
|
|
1234
|
-
headers: { "content-type": FORM_CONTENT_TYPE },
|
|
1235
|
-
body: new URLSearchParams({
|
|
1236
|
-
token: input.token,
|
|
1237
|
-
client_id: input.clientId
|
|
1238
|
-
})
|
|
1239
|
-
});
|
|
1240
|
-
if (!response.ok) throw await failure(response, "OAuth token revocation", "POST", input.revocationEndpoint);
|
|
1241
|
-
await response.body?.cancel().catch(() => void 0);
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
1268
|
//#endregion
|
|
1245
1269
|
//#region src/core/auth/oauth-session.ts
|
|
1246
1270
|
async function refreshOAuthCredential(baseUrl, credential, nowMs) {
|
|
1247
|
-
const metadata = await discoverMetadata(baseUrl);
|
|
1271
|
+
const metadata = await discoverMetadata(baseUrl, credential.scope);
|
|
1248
1272
|
const tokens = await refreshTokens({
|
|
1249
1273
|
tokenEndpoint: metadata.token_endpoint,
|
|
1250
1274
|
refreshToken: credential.refreshToken,
|
|
1251
1275
|
clientId: credential.clientId
|
|
1252
1276
|
});
|
|
1253
|
-
return oauthCredentialFromTokens(tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId, nowMs);
|
|
1277
|
+
return oauthCredentialFromTokens(tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId, credential.scope, nowMs);
|
|
1254
1278
|
}
|
|
1255
1279
|
async function revokeOAuthCredential(baseUrl, credential) {
|
|
1256
|
-
const metadata = await discoverMetadata(baseUrl);
|
|
1280
|
+
const metadata = await discoverMetadata(baseUrl, credential.scope);
|
|
1257
1281
|
const revocationEndpoint = metadata.revocation_endpoint;
|
|
1258
1282
|
if (revocationEndpoint === void 0) return false;
|
|
1259
1283
|
await revokeToken({
|
|
@@ -1274,8 +1298,10 @@ async function revokeOAuthCredential(baseUrl, credential) {
|
|
|
1274
1298
|
function isPreflightSkipped() {
|
|
1275
1299
|
return readEnv(ENV_SKIP_PREFLIGHT) === "1";
|
|
1276
1300
|
}
|
|
1277
|
-
function
|
|
1278
|
-
|
|
1301
|
+
async function resolveActiveProfileName(profileFlag) {
|
|
1302
|
+
const explicit = explicitProfileName(profileFlag);
|
|
1303
|
+
if (explicit !== null) return explicit;
|
|
1304
|
+
return await readDefaultProfileName() ?? DEFAULT_PROFILE;
|
|
1279
1305
|
}
|
|
1280
1306
|
function explicitProfileName(profileFlag) {
|
|
1281
1307
|
return profileFlag || readEnv(ENV_PROFILE) || null;
|
|
@@ -1287,7 +1313,7 @@ function readEnvCredentials() {
|
|
|
1287
1313
|
};
|
|
1288
1314
|
}
|
|
1289
1315
|
async function resolveConfig(flags) {
|
|
1290
|
-
const profile =
|
|
1316
|
+
const profile = await resolveActiveProfileName(flags.profile);
|
|
1291
1317
|
const env = readEnvCredentials();
|
|
1292
1318
|
const hasUrl = Boolean(flags.url ?? env.url);
|
|
1293
1319
|
const hasKey = Boolean(flags.apiKey ?? env.apiKey);
|
|
@@ -1617,6 +1643,8 @@ function defineMetabaseCommand(def) {
|
|
|
1617
1643
|
getResolvedConfig,
|
|
1618
1644
|
getServerInfo
|
|
1619
1645
|
});
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
throw enrichScopeForbiddenError(error, cachedConfig);
|
|
1620
1648
|
} finally {
|
|
1621
1649
|
emitPendingWarnings();
|
|
1622
1650
|
}
|
|
@@ -1629,11 +1657,18 @@ function defineMetabaseCommand(def) {
|
|
|
1629
1657
|
examples: def.examples ?? [],
|
|
1630
1658
|
details: def.details ? def.details : null,
|
|
1631
1659
|
skills: def.skills ?? [],
|
|
1660
|
+
inputSchema: def.inputSchema ?? null,
|
|
1632
1661
|
outputSchema: def.outputSchema ?? null,
|
|
1633
1662
|
capabilities: required
|
|
1634
1663
|
});
|
|
1635
1664
|
return cmd;
|
|
1636
1665
|
}
|
|
1666
|
+
function enrichScopeForbiddenError(error, config) {
|
|
1667
|
+
if (!(error instanceof HttpError) || error.status !== 403 || config === null) return error;
|
|
1668
|
+
const { credential } = config;
|
|
1669
|
+
if (credential.kind !== "oauth" || credential.scope === OAUTH_SCOPE) return error;
|
|
1670
|
+
return new ConfigError(`${error.userMessage} This profile's login is scoped to ${credential.scope}, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or run content commands against the workspace itself via its ws-<id> profile.`);
|
|
1671
|
+
}
|
|
1637
1672
|
function emitPendingWarnings() {
|
|
1638
1673
|
for (const message of consumeLegacyEnvWarnings()) warn(message);
|
|
1639
1674
|
const legacy = consumeLegacyStorageWarning();
|
|
@@ -1693,4 +1728,4 @@ function buildConfigFlags(ctx) {
|
|
|
1693
1728
|
}
|
|
1694
1729
|
|
|
1695
1730
|
//#endregion
|
|
1696
|
-
export { DEFAULT_PROFILE, HttpError, OAUTH_SCOPE, ParsedVersionSchema, ProbedUser, ProfileAuthMethod, ProfileLastFailure, TokenFeatures, USER_AGENT, clearProfile, combineAborts, consumeKeychainResidualWarning, consumeKeyringDowngradeWarning, createClient, createCredentialRefresher, defineMetabaseCommand, discoverMetadata, displayUrl, exchangeCode, explicitProfileName, keyringFallbackWarning, listProfileRecords, normalizeUrl, oauthCredentialFromTokens, parseCsv, parseEnum, parseEnumCsv, parseInteger, parseJson, parseJsonOrPlain, probeServer, profileAuthMethod, readEnvCredentials, readProfileCredential, readProfileRecord, registerClient,
|
|
1731
|
+
export { DEFAULT_PROFILE, HttpError, OAUTH_SCOPE, ParsedVersionSchema, ProbedUser, ProfileAuthMethod, ProfileLastFailure, TokenFeatures, USER_AGENT, WORKSPACE_MANAGER_SCOPE, clearProfile, combineAborts, consumeKeychainResidualWarning, consumeKeyringDowngradeWarning, createClient, createCredentialRefresher, defineMetabaseCommand, discoverMetadata, displayUrl, exchangeCode, explicitProfileName, keyringFallbackWarning, listProfileRecords, normalizeUrl, oauthCredentialFromTokens, parseCsv, parseEnum, parseEnumCsv, parseInteger, parseJson, parseJsonOrPlain, probeServer, profileAuthMethod, readEnvCredentials, readProfileCredential, readProfileRecord, registerClient, resolveActiveProfileName, resolveRecordCredential, revokeOAuthCredential, setDefaultProfile, throwIfAborted, tryDiscoverMetadata, writeOAuthProfile, writeProbeFailure, writeProbeResult, writeProfile };
|