keboola-cli 0.63.4__py3-none-any.whl
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.
- keboola_agent_cli/__init__.py +34 -0
- keboola_agent_cli/__main__.py +5 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/index.html +50 -0
- keboola_agent_cli/ai_client.py +83 -0
- keboola_agent_cli/auto_update.py +550 -0
- keboola_agent_cli/changelog.py +1198 -0
- keboola_agent_cli/cli.py +448 -0
- keboola_agent_cli/client.py +3422 -0
- keboola_agent_cli/commands/__init__.py +0 -0
- keboola_agent_cli/commands/_data_app_git.py +343 -0
- keboola_agent_cli/commands/_helpers.py +377 -0
- keboola_agent_cli/commands/_metadata_input.py +49 -0
- keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
- keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
- keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
- keboola_agent_cli/commands/agent.py +968 -0
- keboola_agent_cli/commands/branch.py +423 -0
- keboola_agent_cli/commands/changelog.py +168 -0
- keboola_agent_cli/commands/component.py +216 -0
- keboola_agent_cli/commands/config.py +2442 -0
- keboola_agent_cli/commands/context.py +1481 -0
- keboola_agent_cli/commands/data_app.py +1279 -0
- keboola_agent_cli/commands/dev_portal.py +584 -0
- keboola_agent_cli/commands/doctor.py +37 -0
- keboola_agent_cli/commands/encrypt.py +145 -0
- keboola_agent_cli/commands/feature.py +311 -0
- keboola_agent_cli/commands/flow.py +948 -0
- keboola_agent_cli/commands/http_client.py +157 -0
- keboola_agent_cli/commands/init.py +279 -0
- keboola_agent_cli/commands/job.py +661 -0
- keboola_agent_cli/commands/kai.py +301 -0
- keboola_agent_cli/commands/lineage.py +1464 -0
- keboola_agent_cli/commands/org.py +292 -0
- keboola_agent_cli/commands/permissions.py +360 -0
- keboola_agent_cli/commands/project.py +1192 -0
- keboola_agent_cli/commands/repl.py +243 -0
- keboola_agent_cli/commands/schedule.py +340 -0
- keboola_agent_cli/commands/search.py +178 -0
- keboola_agent_cli/commands/semantic_layer.py +939 -0
- keboola_agent_cli/commands/serve.py +272 -0
- keboola_agent_cli/commands/sharing.py +340 -0
- keboola_agent_cli/commands/storage.py +2630 -0
- keboola_agent_cli/commands/stream.py +266 -0
- keboola_agent_cli/commands/sync.py +1277 -0
- keboola_agent_cli/commands/tool.py +206 -0
- keboola_agent_cli/commands/version.py +186 -0
- keboola_agent_cli/commands/workspace.py +635 -0
- keboola_agent_cli/config_store.py +582 -0
- keboola_agent_cli/constants.py +528 -0
- keboola_agent_cli/data_science_client.py +342 -0
- keboola_agent_cli/dev_portal_client.py +323 -0
- keboola_agent_cli/errors.py +248 -0
- keboola_agent_cli/http_base.py +315 -0
- keboola_agent_cli/json_utils.py +126 -0
- keboola_agent_cli/lib.py +536 -0
- keboola_agent_cli/manage_client.py +324 -0
- keboola_agent_cli/metastore_client.py +214 -0
- keboola_agent_cli/models.py +427 -0
- keboola_agent_cli/output.py +1084 -0
- keboola_agent_cli/permissions.py +469 -0
- keboola_agent_cli/py.typed +3 -0
- keboola_agent_cli/result_models.py +271 -0
- keboola_agent_cli/server/__init__.py +34 -0
- keboola_agent_cli/server/agent_runner.py +1289 -0
- keboola_agent_cli/server/agents_store.py +325 -0
- keboola_agent_cli/server/app.py +764 -0
- keboola_agent_cli/server/auth.py +117 -0
- keboola_agent_cli/server/dependencies.py +149 -0
- keboola_agent_cli/server/pricing.py +303 -0
- keboola_agent_cli/server/routers/__init__.py +1 -0
- keboola_agent_cli/server/routers/agents.py +616 -0
- keboola_agent_cli/server/routers/ai_chat.py +129 -0
- keboola_agent_cli/server/routers/branches.py +133 -0
- keboola_agent_cli/server/routers/components.py +48 -0
- keboola_agent_cli/server/routers/configs.py +507 -0
- keboola_agent_cli/server/routers/data_apps.py +384 -0
- keboola_agent_cli/server/routers/dev_portal.py +67 -0
- keboola_agent_cli/server/routers/encrypt.py +35 -0
- keboola_agent_cli/server/routers/feature.py +179 -0
- keboola_agent_cli/server/routers/flows.py +204 -0
- keboola_agent_cli/server/routers/health.py +53 -0
- keboola_agent_cli/server/routers/jobs.py +175 -0
- keboola_agent_cli/server/routers/kai.py +80 -0
- keboola_agent_cli/server/routers/lineage.py +226 -0
- keboola_agent_cli/server/routers/mcp.py +70 -0
- keboola_agent_cli/server/routers/members.py +170 -0
- keboola_agent_cli/server/routers/org.py +96 -0
- keboola_agent_cli/server/routers/projects.py +106 -0
- keboola_agent_cli/server/routers/schedules.py +54 -0
- keboola_agent_cli/server/routers/search.py +30 -0
- keboola_agent_cli/server/routers/semantic_layer.py +650 -0
- keboola_agent_cli/server/routers/sharing.py +86 -0
- keboola_agent_cli/server/routers/storage.py +574 -0
- keboola_agent_cli/server/routers/stream.py +100 -0
- keboola_agent_cli/server/routers/workspaces.py +302 -0
- keboola_agent_cli/server/run_broadcaster.py +329 -0
- keboola_agent_cli/server/sse.py +25 -0
- keboola_agent_cli/services/__init__.py +0 -0
- keboola_agent_cli/services/_encryption.py +217 -0
- keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
- keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
- keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
- keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
- keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
- keboola_agent_cli/services/_sync_bindings.py +456 -0
- keboola_agent_cli/services/_sync_branch.py +191 -0
- keboola_agent_cli/services/_sync_bulk.py +228 -0
- keboola_agent_cli/services/_sync_clone.py +163 -0
- keboola_agent_cli/services/_sync_models.py +97 -0
- keboola_agent_cli/services/_sync_push_ops.py +369 -0
- keboola_agent_cli/services/_sync_storage.py +376 -0
- keboola_agent_cli/services/_sync_writeback.py +167 -0
- keboola_agent_cli/services/agent_service.py +458 -0
- keboola_agent_cli/services/base.py +175 -0
- keboola_agent_cli/services/branch_service.py +588 -0
- keboola_agent_cli/services/component_service.py +694 -0
- keboola_agent_cli/services/config_service.py +2099 -0
- keboola_agent_cli/services/data_app_git_service.py +224 -0
- keboola_agent_cli/services/data_app_service.py +2082 -0
- keboola_agent_cli/services/deep_lineage_service.py +1322 -0
- keboola_agent_cli/services/dev_portal_service.py +345 -0
- keboola_agent_cli/services/doctor_service.py +445 -0
- keboola_agent_cli/services/encrypt_service.py +87 -0
- keboola_agent_cli/services/feature_service.py +268 -0
- keboola_agent_cli/services/flow_service.py +769 -0
- keboola_agent_cli/services/flow_validation.py +188 -0
- keboola_agent_cli/services/http_forwarder_service.py +236 -0
- keboola_agent_cli/services/job_idempotency_store.py +285 -0
- keboola_agent_cli/services/job_service.py +797 -0
- keboola_agent_cli/services/kai_service.py +367 -0
- keboola_agent_cli/services/lineage_service.py +274 -0
- keboola_agent_cli/services/mcp_service.py +1498 -0
- keboola_agent_cli/services/mcp_transport.py +259 -0
- keboola_agent_cli/services/member_service.py +593 -0
- keboola_agent_cli/services/org_service.py +619 -0
- keboola_agent_cli/services/project_service.py +947 -0
- keboola_agent_cli/services/repo_validate_service.py +767 -0
- keboola_agent_cli/services/schedule_service.py +731 -0
- keboola_agent_cli/services/search_service.py +331 -0
- keboola_agent_cli/services/semantic_layer_service.py +1497 -0
- keboola_agent_cli/services/sharing_service.py +307 -0
- keboola_agent_cli/services/storage_service.py +2524 -0
- keboola_agent_cli/services/stream_service.py +395 -0
- keboola_agent_cli/services/sync_service.py +2244 -0
- keboola_agent_cli/services/variables_service.py +447 -0
- keboola_agent_cli/services/version_service.py +1038 -0
- keboola_agent_cli/services/workspace_service.py +1103 -0
- keboola_agent_cli/stream_client.py +217 -0
- keboola_agent_cli/sync/__init__.py +1 -0
- keboola_agent_cli/sync/branch_mapping.py +174 -0
- keboola_agent_cli/sync/clone.py +211 -0
- keboola_agent_cli/sync/code_extraction.py +655 -0
- keboola_agent_cli/sync/config_format.py +290 -0
- keboola_agent_cli/sync/diff_engine.py +566 -0
- keboola_agent_cli/sync/git_utils.py +93 -0
- keboola_agent_cli/sync/manifest.py +162 -0
- keboola_agent_cli/sync/naming.py +90 -0
- keboola_agent_cli/sync/secrets.py +62 -0
- keboola_agent_cli/sync/sql_split.py +134 -0
- keboola_cli-0.63.4.dist-info/METADATA +308 -0
- keboola_cli-0.63.4.dist-info/RECORD +306 -0
- keboola_cli-0.63.4.dist-info/WHEEL +4 -0
- keboola_cli-0.63.4.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,2442 @@
|
|
|
1
|
+
"""Configuration commands - list, detail, search, update, delete, and scaffold.
|
|
2
|
+
|
|
3
|
+
Thin CLI layer: parses arguments, calls ConfigService, formats output.
|
|
4
|
+
No business logic belongs here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.syntax import Syntax
|
|
16
|
+
|
|
17
|
+
from ..config_store import ConfigStore
|
|
18
|
+
from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME, VALID_COMPONENT_TYPES
|
|
19
|
+
from ..errors import ConfigError, ErrorCode, KeboolaApiError
|
|
20
|
+
from ..output import format_config_detail, format_configs_table, format_search_results
|
|
21
|
+
from ._helpers import (
|
|
22
|
+
check_cli_permission,
|
|
23
|
+
emit_project_warnings,
|
|
24
|
+
get_formatter,
|
|
25
|
+
get_service,
|
|
26
|
+
map_error_to_exit_code,
|
|
27
|
+
resolve_branch,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_branch_prefix(output_dir: Path) -> str | None:
|
|
34
|
+
"""Detect kbc project branch path from .keboola/manifest.json.
|
|
35
|
+
|
|
36
|
+
When output_dir is inside a kbc project (has .keboola/manifest.json),
|
|
37
|
+
returns the default branch path (e.g. "main") so scaffold files
|
|
38
|
+
land in the correct location (main/extractor/... instead of extractor/...).
|
|
39
|
+
|
|
40
|
+
Returns None if not a kbc project or manifest is unreadable.
|
|
41
|
+
"""
|
|
42
|
+
manifest_path = output_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
|
|
43
|
+
if not manifest_path.is_file():
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
48
|
+
branches = manifest.get("branches", [])
|
|
49
|
+
if branches:
|
|
50
|
+
# Use the first (default) branch path
|
|
51
|
+
branch_path = branches[0].get("path", "")
|
|
52
|
+
if branch_path:
|
|
53
|
+
logger.debug("Detected kbc branch prefix: %s", branch_path)
|
|
54
|
+
return branch_path
|
|
55
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
56
|
+
logger.debug("Could not read manifest: %s", exc)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
config_app = typer.Typer(help="Browse and inspect configurations")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@config_app.callback(invoke_without_command=True)
|
|
65
|
+
def _config_permission_check(ctx: typer.Context) -> None:
|
|
66
|
+
check_cli_permission(ctx, "config")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@config_app.command("list", rich_help_panel="Browse")
|
|
70
|
+
def config_list(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
project: list[str] | None = typer.Option(
|
|
73
|
+
None,
|
|
74
|
+
"--project",
|
|
75
|
+
help="Project alias to query (can be repeated for multiple projects)",
|
|
76
|
+
),
|
|
77
|
+
component_type: str | None = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--component-type",
|
|
80
|
+
help="Filter by component type: extractor, writer, transformation, application",
|
|
81
|
+
),
|
|
82
|
+
component_id: str | None = typer.Option(
|
|
83
|
+
None,
|
|
84
|
+
"--component-id",
|
|
85
|
+
help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)",
|
|
86
|
+
),
|
|
87
|
+
branch: int | None = typer.Option(
|
|
88
|
+
None,
|
|
89
|
+
"--branch",
|
|
90
|
+
help="List configs from a specific dev branch ID (defaults to active branch)",
|
|
91
|
+
),
|
|
92
|
+
include_rows: bool = typer.Option(
|
|
93
|
+
False,
|
|
94
|
+
"--include-rows",
|
|
95
|
+
help=(
|
|
96
|
+
"Include full configuration + rows body per config (noticeably larger "
|
|
97
|
+
"payload). Without this flag the response is summary-level only "
|
|
98
|
+
"(name/description/component/last_modified/folder)."
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""List configurations from connected projects.
|
|
103
|
+
|
|
104
|
+
If a dev branch is active (via 'branch use'), configs from that branch
|
|
105
|
+
are listed. Use --branch to override.
|
|
106
|
+
"""
|
|
107
|
+
formatter = get_formatter(ctx)
|
|
108
|
+
service = get_service(ctx, "config_service")
|
|
109
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
110
|
+
|
|
111
|
+
# --branch requires --project (branch ID is per-project)
|
|
112
|
+
# For list with multiple projects, only validate if explicit --branch given
|
|
113
|
+
if branch is not None and (not project or len(project) != 1):
|
|
114
|
+
formatter.error(
|
|
115
|
+
message="--branch requires exactly one --project (branch ID is per-project)",
|
|
116
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
117
|
+
)
|
|
118
|
+
raise typer.Exit(code=2)
|
|
119
|
+
|
|
120
|
+
# Resolve active branch (only for single-project queries)
|
|
121
|
+
effective_branch: int | None = branch
|
|
122
|
+
effective_project = project
|
|
123
|
+
if branch is None and project and len(project) == 1:
|
|
124
|
+
_, effective_branch = resolve_branch(config_store, formatter, project[0], None)
|
|
125
|
+
|
|
126
|
+
# Validate component_type if provided
|
|
127
|
+
if component_type and component_type not in VALID_COMPONENT_TYPES:
|
|
128
|
+
formatter.error(
|
|
129
|
+
message=f"Invalid component type '{component_type}'. "
|
|
130
|
+
f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}",
|
|
131
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
132
|
+
)
|
|
133
|
+
raise typer.Exit(code=2)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = service.list_configs(
|
|
137
|
+
aliases=effective_project,
|
|
138
|
+
component_type=component_type,
|
|
139
|
+
component_id=component_id,
|
|
140
|
+
branch_id=effective_branch,
|
|
141
|
+
include_rows=include_rows,
|
|
142
|
+
)
|
|
143
|
+
except ConfigError as exc:
|
|
144
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
145
|
+
raise typer.Exit(code=5) from None
|
|
146
|
+
|
|
147
|
+
# In JSON mode, include both configs and errors in the response
|
|
148
|
+
if formatter.json_mode:
|
|
149
|
+
formatter.output(result)
|
|
150
|
+
else:
|
|
151
|
+
# In human mode, show per-project errors as warnings and configs as table
|
|
152
|
+
format_configs_table(formatter.console, result)
|
|
153
|
+
emit_project_warnings(formatter, result)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@config_app.command("detail", rich_help_panel="Browse")
|
|
157
|
+
def config_detail(
|
|
158
|
+
ctx: typer.Context,
|
|
159
|
+
project: list[str] | None = typer.Option(
|
|
160
|
+
None,
|
|
161
|
+
"--project",
|
|
162
|
+
help=(
|
|
163
|
+
"Project alias to query. Repeat for multiple projects (only valid in "
|
|
164
|
+
"bulk mode, i.e. when --config-id is omitted)."
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
168
|
+
config_id: str | None = typer.Option(
|
|
169
|
+
None,
|
|
170
|
+
"--config-id",
|
|
171
|
+
help=(
|
|
172
|
+
"Configuration ID. When omitted, the command switches to BULK mode "
|
|
173
|
+
"and returns every configuration under --component-id as a JSON "
|
|
174
|
+
"array ({'configs': [...], 'errors': [...]})."
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
branch: int | None = typer.Option(
|
|
178
|
+
None,
|
|
179
|
+
"--branch",
|
|
180
|
+
help="Get detail from a specific dev branch ID (defaults to active branch)",
|
|
181
|
+
),
|
|
182
|
+
with_state: bool = typer.Option(
|
|
183
|
+
False,
|
|
184
|
+
"--with-state",
|
|
185
|
+
help=(
|
|
186
|
+
"Attach the runtime state dict to each config under 'state'. "
|
|
187
|
+
"Single-config mode: state is read from the same detail response "
|
|
188
|
+
"(no extra HTTP call). Bulk mode: state is fetched inline via "
|
|
189
|
+
"include=state (no N+1). WARNING: --with-state output may contain "
|
|
190
|
+
"OAuth tokens, refresh tokens, and other credential-bearing runtime "
|
|
191
|
+
"data. Do not pipe into logs, scratch files, or shared workspaces "
|
|
192
|
+
"without redaction."
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Show detailed information about one or many configurations.
|
|
197
|
+
|
|
198
|
+
\b
|
|
199
|
+
Two modes, switched by --config-id:
|
|
200
|
+
- Single-config mode (default when --config-id given): returns the
|
|
201
|
+
full configuration detail dict, shape identical to historical output
|
|
202
|
+
(callers depending on this shape are unaffected).
|
|
203
|
+
- Bulk mode (--config-id omitted): returns every config under
|
|
204
|
+
--component-id as {"configs": [...], "errors": [...]}. Works across
|
|
205
|
+
multiple projects when --project is repeated (per-project errors
|
|
206
|
+
surface in the errors list without aborting other projects).
|
|
207
|
+
|
|
208
|
+
If a dev branch is active (via 'branch use') the detail is fetched
|
|
209
|
+
from that branch. --branch overrides; bulk mode with multiple projects
|
|
210
|
+
rejects --branch (branch IDs are per-project).
|
|
211
|
+
|
|
212
|
+
\b
|
|
213
|
+
Examples:
|
|
214
|
+
# Single config (unchanged shape)
|
|
215
|
+
kbagent config detail --project prod --component-id keboola.ex-db-snowflake --config-id 101
|
|
216
|
+
# Bulk: every Snowflake writer in one project
|
|
217
|
+
kbagent --json config detail --project prod --component-id keboola.wr-db-snowflake
|
|
218
|
+
# Bulk: every Snowflake writer across many projects (with runtime state)
|
|
219
|
+
kbagent --json config detail --project prod --project stage --component-id keboola.wr-db-snowflake --with-state
|
|
220
|
+
"""
|
|
221
|
+
formatter = get_formatter(ctx)
|
|
222
|
+
service = get_service(ctx, "config_service")
|
|
223
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
224
|
+
|
|
225
|
+
# --project is required (zero, one, or many)
|
|
226
|
+
if not project:
|
|
227
|
+
formatter.error(
|
|
228
|
+
message="--project is required (repeat for multiple projects in bulk mode).",
|
|
229
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
230
|
+
)
|
|
231
|
+
raise typer.Exit(code=2)
|
|
232
|
+
|
|
233
|
+
# Typer ``...`` enforces presence but not non-emptiness; guard here so
|
|
234
|
+
# ``--component-id ""`` fails fast with a clear message instead of
|
|
235
|
+
# constructing a malformed /components//configs URL downstream.
|
|
236
|
+
if not component_id.strip():
|
|
237
|
+
formatter.error(
|
|
238
|
+
message="--component-id must not be empty.",
|
|
239
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
240
|
+
)
|
|
241
|
+
raise typer.Exit(code=2)
|
|
242
|
+
|
|
243
|
+
# --config-id + multiple --project is ambiguous: a single config lives in
|
|
244
|
+
# exactly one project. Reject with a clear message instead of picking one.
|
|
245
|
+
if config_id is not None and len(project) > 1:
|
|
246
|
+
formatter.error(
|
|
247
|
+
message=(
|
|
248
|
+
"--config-id is only valid with exactly one --project. "
|
|
249
|
+
"Omit --config-id to fan out across multiple projects."
|
|
250
|
+
),
|
|
251
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
252
|
+
)
|
|
253
|
+
raise typer.Exit(code=2)
|
|
254
|
+
|
|
255
|
+
# --branch requires exactly one --project (branch IDs are per-project).
|
|
256
|
+
if branch is not None and len(project) != 1:
|
|
257
|
+
formatter.error(
|
|
258
|
+
message="--branch requires exactly one --project (branch ID is per-project).",
|
|
259
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
260
|
+
)
|
|
261
|
+
raise typer.Exit(code=2)
|
|
262
|
+
|
|
263
|
+
# Resolve active branch when only one --project was passed
|
|
264
|
+
effective_branch: int | None = branch
|
|
265
|
+
if branch is None and len(project) == 1:
|
|
266
|
+
_, effective_branch = resolve_branch(config_store, formatter, project[0], None)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
if config_id is not None:
|
|
270
|
+
# Single-config mode: shape unchanged for backward compat,
|
|
271
|
+
# plus the opt-in sandbox annotation (since v0.42.1, issue #312):
|
|
272
|
+
# the service layer now owns the keboola.sandboxes
|
|
273
|
+
# configurationId->workspace.id resolution so HTTP and REST
|
|
274
|
+
# callers get the same enrichment, not only the CLI.
|
|
275
|
+
result = service.get_config_detail(
|
|
276
|
+
alias=project[0],
|
|
277
|
+
component_id=component_id,
|
|
278
|
+
config_id=config_id,
|
|
279
|
+
branch_id=effective_branch,
|
|
280
|
+
with_state=with_state,
|
|
281
|
+
include_sandbox_annotation=True,
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
# Bulk mode: one call per project, filtered by component_id.
|
|
285
|
+
# Annotation flag stays off here -- bulk mode would N+1 the
|
|
286
|
+
# workspace listing endpoint (one extra round-trip per config),
|
|
287
|
+
# and the field that triggers the annotation in single-config
|
|
288
|
+
# mode (parameters.id) is rarely consumed in bulk anyway.
|
|
289
|
+
result = service.get_config_detail(
|
|
290
|
+
alias=project[0],
|
|
291
|
+
component_id=component_id,
|
|
292
|
+
config_id=None,
|
|
293
|
+
branch_id=effective_branch,
|
|
294
|
+
with_state=with_state,
|
|
295
|
+
aliases=project,
|
|
296
|
+
)
|
|
297
|
+
except ConfigError as exc:
|
|
298
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
299
|
+
raise typer.Exit(code=5) from None
|
|
300
|
+
except KeboolaApiError as exc:
|
|
301
|
+
exit_code = map_error_to_exit_code(exc)
|
|
302
|
+
formatter.error(
|
|
303
|
+
message=exc.message,
|
|
304
|
+
error_code=exc.error_code,
|
|
305
|
+
project=project[0],
|
|
306
|
+
retryable=exc.retryable,
|
|
307
|
+
)
|
|
308
|
+
raise typer.Exit(code=exit_code) from None
|
|
309
|
+
|
|
310
|
+
if config_id is not None:
|
|
311
|
+
# Single-config mode: emit unchanged shape
|
|
312
|
+
formatter.output(result, format_config_detail)
|
|
313
|
+
else:
|
|
314
|
+
# Bulk mode: emit {configs: [...], errors: [...]}
|
|
315
|
+
if formatter.json_mode:
|
|
316
|
+
formatter.output(result)
|
|
317
|
+
else:
|
|
318
|
+
_format_config_detail_bulk(formatter.console, result, component_id, with_state)
|
|
319
|
+
emit_project_warnings(formatter, result)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _format_config_detail_bulk(
|
|
323
|
+
console: Any,
|
|
324
|
+
data: dict,
|
|
325
|
+
component_id: str,
|
|
326
|
+
with_state: bool,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Render bulk config detail results as a Rich table grouped by project.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
console: Rich Console instance.
|
|
332
|
+
data: ``{"configs": [...], "errors": [...]}`` from the service layer.
|
|
333
|
+
component_id: The component ID the bulk query was scoped to.
|
|
334
|
+
with_state: Whether ``state`` was requested (adds a State column).
|
|
335
|
+
"""
|
|
336
|
+
from rich.table import Table
|
|
337
|
+
|
|
338
|
+
# ``component_id`` is user-supplied and Rich interprets ``[tag]`` syntax.
|
|
339
|
+
# Escape before embedding in Rich-rendered strings so an ID like
|
|
340
|
+
# ``keboola.[red]evil[/red]`` cannot paint the terminal.
|
|
341
|
+
safe_component_id = escape(component_id)
|
|
342
|
+
|
|
343
|
+
configs = data.get("configs", [])
|
|
344
|
+
if not configs:
|
|
345
|
+
console.print(
|
|
346
|
+
f"[dim]No configurations found for [bold]{safe_component_id}[/bold] "
|
|
347
|
+
"in the selected project(s).[/dim]"
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Group by project_alias for readability
|
|
352
|
+
grouped: dict[str, list[dict]] = {}
|
|
353
|
+
order: list[str] = []
|
|
354
|
+
for cfg in configs:
|
|
355
|
+
alias = cfg.get("project_alias", "unknown")
|
|
356
|
+
if alias not in grouped:
|
|
357
|
+
order.append(alias)
|
|
358
|
+
grouped[alias] = []
|
|
359
|
+
grouped[alias].append(cfg)
|
|
360
|
+
|
|
361
|
+
for alias in order:
|
|
362
|
+
table = Table(title=f"{safe_component_id} configs -- {alias} ({len(grouped[alias])})")
|
|
363
|
+
table.add_column("Config ID", style="bold cyan", justify="right")
|
|
364
|
+
table.add_column("Name")
|
|
365
|
+
table.add_column("Rows", justify="right", style="dim")
|
|
366
|
+
table.add_column("Version", justify="right", style="dim")
|
|
367
|
+
table.add_column("Disabled", style="dim")
|
|
368
|
+
if with_state:
|
|
369
|
+
table.add_column("State Keys", style="dim")
|
|
370
|
+
|
|
371
|
+
for cfg in grouped[alias]:
|
|
372
|
+
row = [
|
|
373
|
+
str(cfg.get("config_id", "")),
|
|
374
|
+
str(cfg.get("name", "")),
|
|
375
|
+
str(len(cfg.get("rows", []) or [])),
|
|
376
|
+
str(cfg.get("version", "") or ""),
|
|
377
|
+
"yes" if cfg.get("isDisabled") else "",
|
|
378
|
+
]
|
|
379
|
+
if with_state:
|
|
380
|
+
state = cfg.get("state") or {}
|
|
381
|
+
row.append(str(len(state) if isinstance(state, dict) else 0))
|
|
382
|
+
table.add_row(*row)
|
|
383
|
+
|
|
384
|
+
console.print(table)
|
|
385
|
+
console.print()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@config_app.command("search", rich_help_panel="Browse")
|
|
389
|
+
def config_search(
|
|
390
|
+
ctx: typer.Context,
|
|
391
|
+
query: str = typer.Option(..., "--query", "-q", help="Search string or regex pattern"),
|
|
392
|
+
project: list[str] | None = typer.Option(
|
|
393
|
+
None,
|
|
394
|
+
"--project",
|
|
395
|
+
help="Project alias to search (can be repeated for multiple projects)",
|
|
396
|
+
),
|
|
397
|
+
component_type: str | None = typer.Option(
|
|
398
|
+
None,
|
|
399
|
+
"--component-type",
|
|
400
|
+
help="Filter by component type: extractor, writer, transformation, application",
|
|
401
|
+
),
|
|
402
|
+
component_id: str | None = typer.Option(
|
|
403
|
+
None,
|
|
404
|
+
"--component-id",
|
|
405
|
+
help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)",
|
|
406
|
+
),
|
|
407
|
+
ignore_case: bool = typer.Option(
|
|
408
|
+
False,
|
|
409
|
+
"--ignore-case",
|
|
410
|
+
"-i",
|
|
411
|
+
help="Case-insensitive matching",
|
|
412
|
+
),
|
|
413
|
+
use_regex: bool = typer.Option(
|
|
414
|
+
False,
|
|
415
|
+
"--regex",
|
|
416
|
+
"-r",
|
|
417
|
+
help="Interpret query as a regular expression",
|
|
418
|
+
),
|
|
419
|
+
branch: int | None = typer.Option(
|
|
420
|
+
None,
|
|
421
|
+
"--branch",
|
|
422
|
+
help="Search configs in a specific dev branch ID (defaults to active branch)",
|
|
423
|
+
),
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Search through configuration bodies for a string or pattern.
|
|
426
|
+
|
|
427
|
+
Searches config names, descriptions, parameters, and row definitions.
|
|
428
|
+
Reports which configurations match and where in the JSON tree.
|
|
429
|
+
|
|
430
|
+
If a dev branch is active (via 'branch use'), configs from that branch
|
|
431
|
+
are searched. Use --branch to override.
|
|
432
|
+
"""
|
|
433
|
+
formatter = get_formatter(ctx)
|
|
434
|
+
service = get_service(ctx, "config_service")
|
|
435
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
436
|
+
|
|
437
|
+
# --branch requires exactly one --project
|
|
438
|
+
if branch is not None and (not project or len(project) != 1):
|
|
439
|
+
formatter.error(
|
|
440
|
+
message="--branch requires exactly one --project (branch ID is per-project)",
|
|
441
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
442
|
+
)
|
|
443
|
+
raise typer.Exit(code=2)
|
|
444
|
+
|
|
445
|
+
# Resolve active branch (only for single-project queries)
|
|
446
|
+
effective_branch: int | None = branch
|
|
447
|
+
if branch is None and project and len(project) == 1:
|
|
448
|
+
_, effective_branch = resolve_branch(config_store, formatter, project[0], None)
|
|
449
|
+
|
|
450
|
+
# Validate component_type
|
|
451
|
+
if component_type and component_type not in VALID_COMPONENT_TYPES:
|
|
452
|
+
formatter.error(
|
|
453
|
+
message=f"Invalid component type '{component_type}'. "
|
|
454
|
+
f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}",
|
|
455
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
456
|
+
)
|
|
457
|
+
raise typer.Exit(code=2)
|
|
458
|
+
|
|
459
|
+
# Validate regex if provided
|
|
460
|
+
if use_regex:
|
|
461
|
+
try:
|
|
462
|
+
re.compile(query)
|
|
463
|
+
except re.error as exc:
|
|
464
|
+
formatter.error(
|
|
465
|
+
message=f"Invalid regex pattern: {exc}",
|
|
466
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
467
|
+
)
|
|
468
|
+
raise typer.Exit(code=2) from None
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
result = service.search_configs(
|
|
472
|
+
query=query,
|
|
473
|
+
aliases=project,
|
|
474
|
+
component_type=component_type,
|
|
475
|
+
component_id=component_id,
|
|
476
|
+
ignore_case=ignore_case,
|
|
477
|
+
use_regex=use_regex,
|
|
478
|
+
branch_id=effective_branch,
|
|
479
|
+
)
|
|
480
|
+
except ConfigError as exc:
|
|
481
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
482
|
+
raise typer.Exit(code=5) from None
|
|
483
|
+
|
|
484
|
+
if formatter.json_mode:
|
|
485
|
+
formatter.output(result)
|
|
486
|
+
else:
|
|
487
|
+
format_search_results(formatter.console, result)
|
|
488
|
+
emit_project_warnings(formatter, result)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _parse_json_input(raw: str) -> dict:
|
|
492
|
+
"""Parse JSON from inline string, ``@file``, or ``-`` (stdin)."""
|
|
493
|
+
import sys
|
|
494
|
+
|
|
495
|
+
if raw == "-":
|
|
496
|
+
return json.loads(sys.stdin.read())
|
|
497
|
+
if raw.startswith("@"):
|
|
498
|
+
file_path = Path(raw[1:])
|
|
499
|
+
if not file_path.is_file():
|
|
500
|
+
raise FileNotFoundError(f"Input file not found: {file_path}")
|
|
501
|
+
return json.loads(file_path.read_text(encoding="utf-8"))
|
|
502
|
+
return json.loads(raw)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _parse_set_value(raw: str) -> object:
|
|
506
|
+
"""Try to parse *raw* as JSON; fall back to plain string."""
|
|
507
|
+
try:
|
|
508
|
+
return json.loads(raw)
|
|
509
|
+
except (json.JSONDecodeError, ValueError):
|
|
510
|
+
return raw
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@config_app.command("update", rich_help_panel="Lifecycle")
|
|
514
|
+
def config_update(
|
|
515
|
+
ctx: typer.Context,
|
|
516
|
+
project: str = typer.Option(
|
|
517
|
+
...,
|
|
518
|
+
"--project",
|
|
519
|
+
help="Project alias",
|
|
520
|
+
),
|
|
521
|
+
component_id: str = typer.Option(
|
|
522
|
+
...,
|
|
523
|
+
"--component-id",
|
|
524
|
+
help="Component ID (e.g. keboola.python-transformation-v2)",
|
|
525
|
+
),
|
|
526
|
+
config_id: str = typer.Option(
|
|
527
|
+
...,
|
|
528
|
+
"--config-id",
|
|
529
|
+
help="Configuration ID to update",
|
|
530
|
+
),
|
|
531
|
+
name: str | None = typer.Option(
|
|
532
|
+
None,
|
|
533
|
+
"--name",
|
|
534
|
+
help="New configuration name",
|
|
535
|
+
),
|
|
536
|
+
description: str | None = typer.Option(
|
|
537
|
+
None,
|
|
538
|
+
"--description",
|
|
539
|
+
help="New configuration description",
|
|
540
|
+
),
|
|
541
|
+
configuration: str | None = typer.Option(
|
|
542
|
+
None,
|
|
543
|
+
"--configuration",
|
|
544
|
+
help="Configuration JSON: inline, @file.json, or - for stdin",
|
|
545
|
+
),
|
|
546
|
+
configuration_file: Path | None = typer.Option(
|
|
547
|
+
None,
|
|
548
|
+
"--configuration-file",
|
|
549
|
+
help="Path to a JSON file with configuration content",
|
|
550
|
+
exists=True,
|
|
551
|
+
readable=True,
|
|
552
|
+
),
|
|
553
|
+
set_values: list[str] | None = typer.Option(
|
|
554
|
+
None,
|
|
555
|
+
"--set",
|
|
556
|
+
help="Set a nested value: PATH VALUE (e.g. --set 'parameters.db.host=new-host')",
|
|
557
|
+
),
|
|
558
|
+
merge: bool = typer.Option(
|
|
559
|
+
False,
|
|
560
|
+
"--merge",
|
|
561
|
+
help="Deep-merge into existing config instead of replacing",
|
|
562
|
+
),
|
|
563
|
+
dry_run: bool = typer.Option(
|
|
564
|
+
False,
|
|
565
|
+
"--dry-run",
|
|
566
|
+
help="Show what would change without applying",
|
|
567
|
+
),
|
|
568
|
+
branch: int | None = typer.Option(
|
|
569
|
+
None,
|
|
570
|
+
"--branch",
|
|
571
|
+
help="Update in a specific dev branch ID (defaults to active branch)",
|
|
572
|
+
),
|
|
573
|
+
allow_plaintext: bool = typer.Option(
|
|
574
|
+
False,
|
|
575
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
576
|
+
help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
|
|
577
|
+
),
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Update a configuration's metadata and/or content.
|
|
580
|
+
|
|
581
|
+
\b
|
|
582
|
+
Metadata options (--name, --description) update display info.
|
|
583
|
+
Content options modify the configuration JSON itself:
|
|
584
|
+
--configuration / --configuration-file : provide a full JSON blob
|
|
585
|
+
--set PATH=VALUE : set a single nested key (repeatable)
|
|
586
|
+
--merge : deep-merge into existing config (preserves sibling keys)
|
|
587
|
+
--dry-run : preview changes without applying
|
|
588
|
+
|
|
589
|
+
\b
|
|
590
|
+
Examples:
|
|
591
|
+
# Update just the name
|
|
592
|
+
kbagent config update --project P --component-id C --config-id ID --name "New name"
|
|
593
|
+
|
|
594
|
+
# Replace entire configuration from a file
|
|
595
|
+
kbagent config update --project P --component-id C --config-id ID --configuration-file config.json
|
|
596
|
+
|
|
597
|
+
# Deep-merge a partial update (preserves sibling keys!)
|
|
598
|
+
kbagent config update --project P --component-id C --config-id ID \\
|
|
599
|
+
--configuration '{"parameters": {"tables": {"new": "data"}}}' --merge
|
|
600
|
+
|
|
601
|
+
# Set a single nested value
|
|
602
|
+
kbagent config update --project P --component-id C --config-id ID \\
|
|
603
|
+
--set 'parameters.db.host=new-host.example.com'
|
|
604
|
+
|
|
605
|
+
# Preview changes without applying
|
|
606
|
+
kbagent config update --project P --component-id C --config-id ID \\
|
|
607
|
+
--set 'parameters.db.host=new-host' --dry-run
|
|
608
|
+
"""
|
|
609
|
+
formatter = get_formatter(ctx)
|
|
610
|
+
service = get_service(ctx, "config_service")
|
|
611
|
+
|
|
612
|
+
# --- Parse configuration content ------------------------------------------
|
|
613
|
+
config_dict: dict | None = None
|
|
614
|
+
if configuration and configuration_file:
|
|
615
|
+
formatter.error(
|
|
616
|
+
message="Cannot use both --configuration and --configuration-file.",
|
|
617
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
618
|
+
)
|
|
619
|
+
raise typer.Exit(code=2) from None
|
|
620
|
+
|
|
621
|
+
if configuration:
|
|
622
|
+
try:
|
|
623
|
+
config_dict = _parse_json_input(configuration)
|
|
624
|
+
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
|
625
|
+
formatter.error(
|
|
626
|
+
message=f"Invalid --configuration input: {exc}",
|
|
627
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
628
|
+
)
|
|
629
|
+
raise typer.Exit(code=2) from None
|
|
630
|
+
|
|
631
|
+
if configuration_file:
|
|
632
|
+
try:
|
|
633
|
+
config_dict = json.loads(configuration_file.read_text(encoding="utf-8"))
|
|
634
|
+
except json.JSONDecodeError as exc:
|
|
635
|
+
formatter.error(
|
|
636
|
+
message=f"Invalid JSON in {configuration_file}: {exc}",
|
|
637
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
638
|
+
)
|
|
639
|
+
raise typer.Exit(code=2) from None
|
|
640
|
+
|
|
641
|
+
# --- Parse --set values ---------------------------------------------------
|
|
642
|
+
parsed_sets: list[tuple[str, object]] | None = None
|
|
643
|
+
if set_values:
|
|
644
|
+
parsed_sets = []
|
|
645
|
+
for item in set_values:
|
|
646
|
+
if "=" not in item:
|
|
647
|
+
formatter.error(
|
|
648
|
+
message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.",
|
|
649
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
650
|
+
)
|
|
651
|
+
raise typer.Exit(code=2) from None
|
|
652
|
+
path, _, raw_value = item.partition("=")
|
|
653
|
+
parsed_sets.append((path.strip(), _parse_set_value(raw_value.strip())))
|
|
654
|
+
|
|
655
|
+
# --set implies merge
|
|
656
|
+
effective_merge = merge or bool(parsed_sets)
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
result = service.update_config(
|
|
660
|
+
alias=project,
|
|
661
|
+
component_id=component_id,
|
|
662
|
+
config_id=config_id,
|
|
663
|
+
name=name,
|
|
664
|
+
description=description,
|
|
665
|
+
configuration=config_dict,
|
|
666
|
+
set_paths=parsed_sets,
|
|
667
|
+
merge=effective_merge,
|
|
668
|
+
dry_run=dry_run,
|
|
669
|
+
branch_id=branch,
|
|
670
|
+
allow_plaintext_fallback=allow_plaintext,
|
|
671
|
+
)
|
|
672
|
+
except ConfigError as exc:
|
|
673
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
674
|
+
raise typer.Exit(code=5) from None
|
|
675
|
+
except KeboolaApiError as exc:
|
|
676
|
+
formatter.error(
|
|
677
|
+
message=exc.message,
|
|
678
|
+
error_code=exc.error_code,
|
|
679
|
+
retryable=exc.retryable,
|
|
680
|
+
)
|
|
681
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
682
|
+
|
|
683
|
+
# --- Output ---------------------------------------------------------------
|
|
684
|
+
normalizations = result.get("normalizations") or []
|
|
685
|
+
|
|
686
|
+
if result.get("dry_run"):
|
|
687
|
+
changes = result.get("changes", [])
|
|
688
|
+
if formatter.json_mode:
|
|
689
|
+
formatter.output(result)
|
|
690
|
+
else:
|
|
691
|
+
if not changes:
|
|
692
|
+
formatter.success("No changes detected.")
|
|
693
|
+
else:
|
|
694
|
+
formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
|
|
695
|
+
for change in changes:
|
|
696
|
+
formatter.console.print(f" {change}")
|
|
697
|
+
formatter.console.print()
|
|
698
|
+
_emit_normalizations_warning(formatter, normalizations)
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
if formatter.json_mode:
|
|
702
|
+
formatter.output(result)
|
|
703
|
+
else:
|
|
704
|
+
updated_name = result.get("name", "")
|
|
705
|
+
branch_info = ""
|
|
706
|
+
if result.get("branch_id"):
|
|
707
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
708
|
+
formatter.success(
|
|
709
|
+
f"Updated config '{updated_name}' "
|
|
710
|
+
f"({result.get('component_id', component_id)}/{config_id})"
|
|
711
|
+
f"{branch_info}"
|
|
712
|
+
)
|
|
713
|
+
_emit_normalizations_warning(formatter, normalizations)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _emit_normalizations_warning(formatter: Any, normalizations: list[dict[str, Any]]) -> None:
|
|
717
|
+
"""Surface ``parameters.blocks[].codes[].script`` normalizations in human mode.
|
|
718
|
+
|
|
719
|
+
Storage API silently accepts a string for ``script``; the runtime
|
|
720
|
+
validator rejects it later. When kbagent auto-fixes the shape, the
|
|
721
|
+
operator must see what was changed -- otherwise the fix is invisible
|
|
722
|
+
and a downstream consumer might rely on the original (broken) input
|
|
723
|
+
shape having been written verbatim. JSON mode already exposes the
|
|
724
|
+
same data via the ``normalizations`` field on the envelope.
|
|
725
|
+
"""
|
|
726
|
+
if not normalizations:
|
|
727
|
+
return
|
|
728
|
+
formatter.console.print(
|
|
729
|
+
f"[yellow]Auto-normalized {len(normalizations)} script field(s) "
|
|
730
|
+
f"to array (string -> list). See --json for details.[/yellow]"
|
|
731
|
+
)
|
|
732
|
+
for entry in normalizations:
|
|
733
|
+
formatter.console.print(
|
|
734
|
+
f" [dim]{entry['path']}: {entry['action']} -> {entry['after_length']} element(s)[/dim]"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@config_app.command("set-default-bucket", rich_help_panel="Storage")
|
|
739
|
+
def config_set_default_bucket(
|
|
740
|
+
ctx: typer.Context,
|
|
741
|
+
project: str = typer.Option(
|
|
742
|
+
...,
|
|
743
|
+
"--project",
|
|
744
|
+
help="Project alias",
|
|
745
|
+
),
|
|
746
|
+
component_id: str = typer.Option(
|
|
747
|
+
...,
|
|
748
|
+
"--component-id",
|
|
749
|
+
help="Component ID (e.g. keboola.ex-db-snowflake)",
|
|
750
|
+
),
|
|
751
|
+
config_id: str = typer.Option(
|
|
752
|
+
...,
|
|
753
|
+
"--config-id",
|
|
754
|
+
help="Configuration ID",
|
|
755
|
+
),
|
|
756
|
+
bucket: str | None = typer.Option(
|
|
757
|
+
None,
|
|
758
|
+
"--bucket",
|
|
759
|
+
help="Bucket ID to set as default output (e.g. in.c-preferred-name)",
|
|
760
|
+
),
|
|
761
|
+
clear: bool = typer.Option(
|
|
762
|
+
False,
|
|
763
|
+
"--clear",
|
|
764
|
+
help="Remove the default_bucket key. Mutually exclusive with --bucket.",
|
|
765
|
+
),
|
|
766
|
+
dry_run: bool = typer.Option(
|
|
767
|
+
False,
|
|
768
|
+
"--dry-run",
|
|
769
|
+
help="Show what would change without applying",
|
|
770
|
+
),
|
|
771
|
+
branch: int | None = typer.Option(
|
|
772
|
+
None,
|
|
773
|
+
"--branch",
|
|
774
|
+
help="Apply in a specific dev branch ID (defaults to active branch)",
|
|
775
|
+
),
|
|
776
|
+
) -> None:
|
|
777
|
+
"""Set or clear ``storage.output.default_bucket`` on a configuration.
|
|
778
|
+
|
|
779
|
+
\b
|
|
780
|
+
The Keboola Storage API honors ``configuration.storage.output.default_bucket``
|
|
781
|
+
to override the auto-derived bucket name (``in.<component>-<config-id>``)
|
|
782
|
+
for any output table that does not pin its own ``destination``.
|
|
783
|
+
|
|
784
|
+
\b
|
|
785
|
+
Examples:
|
|
786
|
+
# Set
|
|
787
|
+
kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
|
|
788
|
+
--config-id 12345 --bucket in.c-preferred-name
|
|
789
|
+
|
|
790
|
+
# Clear
|
|
791
|
+
kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
|
|
792
|
+
--config-id 12345 --clear
|
|
793
|
+
|
|
794
|
+
# Preview
|
|
795
|
+
kbagent config set-default-bucket --project P --component-id keboola.ex-db-snowflake \\
|
|
796
|
+
--config-id 12345 --bucket in.c-preferred-name --dry-run
|
|
797
|
+
"""
|
|
798
|
+
formatter = get_formatter(ctx)
|
|
799
|
+
service = get_service(ctx, "config_service")
|
|
800
|
+
|
|
801
|
+
if bucket is not None and clear:
|
|
802
|
+
formatter.error(
|
|
803
|
+
message="Pass exactly one of --bucket or --clear, not both.",
|
|
804
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
805
|
+
)
|
|
806
|
+
raise typer.Exit(code=2) from None
|
|
807
|
+
if bucket is None and not clear:
|
|
808
|
+
formatter.error(
|
|
809
|
+
message="Pass --bucket BUCKET_ID or --clear.",
|
|
810
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
811
|
+
)
|
|
812
|
+
raise typer.Exit(code=2) from None
|
|
813
|
+
|
|
814
|
+
try:
|
|
815
|
+
result = service.set_default_bucket(
|
|
816
|
+
alias=project,
|
|
817
|
+
component_id=component_id,
|
|
818
|
+
config_id=config_id,
|
|
819
|
+
bucket=bucket,
|
|
820
|
+
clear=clear,
|
|
821
|
+
dry_run=dry_run,
|
|
822
|
+
branch_id=branch,
|
|
823
|
+
)
|
|
824
|
+
except ConfigError as exc:
|
|
825
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
826
|
+
raise typer.Exit(code=5) from None
|
|
827
|
+
except KeboolaApiError as exc:
|
|
828
|
+
formatter.error(
|
|
829
|
+
message=exc.message,
|
|
830
|
+
error_code=exc.error_code,
|
|
831
|
+
retryable=exc.retryable,
|
|
832
|
+
)
|
|
833
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
834
|
+
|
|
835
|
+
if result.get("dry_run"):
|
|
836
|
+
changes = result.get("changes", [])
|
|
837
|
+
if formatter.json_mode:
|
|
838
|
+
formatter.output(result)
|
|
839
|
+
else:
|
|
840
|
+
if not changes:
|
|
841
|
+
formatter.success("No changes detected.")
|
|
842
|
+
else:
|
|
843
|
+
formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
|
|
844
|
+
for change in changes:
|
|
845
|
+
formatter.console.print(f" {change}")
|
|
846
|
+
formatter.console.print()
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
if formatter.json_mode:
|
|
850
|
+
formatter.output(result)
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
target = f"{component_id}/{config_id}"
|
|
854
|
+
if result.get("changed") is False:
|
|
855
|
+
existing = result.get("default_bucket")
|
|
856
|
+
if clear:
|
|
857
|
+
formatter.success(f"No change: default_bucket was already absent on {target}.")
|
|
858
|
+
else:
|
|
859
|
+
formatter.success(f"No change: default_bucket on {target} was already '{existing}'.")
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
if clear:
|
|
863
|
+
formatter.success(f"Cleared default_bucket on {target}.")
|
|
864
|
+
else:
|
|
865
|
+
formatter.success(f"Set default_bucket on {target} to '{bucket}'.")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@config_app.command("rename", rich_help_panel="Lifecycle")
|
|
869
|
+
def config_rename(
|
|
870
|
+
ctx: typer.Context,
|
|
871
|
+
project: str = typer.Option(
|
|
872
|
+
...,
|
|
873
|
+
"--project",
|
|
874
|
+
help="Project alias",
|
|
875
|
+
),
|
|
876
|
+
component_id: str = typer.Option(
|
|
877
|
+
...,
|
|
878
|
+
"--component-id",
|
|
879
|
+
help="Component ID (e.g. keboola.python-transformation-v2)",
|
|
880
|
+
),
|
|
881
|
+
config_id: str = typer.Option(
|
|
882
|
+
...,
|
|
883
|
+
"--config-id",
|
|
884
|
+
help="Configuration ID to rename",
|
|
885
|
+
),
|
|
886
|
+
name: str = typer.Option(
|
|
887
|
+
...,
|
|
888
|
+
"--name",
|
|
889
|
+
help="New name for the configuration",
|
|
890
|
+
),
|
|
891
|
+
branch: int | None = typer.Option(
|
|
892
|
+
None,
|
|
893
|
+
"--branch",
|
|
894
|
+
help="Rename in a specific dev branch ID (defaults to active branch)",
|
|
895
|
+
),
|
|
896
|
+
directory: Path | None = typer.Option(
|
|
897
|
+
None,
|
|
898
|
+
"--directory",
|
|
899
|
+
"-d",
|
|
900
|
+
help="Sync working directory (auto-detects .keboola/manifest.json in CWD if omitted)",
|
|
901
|
+
),
|
|
902
|
+
) -> None:
|
|
903
|
+
"""Rename a configuration (update name via API + rename local sync directory).
|
|
904
|
+
|
|
905
|
+
Updates the configuration name in the Keboola project. If a local sync
|
|
906
|
+
directory is detected (either via --directory or the current working
|
|
907
|
+
directory), the local folder is renamed and the manifest is updated
|
|
908
|
+
to match.
|
|
909
|
+
|
|
910
|
+
\b
|
|
911
|
+
Examples:
|
|
912
|
+
# Simple rename
|
|
913
|
+
kbagent config rename --project prod --component-id kds-team.app-custom-python \\
|
|
914
|
+
--config-id abc123 --name "Stripe Extractor"
|
|
915
|
+
|
|
916
|
+
# Rename with explicit sync directory
|
|
917
|
+
kbagent config rename --project prod --component-id kds-team.app-custom-python \\
|
|
918
|
+
--config-id abc123 --name "Stripe Extractor" --directory ./my-project
|
|
919
|
+
"""
|
|
920
|
+
formatter = get_formatter(ctx)
|
|
921
|
+
service = get_service(ctx, "config_service")
|
|
922
|
+
|
|
923
|
+
# Auto-detect sync directory from CWD if not specified
|
|
924
|
+
effective_directory = directory
|
|
925
|
+
if effective_directory is None:
|
|
926
|
+
cwd = Path.cwd()
|
|
927
|
+
if (cwd / KEBOOLA_DIR_NAME / MANIFEST_FILENAME).exists():
|
|
928
|
+
effective_directory = cwd
|
|
929
|
+
|
|
930
|
+
try:
|
|
931
|
+
result = service.rename_config(
|
|
932
|
+
alias=project,
|
|
933
|
+
component_id=component_id,
|
|
934
|
+
config_id=config_id,
|
|
935
|
+
name=name,
|
|
936
|
+
branch_id=branch,
|
|
937
|
+
directory=effective_directory,
|
|
938
|
+
)
|
|
939
|
+
except ConfigError as exc:
|
|
940
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
941
|
+
raise typer.Exit(code=5) from None
|
|
942
|
+
except KeboolaApiError as exc:
|
|
943
|
+
formatter.error(
|
|
944
|
+
message=exc.message,
|
|
945
|
+
error_code=exc.error_code,
|
|
946
|
+
retryable=exc.retryable,
|
|
947
|
+
)
|
|
948
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
949
|
+
|
|
950
|
+
if formatter.json_mode:
|
|
951
|
+
formatter.output(result)
|
|
952
|
+
else:
|
|
953
|
+
branch_info = ""
|
|
954
|
+
if result.get("branch_id"):
|
|
955
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
956
|
+
formatter.success(
|
|
957
|
+
f'Renamed "{result["old_name"]}" -> "{result["new_name"]}"'
|
|
958
|
+
f" ({component_id}/{config_id}){branch_info}"
|
|
959
|
+
)
|
|
960
|
+
sync_info = result.get("sync")
|
|
961
|
+
if sync_info:
|
|
962
|
+
formatter.console.print(
|
|
963
|
+
f" Sync: {sync_info['old_path']}/ -> {sync_info['new_path']}/"
|
|
964
|
+
f" ({sync_info['method']})"
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@config_app.command("delete", rich_help_panel="Lifecycle")
|
|
969
|
+
def config_delete(
|
|
970
|
+
ctx: typer.Context,
|
|
971
|
+
project: str = typer.Option(
|
|
972
|
+
...,
|
|
973
|
+
"--project",
|
|
974
|
+
help="Project alias",
|
|
975
|
+
),
|
|
976
|
+
component_id: str = typer.Option(
|
|
977
|
+
...,
|
|
978
|
+
"--component-id",
|
|
979
|
+
help="Component ID (e.g. keboola.python-transformation-v2)",
|
|
980
|
+
),
|
|
981
|
+
config_id: str = typer.Option(
|
|
982
|
+
...,
|
|
983
|
+
"--config-id",
|
|
984
|
+
help="Configuration ID to delete",
|
|
985
|
+
),
|
|
986
|
+
branch: int | None = typer.Option(
|
|
987
|
+
None,
|
|
988
|
+
"--branch",
|
|
989
|
+
help="Delete from a specific dev branch ID (defaults to active branch)",
|
|
990
|
+
),
|
|
991
|
+
) -> None:
|
|
992
|
+
"""Delete a configuration from a project.
|
|
993
|
+
|
|
994
|
+
If a dev branch is active (via 'branch use'), the deletion targets
|
|
995
|
+
that branch. Use --branch to override. Deleting in a branch marks
|
|
996
|
+
the config as removed without affecting Main.
|
|
997
|
+
"""
|
|
998
|
+
formatter = get_formatter(ctx)
|
|
999
|
+
service = get_service(ctx, "config_service")
|
|
1000
|
+
|
|
1001
|
+
try:
|
|
1002
|
+
result = service.delete_config(
|
|
1003
|
+
alias=project,
|
|
1004
|
+
component_id=component_id,
|
|
1005
|
+
config_id=config_id,
|
|
1006
|
+
branch_id=branch,
|
|
1007
|
+
)
|
|
1008
|
+
except ConfigError as exc:
|
|
1009
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1010
|
+
raise typer.Exit(code=5) from None
|
|
1011
|
+
except KeboolaApiError as exc:
|
|
1012
|
+
formatter.error(
|
|
1013
|
+
message=exc.message,
|
|
1014
|
+
error_code=exc.error_code,
|
|
1015
|
+
retryable=exc.retryable,
|
|
1016
|
+
)
|
|
1017
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1018
|
+
|
|
1019
|
+
if formatter.json_mode:
|
|
1020
|
+
formatter.output(result)
|
|
1021
|
+
else:
|
|
1022
|
+
branch_info = ""
|
|
1023
|
+
if result.get("branch_id"):
|
|
1024
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
1025
|
+
formatter.success(
|
|
1026
|
+
f"Deleted config {result['component_id']}/{result['config_id']} "
|
|
1027
|
+
f"from project '{result['project_alias']}'{branch_info}"
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
# --- File extension to Rich Syntax lexer mapping ---
|
|
1032
|
+
_EXT_TO_LEXER: dict[str, str] = {
|
|
1033
|
+
".yml": "yaml",
|
|
1034
|
+
".yaml": "yaml",
|
|
1035
|
+
".json": "json",
|
|
1036
|
+
".sql": "sql",
|
|
1037
|
+
".py": "python",
|
|
1038
|
+
".toml": "toml",
|
|
1039
|
+
".md": "markdown",
|
|
1040
|
+
".txt": "text",
|
|
1041
|
+
".sh": "bash",
|
|
1042
|
+
".r": "r",
|
|
1043
|
+
".js": "javascript",
|
|
1044
|
+
".ts": "typescript",
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@config_app.command("new", rich_help_panel="Lifecycle")
|
|
1049
|
+
def config_new(
|
|
1050
|
+
ctx: typer.Context,
|
|
1051
|
+
component_id: str = typer.Option(
|
|
1052
|
+
...,
|
|
1053
|
+
"--component-id",
|
|
1054
|
+
help="Component ID (e.g. keboola.ex-http)",
|
|
1055
|
+
),
|
|
1056
|
+
name: str = typer.Option(
|
|
1057
|
+
"",
|
|
1058
|
+
"--name",
|
|
1059
|
+
help="Configuration name (default: auto-generated from component; required with --push)",
|
|
1060
|
+
),
|
|
1061
|
+
project: str | None = typer.Option(
|
|
1062
|
+
None,
|
|
1063
|
+
"--project",
|
|
1064
|
+
help="Project alias (for AI Service auth; required with --push)",
|
|
1065
|
+
),
|
|
1066
|
+
output_dir: str | None = typer.Option(
|
|
1067
|
+
None,
|
|
1068
|
+
"--output-dir",
|
|
1069
|
+
help="Write scaffold files to disk instead of printing",
|
|
1070
|
+
),
|
|
1071
|
+
push: bool = typer.Option(
|
|
1072
|
+
False,
|
|
1073
|
+
"--push",
|
|
1074
|
+
help="Also create the configuration remotely via the Storage API (one-shot; requires --project and --name)",
|
|
1075
|
+
),
|
|
1076
|
+
no_files: bool = typer.Option(
|
|
1077
|
+
False,
|
|
1078
|
+
"--no-files",
|
|
1079
|
+
help="With --push: skip writing/printing scaffold; only POST to API (FIIA-style one-shot)",
|
|
1080
|
+
),
|
|
1081
|
+
description: str = typer.Option(
|
|
1082
|
+
"",
|
|
1083
|
+
"--description",
|
|
1084
|
+
help="Configuration description (used with --push)",
|
|
1085
|
+
),
|
|
1086
|
+
configuration: str | None = typer.Option(
|
|
1087
|
+
None,
|
|
1088
|
+
"--configuration",
|
|
1089
|
+
help="Override the configuration body to POST (used with --push): JSON inline, @file, or - for stdin",
|
|
1090
|
+
),
|
|
1091
|
+
configuration_file: Path | None = typer.Option(
|
|
1092
|
+
None,
|
|
1093
|
+
"--configuration-file",
|
|
1094
|
+
help="Override the configuration body from a JSON file (used with --push)",
|
|
1095
|
+
exists=True,
|
|
1096
|
+
readable=True,
|
|
1097
|
+
),
|
|
1098
|
+
no_validate: bool = typer.Option(
|
|
1099
|
+
False,
|
|
1100
|
+
"--no-validate",
|
|
1101
|
+
help="Skip schema validation against the component's AI Service spec (used with --push)",
|
|
1102
|
+
),
|
|
1103
|
+
branch: int | None = typer.Option(
|
|
1104
|
+
None,
|
|
1105
|
+
"--branch",
|
|
1106
|
+
help="Create in a specific dev branch (used with --push; defaults to active branch)",
|
|
1107
|
+
),
|
|
1108
|
+
dry_run: bool = typer.Option(
|
|
1109
|
+
False,
|
|
1110
|
+
"--dry-run",
|
|
1111
|
+
help="With --push: show planned POST + validation result without creating",
|
|
1112
|
+
),
|
|
1113
|
+
allow_plaintext: bool = typer.Option(
|
|
1114
|
+
False,
|
|
1115
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
1116
|
+
help="With --push: allow create even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
|
|
1117
|
+
),
|
|
1118
|
+
) -> None:
|
|
1119
|
+
"""Generate boilerplate configuration files for a Keboola component, optionally creating the config remotely in one shot.
|
|
1120
|
+
|
|
1121
|
+
\b
|
|
1122
|
+
Two modes:
|
|
1123
|
+
* Default (no --push): scaffold only -- generates ready-to-edit files
|
|
1124
|
+
(config YAML, code blocks, description). Writes to --output-dir or
|
|
1125
|
+
prints to stdout. **Zero API calls.**
|
|
1126
|
+
* With --push: scaffold step + POST to Storage API. Requires --project
|
|
1127
|
+
and a non-empty --name. Use --no-files for FIIA-style one-shot create
|
|
1128
|
+
with no filesystem step.
|
|
1129
|
+
|
|
1130
|
+
\b
|
|
1131
|
+
Examples:
|
|
1132
|
+
# Scaffold-only (today's behavior, unchanged)
|
|
1133
|
+
kbagent config new --component-id keboola.ex-http --output-dir ./scratch
|
|
1134
|
+
|
|
1135
|
+
# Scaffold AND remote create:
|
|
1136
|
+
kbagent config new --component-id keboola.ex-http --name "API ingest" \\
|
|
1137
|
+
--project prod --output-dir ./scratch --push
|
|
1138
|
+
|
|
1139
|
+
# FIIA-style: one-shot remote create with no filesystem step:
|
|
1140
|
+
kbagent config new --component-id keboola.ex-http --name "API ingest" \\
|
|
1141
|
+
--project prod --push --no-files
|
|
1142
|
+
|
|
1143
|
+
# Override the POSTed body with a pre-made config:
|
|
1144
|
+
kbagent config new --component-id keboola.python-transformation-v2 \\
|
|
1145
|
+
--name "T1" --project prod --push --no-files \\
|
|
1146
|
+
--configuration-file ./body.json --branch 42
|
|
1147
|
+
|
|
1148
|
+
# Preview the planned POST without creating:
|
|
1149
|
+
kbagent config new --component-id keboola.ex-http --name "smoke" \\
|
|
1150
|
+
--project prod --push --no-files --dry-run
|
|
1151
|
+
"""
|
|
1152
|
+
formatter = get_formatter(ctx)
|
|
1153
|
+
|
|
1154
|
+
# ── Flag-combination validation ──────────────────────────────────────────
|
|
1155
|
+
#
|
|
1156
|
+
# All --push-gated flags must be set only when --push is on; --push itself
|
|
1157
|
+
# requires --project and a non-empty --name; --configuration vs
|
|
1158
|
+
# --configuration-file and --output-dir vs --no-files are mutually
|
|
1159
|
+
# exclusive.
|
|
1160
|
+
push_gated: list[tuple[str, bool]] = [
|
|
1161
|
+
("--no-files", no_files),
|
|
1162
|
+
("--description", bool(description)),
|
|
1163
|
+
("--configuration", configuration is not None),
|
|
1164
|
+
("--configuration-file", configuration_file is not None),
|
|
1165
|
+
("--no-validate", no_validate),
|
|
1166
|
+
("--branch", branch is not None),
|
|
1167
|
+
("--dry-run", dry_run),
|
|
1168
|
+
]
|
|
1169
|
+
if not push:
|
|
1170
|
+
for flag_name, flag_set in push_gated:
|
|
1171
|
+
if flag_set:
|
|
1172
|
+
formatter.error(
|
|
1173
|
+
message=f"{flag_name} requires --push",
|
|
1174
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1175
|
+
)
|
|
1176
|
+
raise typer.Exit(code=2)
|
|
1177
|
+
else:
|
|
1178
|
+
if not project:
|
|
1179
|
+
formatter.error(
|
|
1180
|
+
message="--push requires --project",
|
|
1181
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1182
|
+
)
|
|
1183
|
+
raise typer.Exit(code=2)
|
|
1184
|
+
if not name:
|
|
1185
|
+
formatter.error(
|
|
1186
|
+
message="--push requires a non-empty --name",
|
|
1187
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1188
|
+
)
|
|
1189
|
+
raise typer.Exit(code=2)
|
|
1190
|
+
if configuration is not None and configuration_file is not None:
|
|
1191
|
+
formatter.error(
|
|
1192
|
+
message="--configuration and --configuration-file are mutually exclusive",
|
|
1193
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1194
|
+
)
|
|
1195
|
+
raise typer.Exit(code=2)
|
|
1196
|
+
if no_files and output_dir:
|
|
1197
|
+
formatter.error(
|
|
1198
|
+
message="--no-files and --output-dir are mutually exclusive",
|
|
1199
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1200
|
+
)
|
|
1201
|
+
raise typer.Exit(code=2)
|
|
1202
|
+
|
|
1203
|
+
# ── Parse the optional body override (used only with --push) ─────────────
|
|
1204
|
+
config_body: dict[str, Any] | None = None
|
|
1205
|
+
if push:
|
|
1206
|
+
if configuration is not None:
|
|
1207
|
+
try:
|
|
1208
|
+
config_body = _parse_json_input(configuration)
|
|
1209
|
+
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
|
1210
|
+
formatter.error(
|
|
1211
|
+
message=f"Invalid --configuration input: {exc}",
|
|
1212
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1213
|
+
)
|
|
1214
|
+
raise typer.Exit(code=2) from None
|
|
1215
|
+
elif configuration_file is not None:
|
|
1216
|
+
try:
|
|
1217
|
+
config_body = json.loads(configuration_file.read_text(encoding="utf-8"))
|
|
1218
|
+
except json.JSONDecodeError as exc:
|
|
1219
|
+
formatter.error(
|
|
1220
|
+
message=f"Invalid JSON in --configuration-file {configuration_file}: {exc}",
|
|
1221
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1222
|
+
)
|
|
1223
|
+
raise typer.Exit(code=2) from None
|
|
1224
|
+
|
|
1225
|
+
# ── Scaffold step (runs in scaffold-only mode and in push+files modes) ───
|
|
1226
|
+
scaffold: dict[str, Any] | None = None
|
|
1227
|
+
skip_scaffold = push and no_files
|
|
1228
|
+
if not skip_scaffold:
|
|
1229
|
+
component_service = get_service(ctx, "component_service")
|
|
1230
|
+
try:
|
|
1231
|
+
scaffold = component_service.generate_scaffold(
|
|
1232
|
+
alias=project,
|
|
1233
|
+
component_id=component_id,
|
|
1234
|
+
name=name or None,
|
|
1235
|
+
)
|
|
1236
|
+
except ConfigError as exc:
|
|
1237
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1238
|
+
raise typer.Exit(code=5) from None
|
|
1239
|
+
except KeboolaApiError as exc:
|
|
1240
|
+
exit_code = map_error_to_exit_code(exc)
|
|
1241
|
+
formatter.error(
|
|
1242
|
+
message=exc.message,
|
|
1243
|
+
error_code=exc.error_code,
|
|
1244
|
+
project=project or "",
|
|
1245
|
+
retryable=exc.retryable,
|
|
1246
|
+
)
|
|
1247
|
+
raise typer.Exit(code=exit_code) from None
|
|
1248
|
+
|
|
1249
|
+
# ── Push path: also create remotely via Storage API ──────────────────────
|
|
1250
|
+
if push:
|
|
1251
|
+
config_service = get_service(ctx, "config_service")
|
|
1252
|
+
try:
|
|
1253
|
+
push_result = config_service.create_config(
|
|
1254
|
+
alias=project,
|
|
1255
|
+
component_id=component_id,
|
|
1256
|
+
name=name,
|
|
1257
|
+
description=description,
|
|
1258
|
+
configuration=config_body,
|
|
1259
|
+
branch_id=branch,
|
|
1260
|
+
dry_run=dry_run,
|
|
1261
|
+
validate=not no_validate,
|
|
1262
|
+
allow_plaintext_fallback=allow_plaintext,
|
|
1263
|
+
)
|
|
1264
|
+
except ConfigError as exc:
|
|
1265
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1266
|
+
raise typer.Exit(code=5) from None
|
|
1267
|
+
except KeboolaApiError as exc:
|
|
1268
|
+
exit_code = map_error_to_exit_code(exc)
|
|
1269
|
+
formatter.error(
|
|
1270
|
+
message=exc.message,
|
|
1271
|
+
error_code=exc.error_code,
|
|
1272
|
+
project=project or "",
|
|
1273
|
+
retryable=exc.retryable,
|
|
1274
|
+
)
|
|
1275
|
+
raise typer.Exit(code=exit_code) from None
|
|
1276
|
+
|
|
1277
|
+
# When --push is set AND --output-dir is given AND we're not in
|
|
1278
|
+
# dry-run mode, ALSO write the scaffold to disk in addition to the
|
|
1279
|
+
# POST (the "scaffold + push" combo). Dry-run must NOT touch the
|
|
1280
|
+
# filesystem -- the user expects a preview, not a side effect.
|
|
1281
|
+
#
|
|
1282
|
+
# ``silent=True``: the push-result envelope below is the authoritative
|
|
1283
|
+
# output for this path. In JSON mode emitting the scaffold envelope
|
|
1284
|
+
# too would produce two concatenated JSON documents on stdout (breaks
|
|
1285
|
+
# ``jq``); in human mode the dim banner adds noise above the "Created
|
|
1286
|
+
# config ..." line that already lists the same path. ``json_mode`` is
|
|
1287
|
+
# passed as a sentinel ``False`` because ``silent=True`` short-circuits
|
|
1288
|
+
# before it is read.
|
|
1289
|
+
if output_dir and scaffold is not None and not dry_run:
|
|
1290
|
+
_write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=False, silent=True)
|
|
1291
|
+
|
|
1292
|
+
if formatter.json_mode:
|
|
1293
|
+
formatter.output(push_result)
|
|
1294
|
+
else:
|
|
1295
|
+
_render_push_result_human(formatter, push_result, component_id, name)
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
# ── Scaffold-only path: today's behavior, byte-for-byte unchanged ────────
|
|
1299
|
+
assert scaffold is not None # narrowing for type-checker; skip_scaffold is False here.
|
|
1300
|
+
if output_dir:
|
|
1301
|
+
_write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=formatter.json_mode)
|
|
1302
|
+
else:
|
|
1303
|
+
# Print scaffold content
|
|
1304
|
+
if formatter.json_mode:
|
|
1305
|
+
formatter.output(scaffold)
|
|
1306
|
+
else:
|
|
1307
|
+
formatter.console.print(f"\n[bold]Scaffold for [cyan]{component_id}[/cyan][/bold]")
|
|
1308
|
+
formatter.console.print(f"[dim]Directory: {scaffold['directory']}[/dim]\n")
|
|
1309
|
+
|
|
1310
|
+
for file_entry in scaffold["files"]:
|
|
1311
|
+
file_name = file_entry["path"]
|
|
1312
|
+
content = file_entry["content"]
|
|
1313
|
+
|
|
1314
|
+
# Determine lexer from file extension
|
|
1315
|
+
suffix = Path(file_name).suffix.lower()
|
|
1316
|
+
lexer = _EXT_TO_LEXER.get(suffix, "text")
|
|
1317
|
+
|
|
1318
|
+
formatter.console.rule(f"[bold]{file_name}[/bold]")
|
|
1319
|
+
syntax = Syntax(
|
|
1320
|
+
content,
|
|
1321
|
+
lexer,
|
|
1322
|
+
theme="monokai",
|
|
1323
|
+
line_numbers=True,
|
|
1324
|
+
)
|
|
1325
|
+
formatter.console.print(syntax)
|
|
1326
|
+
formatter.console.print()
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _write_scaffold_to_disk(
|
|
1330
|
+
formatter: Any,
|
|
1331
|
+
scaffold: dict[str, Any],
|
|
1332
|
+
output_dir: str,
|
|
1333
|
+
json_mode: bool,
|
|
1334
|
+
silent: bool = False,
|
|
1335
|
+
) -> None:
|
|
1336
|
+
"""Shared helper: write the generated scaffold files under ``output_dir``.
|
|
1337
|
+
|
|
1338
|
+
Detects an enclosing ``main/`` branch prefix the same way the pre-push
|
|
1339
|
+
path does, so the layout matches what ``kbagent sync push`` would expect.
|
|
1340
|
+
|
|
1341
|
+
Output rules:
|
|
1342
|
+
- ``silent=True``: write files only; emit no banner and no JSON envelope.
|
|
1343
|
+
Used by the ``--push --output-dir`` path, where the push-result envelope
|
|
1344
|
+
(single JSON in JSON mode; the "Created config ..." line in human mode)
|
|
1345
|
+
is the authoritative output and the scaffold-banner side-channel would
|
|
1346
|
+
either duplicate JSON (breaking ``jq`` pipes) or distract in human mode.
|
|
1347
|
+
- ``silent=False`` (default; scaffold-only mode): emit a JSON envelope
|
|
1348
|
+
``{directory, files_written}`` when ``json_mode`` is set, otherwise a
|
|
1349
|
+
dim-formatted "Scaffold written to ..." banner.
|
|
1350
|
+
"""
|
|
1351
|
+
branch_prefix = _detect_branch_prefix(Path(output_dir))
|
|
1352
|
+
if branch_prefix:
|
|
1353
|
+
scaffold_dir = branch_prefix + "/" + scaffold["directory"]
|
|
1354
|
+
else:
|
|
1355
|
+
scaffold_dir = scaffold["directory"]
|
|
1356
|
+
|
|
1357
|
+
base_path = Path(output_dir) / scaffold_dir
|
|
1358
|
+
base_path.mkdir(parents=True, exist_ok=True)
|
|
1359
|
+
|
|
1360
|
+
for file_entry in scaffold["files"]:
|
|
1361
|
+
file_path = base_path / file_entry["path"]
|
|
1362
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1363
|
+
file_path.write_text(file_entry["content"], encoding="utf-8")
|
|
1364
|
+
|
|
1365
|
+
if silent:
|
|
1366
|
+
return
|
|
1367
|
+
|
|
1368
|
+
if json_mode:
|
|
1369
|
+
formatter.output(
|
|
1370
|
+
{
|
|
1371
|
+
"directory": str(base_path),
|
|
1372
|
+
"files_written": [f["path"] for f in scaffold["files"]],
|
|
1373
|
+
}
|
|
1374
|
+
)
|
|
1375
|
+
else:
|
|
1376
|
+
formatter.console.print(
|
|
1377
|
+
f"[dim]Scaffold written to {base_path} ({len(scaffold['files'])} file(s))[/dim]"
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _render_push_result_human(
|
|
1382
|
+
formatter: Any,
|
|
1383
|
+
result: dict[str, Any],
|
|
1384
|
+
component_id: str,
|
|
1385
|
+
name: str,
|
|
1386
|
+
) -> None:
|
|
1387
|
+
"""Render the human-mode banner for ``config new --push`` (dry-run + success)."""
|
|
1388
|
+
validation_status = result.get("validation_status", "skipped")
|
|
1389
|
+
validation_errors = result.get("validation_errors", []) or []
|
|
1390
|
+
branch_info = f" (branch {result['branch_id']})" if result.get("branch_id") else ""
|
|
1391
|
+
|
|
1392
|
+
if result.get("dry_run"):
|
|
1393
|
+
formatter.console.print(
|
|
1394
|
+
f"\n[bold]Dry-run -- would POST {escape(component_id)} '{escape(name)}'{branch_info}[/bold]\n"
|
|
1395
|
+
)
|
|
1396
|
+
formatter.console.print("[dim]Planned configuration body:[/dim]")
|
|
1397
|
+
formatter.console.print(json.dumps(result.get("configuration", {}), indent=2))
|
|
1398
|
+
if validation_status == "ok":
|
|
1399
|
+
formatter.console.print("\n[green]✓ Schema validation passed[/green]")
|
|
1400
|
+
elif validation_status == "skipped":
|
|
1401
|
+
formatter.console.print(
|
|
1402
|
+
"\n[yellow]⚠ Schema validation skipped[/yellow] "
|
|
1403
|
+
"[dim](empty shell, no schema available, or --no-validate)[/dim]"
|
|
1404
|
+
)
|
|
1405
|
+
else: # failed
|
|
1406
|
+
formatter.console.print("\n[red]✗ Schema validation failed:[/red]")
|
|
1407
|
+
for err in validation_errors:
|
|
1408
|
+
formatter.console.print(f" [red]• {escape(err)}[/red]")
|
|
1409
|
+
return
|
|
1410
|
+
|
|
1411
|
+
config_id = result.get("id", "")
|
|
1412
|
+
formatter.success(
|
|
1413
|
+
f"Created config '{escape(name)}' [{config_id}] in {escape(component_id)}{branch_info}"
|
|
1414
|
+
)
|
|
1415
|
+
if validation_status == "skipped":
|
|
1416
|
+
formatter.console.print(
|
|
1417
|
+
"[dim]Note: schema validation was skipped "
|
|
1418
|
+
"(empty shell, no schema available, or --no-validate).[/dim]"
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
# ── Config metadata commands ───────────────────────────────────────────
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
@config_app.command("metadata-list", rich_help_panel="Metadata")
|
|
1426
|
+
def config_metadata_list(
|
|
1427
|
+
ctx: typer.Context,
|
|
1428
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1429
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
1430
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
1431
|
+
branch: int | None = typer.Option(
|
|
1432
|
+
None, "--branch", help="Dev branch ID (defaults to active branch)"
|
|
1433
|
+
),
|
|
1434
|
+
) -> None:
|
|
1435
|
+
"""List all metadata entries on a configuration."""
|
|
1436
|
+
formatter = get_formatter(ctx)
|
|
1437
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1438
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1439
|
+
service = get_service(ctx, "config_service")
|
|
1440
|
+
try:
|
|
1441
|
+
result = service.list_config_metadata(
|
|
1442
|
+
alias=project,
|
|
1443
|
+
component_id=component_id,
|
|
1444
|
+
config_id=config_id,
|
|
1445
|
+
branch_id=effective_branch,
|
|
1446
|
+
)
|
|
1447
|
+
except ConfigError as exc:
|
|
1448
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1449
|
+
raise typer.Exit(code=5) from None
|
|
1450
|
+
except KeboolaApiError as exc:
|
|
1451
|
+
formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
|
|
1452
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1453
|
+
|
|
1454
|
+
if formatter.json_mode:
|
|
1455
|
+
formatter.output(result)
|
|
1456
|
+
else:
|
|
1457
|
+
entries = result.get("metadata", [])
|
|
1458
|
+
if not entries:
|
|
1459
|
+
formatter.console.print("[dim]No metadata entries.[/dim]")
|
|
1460
|
+
else:
|
|
1461
|
+
for e in entries:
|
|
1462
|
+
formatter.console.print(
|
|
1463
|
+
f" [dim]{escape(str(e.get('id', '')))}[/dim] [green]{escape(e.get('key', ''))}[/green] = {escape(str(e.get('value', '')))} [dim]{escape(e.get('provider', 'user'))}[/dim]"
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
@config_app.command("get-metadata", rich_help_panel="Metadata")
|
|
1468
|
+
def config_get_metadata(
|
|
1469
|
+
ctx: typer.Context,
|
|
1470
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1471
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
1472
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
1473
|
+
key: str = typer.Option(..., "--key", help="Metadata key to read"),
|
|
1474
|
+
branch: int | None = typer.Option(
|
|
1475
|
+
None, "--branch", help="Dev branch ID (defaults to active branch)"
|
|
1476
|
+
),
|
|
1477
|
+
) -> None:
|
|
1478
|
+
"""Read a single metadata value by key.
|
|
1479
|
+
|
|
1480
|
+
Exits with code 1 (NOT_FOUND) if the key is not present.
|
|
1481
|
+
"""
|
|
1482
|
+
formatter = get_formatter(ctx)
|
|
1483
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1484
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1485
|
+
service = get_service(ctx, "config_service")
|
|
1486
|
+
try:
|
|
1487
|
+
result = service.get_config_metadata_value(
|
|
1488
|
+
alias=project,
|
|
1489
|
+
component_id=component_id,
|
|
1490
|
+
config_id=config_id,
|
|
1491
|
+
key=key,
|
|
1492
|
+
branch_id=effective_branch,
|
|
1493
|
+
)
|
|
1494
|
+
except ConfigError as exc:
|
|
1495
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1496
|
+
raise typer.Exit(code=5) from None
|
|
1497
|
+
except KeboolaApiError as exc:
|
|
1498
|
+
formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
|
|
1499
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1500
|
+
formatter.output(result, lambda c, d: c.print(d["value"]))
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
@config_app.command("set-metadata", rich_help_panel="Metadata")
|
|
1504
|
+
def config_set_metadata(
|
|
1505
|
+
ctx: typer.Context,
|
|
1506
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1507
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
1508
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
1509
|
+
key: str = typer.Option(..., "--key", help="Metadata key to set"),
|
|
1510
|
+
value: str = typer.Option(..., "--value", help="Metadata value (string)"),
|
|
1511
|
+
branch: int | None = typer.Option(
|
|
1512
|
+
None, "--branch", help="Dev branch ID (defaults to active branch)"
|
|
1513
|
+
),
|
|
1514
|
+
) -> None:
|
|
1515
|
+
"""Set a metadata key/value on a configuration (upsert)."""
|
|
1516
|
+
formatter = get_formatter(ctx)
|
|
1517
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1518
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1519
|
+
service = get_service(ctx, "config_service")
|
|
1520
|
+
try:
|
|
1521
|
+
result = service.set_config_metadata(
|
|
1522
|
+
alias=project,
|
|
1523
|
+
component_id=component_id,
|
|
1524
|
+
config_id=config_id,
|
|
1525
|
+
key=key,
|
|
1526
|
+
value=value,
|
|
1527
|
+
branch_id=effective_branch,
|
|
1528
|
+
)
|
|
1529
|
+
except ConfigError as exc:
|
|
1530
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1531
|
+
raise typer.Exit(code=5) from None
|
|
1532
|
+
except KeboolaApiError as exc:
|
|
1533
|
+
formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
|
|
1534
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1535
|
+
formatter.output(
|
|
1536
|
+
result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
@config_app.command("delete-metadata", rich_help_panel="Metadata")
|
|
1541
|
+
def config_delete_metadata(
|
|
1542
|
+
ctx: typer.Context,
|
|
1543
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1544
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
1545
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
1546
|
+
metadata_id: int = typer.Option(..., "--metadata-id", help="Numeric ID from metadata-list"),
|
|
1547
|
+
branch: int | None = typer.Option(
|
|
1548
|
+
None, "--branch", help="Dev branch ID (defaults to active branch)"
|
|
1549
|
+
),
|
|
1550
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
1551
|
+
) -> None:
|
|
1552
|
+
"""Delete a configuration metadata entry by its numeric ID."""
|
|
1553
|
+
formatter = get_formatter(ctx)
|
|
1554
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1555
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1556
|
+
|
|
1557
|
+
if (
|
|
1558
|
+
not yes
|
|
1559
|
+
and not formatter.json_mode
|
|
1560
|
+
and not typer.confirm(f"Delete metadata ID {metadata_id} from {component_id}/{config_id}?")
|
|
1561
|
+
):
|
|
1562
|
+
formatter.console.print("Aborted.")
|
|
1563
|
+
raise typer.Exit(code=0)
|
|
1564
|
+
|
|
1565
|
+
service = get_service(ctx, "config_service")
|
|
1566
|
+
try:
|
|
1567
|
+
result = service.delete_config_metadata(
|
|
1568
|
+
alias=project,
|
|
1569
|
+
component_id=component_id,
|
|
1570
|
+
config_id=config_id,
|
|
1571
|
+
metadata_id=metadata_id,
|
|
1572
|
+
branch_id=effective_branch,
|
|
1573
|
+
)
|
|
1574
|
+
except ConfigError as exc:
|
|
1575
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1576
|
+
raise typer.Exit(code=5) from None
|
|
1577
|
+
except KeboolaApiError as exc:
|
|
1578
|
+
formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
|
|
1579
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1580
|
+
formatter.output(
|
|
1581
|
+
result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
@config_app.command("set-folder", rich_help_panel="Metadata")
|
|
1586
|
+
def config_set_folder(
|
|
1587
|
+
ctx: typer.Context,
|
|
1588
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1589
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
1590
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
1591
|
+
name: str = typer.Option(..., "--name", help="Folder name (empty string to clear)"),
|
|
1592
|
+
branch: int | None = typer.Option(
|
|
1593
|
+
None, "--branch", help="Dev branch ID (defaults to active branch)"
|
|
1594
|
+
),
|
|
1595
|
+
) -> None:
|
|
1596
|
+
"""Set the folder (KBC.configuration.folderName) on a configuration.
|
|
1597
|
+
|
|
1598
|
+
Organises configs into named groups in the Keboola UI.
|
|
1599
|
+
Pass an empty string to remove the folder assignment.
|
|
1600
|
+
"""
|
|
1601
|
+
formatter = get_formatter(ctx)
|
|
1602
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1603
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1604
|
+
service = get_service(ctx, "config_service")
|
|
1605
|
+
try:
|
|
1606
|
+
result = service.set_config_folder(
|
|
1607
|
+
alias=project,
|
|
1608
|
+
component_id=component_id,
|
|
1609
|
+
config_id=config_id,
|
|
1610
|
+
folder_name=name,
|
|
1611
|
+
branch_id=effective_branch,
|
|
1612
|
+
)
|
|
1613
|
+
except ConfigError as exc:
|
|
1614
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1615
|
+
raise typer.Exit(code=5) from None
|
|
1616
|
+
except KeboolaApiError as exc:
|
|
1617
|
+
formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
|
|
1618
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1619
|
+
formatter.output(
|
|
1620
|
+
result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _parse_kv_var(raw: str) -> tuple[str, str]:
|
|
1625
|
+
"""Split a ``KEY=VALUE`` token into ``(key, value)``; ``#``-prefix preserved."""
|
|
1626
|
+
if "=" not in raw:
|
|
1627
|
+
raise typer.BadParameter(
|
|
1628
|
+
f"Invalid --var: '{raw}'. Expected KEY=VALUE (use # prefix for secrets)."
|
|
1629
|
+
)
|
|
1630
|
+
key, _, value = raw.partition("=")
|
|
1631
|
+
key = key.strip()
|
|
1632
|
+
if not key:
|
|
1633
|
+
raise typer.BadParameter(f"Invalid --var: '{raw}'. Empty key.")
|
|
1634
|
+
return key, value
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
@config_app.command("variables-set", rich_help_panel="Variables")
|
|
1638
|
+
def config_variables_set(
|
|
1639
|
+
ctx: typer.Context,
|
|
1640
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1641
|
+
component_id: str = typer.Option(
|
|
1642
|
+
..., "--component-id", help="Component ID of the config to attach variables to"
|
|
1643
|
+
),
|
|
1644
|
+
config_id: str = typer.Option(
|
|
1645
|
+
..., "--config-id", help="Configuration ID to attach variables to"
|
|
1646
|
+
),
|
|
1647
|
+
variable: list[str] | None = typer.Option(
|
|
1648
|
+
None,
|
|
1649
|
+
"--var",
|
|
1650
|
+
help="Variable as KEY=VALUE (repeatable). Prefix key with # to mark as a secret (auto-encrypted).",
|
|
1651
|
+
),
|
|
1652
|
+
replace: bool = typer.Option(
|
|
1653
|
+
False,
|
|
1654
|
+
"--replace",
|
|
1655
|
+
help="Replace ALL variable values instead of merging (drops any keys not in --var).",
|
|
1656
|
+
),
|
|
1657
|
+
variables_id: str | None = typer.Option(
|
|
1658
|
+
None,
|
|
1659
|
+
"--variables-id",
|
|
1660
|
+
help="Attach parent to an existing keboola.variables config (skips auto-create).",
|
|
1661
|
+
),
|
|
1662
|
+
values_id: str | None = typer.Option(
|
|
1663
|
+
None,
|
|
1664
|
+
"--values-id",
|
|
1665
|
+
help="Attach to a specific values row (defaults to the first row).",
|
|
1666
|
+
),
|
|
1667
|
+
branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
|
|
1668
|
+
dry_run: bool = typer.Option(
|
|
1669
|
+
False, "--dry-run", help="Preview the change without writing to Keboola."
|
|
1670
|
+
),
|
|
1671
|
+
allow_plaintext: bool = typer.Option(
|
|
1672
|
+
False,
|
|
1673
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
1674
|
+
help="Fall back to plaintext if encryption fails (NOT recommended).",
|
|
1675
|
+
),
|
|
1676
|
+
) -> None:
|
|
1677
|
+
"""Assign variables to a config (auto-creates backing keboola.variables on first call).
|
|
1678
|
+
|
|
1679
|
+
Variables are presented as a flat KEY=VALUE dict. The implementation detail
|
|
1680
|
+
that Keboola stores them as a separate keboola.variables configuration with
|
|
1681
|
+
rows is hidden: first call creates the sibling config named
|
|
1682
|
+
<parent-name>-vars + default row; subsequent calls update the same row.
|
|
1683
|
+
"""
|
|
1684
|
+
formatter = get_formatter(ctx)
|
|
1685
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1686
|
+
|
|
1687
|
+
raw_vars = variable or []
|
|
1688
|
+
if not raw_vars:
|
|
1689
|
+
formatter.error(
|
|
1690
|
+
message="At least one --var KEY=VALUE is required.",
|
|
1691
|
+
error_code=ErrorCode.INVALID_ARGUMENT,
|
|
1692
|
+
)
|
|
1693
|
+
raise typer.Exit(code=2)
|
|
1694
|
+
|
|
1695
|
+
variables_dict: dict[str, str] = {}
|
|
1696
|
+
for raw in raw_vars:
|
|
1697
|
+
try:
|
|
1698
|
+
key, value = _parse_kv_var(raw)
|
|
1699
|
+
except typer.BadParameter as exc:
|
|
1700
|
+
formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
|
|
1701
|
+
raise typer.Exit(code=2) from None
|
|
1702
|
+
variables_dict[key] = value
|
|
1703
|
+
|
|
1704
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1705
|
+
|
|
1706
|
+
service = get_service(ctx, "variables_service")
|
|
1707
|
+
|
|
1708
|
+
if dry_run:
|
|
1709
|
+
try:
|
|
1710
|
+
current = service.get_variables(
|
|
1711
|
+
alias=project,
|
|
1712
|
+
component_id=component_id,
|
|
1713
|
+
config_id=config_id,
|
|
1714
|
+
branch_id=effective_branch,
|
|
1715
|
+
)
|
|
1716
|
+
except KeboolaApiError as exc:
|
|
1717
|
+
formatter.error(
|
|
1718
|
+
message=exc.message,
|
|
1719
|
+
error_code=exc.error_code,
|
|
1720
|
+
retryable=exc.retryable,
|
|
1721
|
+
)
|
|
1722
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1723
|
+
except ConfigError as exc:
|
|
1724
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1725
|
+
raise typer.Exit(code=5) from None
|
|
1726
|
+
|
|
1727
|
+
preview_values = (
|
|
1728
|
+
dict(variables_dict) if replace else {**current["values"], **variables_dict}
|
|
1729
|
+
)
|
|
1730
|
+
result = {
|
|
1731
|
+
"dry_run": True,
|
|
1732
|
+
"project_alias": project,
|
|
1733
|
+
"parent_component_id": component_id,
|
|
1734
|
+
"parent_config_id": config_id,
|
|
1735
|
+
"was_linked": current["linked"],
|
|
1736
|
+
"current_variables_id": current["variables_id"],
|
|
1737
|
+
"current_values": current["values"],
|
|
1738
|
+
"would_write": preview_values,
|
|
1739
|
+
"action": "would_create" if not current["linked"] else "would_update",
|
|
1740
|
+
}
|
|
1741
|
+
if formatter.json_mode:
|
|
1742
|
+
formatter.output(result)
|
|
1743
|
+
else:
|
|
1744
|
+
_format_variables_dry_run(formatter, result)
|
|
1745
|
+
return
|
|
1746
|
+
|
|
1747
|
+
try:
|
|
1748
|
+
result = service.set_variables(
|
|
1749
|
+
alias=project,
|
|
1750
|
+
component_id=component_id,
|
|
1751
|
+
config_id=config_id,
|
|
1752
|
+
variables=variables_dict,
|
|
1753
|
+
replace=replace,
|
|
1754
|
+
variables_id=variables_id,
|
|
1755
|
+
values_id=values_id,
|
|
1756
|
+
branch_id=effective_branch,
|
|
1757
|
+
allow_plaintext_fallback=allow_plaintext,
|
|
1758
|
+
)
|
|
1759
|
+
except KeboolaApiError as exc:
|
|
1760
|
+
formatter.error(
|
|
1761
|
+
message=exc.message,
|
|
1762
|
+
error_code=exc.error_code,
|
|
1763
|
+
retryable=exc.retryable,
|
|
1764
|
+
)
|
|
1765
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1766
|
+
except ConfigError as exc:
|
|
1767
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1768
|
+
raise typer.Exit(code=5) from None
|
|
1769
|
+
|
|
1770
|
+
if formatter.json_mode:
|
|
1771
|
+
formatter.output(result)
|
|
1772
|
+
else:
|
|
1773
|
+
_format_variables_set(formatter, result)
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
@config_app.command("variables-get", rich_help_panel="Variables")
|
|
1777
|
+
def config_variables_get(
|
|
1778
|
+
ctx: typer.Context,
|
|
1779
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1780
|
+
component_id: str = typer.Option(
|
|
1781
|
+
..., "--component-id", help="Component ID of the config whose variables to read"
|
|
1782
|
+
),
|
|
1783
|
+
config_id: str = typer.Option(
|
|
1784
|
+
..., "--config-id", help="Configuration ID whose variables to read"
|
|
1785
|
+
),
|
|
1786
|
+
branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
|
|
1787
|
+
) -> None:
|
|
1788
|
+
"""Read the current variable values attached to a config."""
|
|
1789
|
+
formatter = get_formatter(ctx)
|
|
1790
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1791
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1792
|
+
|
|
1793
|
+
service = get_service(ctx, "variables_service")
|
|
1794
|
+
try:
|
|
1795
|
+
result = service.get_variables(
|
|
1796
|
+
alias=project,
|
|
1797
|
+
component_id=component_id,
|
|
1798
|
+
config_id=config_id,
|
|
1799
|
+
branch_id=effective_branch,
|
|
1800
|
+
)
|
|
1801
|
+
except KeboolaApiError as exc:
|
|
1802
|
+
formatter.error(
|
|
1803
|
+
message=exc.message,
|
|
1804
|
+
error_code=exc.error_code,
|
|
1805
|
+
retryable=exc.retryable,
|
|
1806
|
+
)
|
|
1807
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1808
|
+
except ConfigError as exc:
|
|
1809
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1810
|
+
raise typer.Exit(code=5) from None
|
|
1811
|
+
|
|
1812
|
+
if formatter.json_mode:
|
|
1813
|
+
formatter.output(result)
|
|
1814
|
+
else:
|
|
1815
|
+
_format_variables_get(formatter, result)
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
@config_app.command("variables-clear", rich_help_panel="Variables")
|
|
1819
|
+
def config_variables_clear(
|
|
1820
|
+
ctx: typer.Context,
|
|
1821
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1822
|
+
component_id: str = typer.Option(
|
|
1823
|
+
..., "--component-id", help="Component ID of the config to unlink"
|
|
1824
|
+
),
|
|
1825
|
+
config_id: str = typer.Option(
|
|
1826
|
+
..., "--config-id", help="Configuration ID to unlink variables from"
|
|
1827
|
+
),
|
|
1828
|
+
branch: int | None = typer.Option(None, "--branch", help="Development branch ID (per-project)"),
|
|
1829
|
+
yes: bool = typer.Option(
|
|
1830
|
+
False,
|
|
1831
|
+
"--yes",
|
|
1832
|
+
"-y",
|
|
1833
|
+
help="Skip confirmation prompt.",
|
|
1834
|
+
),
|
|
1835
|
+
) -> None:
|
|
1836
|
+
"""Unlink variables from a config (does NOT delete the underlying keboola.variables)."""
|
|
1837
|
+
formatter = get_formatter(ctx)
|
|
1838
|
+
config_store: ConfigStore = ctx.obj["config_store"]
|
|
1839
|
+
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
|
|
1840
|
+
|
|
1841
|
+
if not yes and not formatter.json_mode:
|
|
1842
|
+
confirmed = typer.confirm(
|
|
1843
|
+
f"Unlink variables from {component_id}/{config_id}? "
|
|
1844
|
+
"(The underlying variables config will NOT be deleted.)"
|
|
1845
|
+
)
|
|
1846
|
+
if not confirmed:
|
|
1847
|
+
formatter.console.print("[yellow]Aborted.[/yellow]")
|
|
1848
|
+
raise typer.Exit(code=0)
|
|
1849
|
+
|
|
1850
|
+
service = get_service(ctx, "variables_service")
|
|
1851
|
+
try:
|
|
1852
|
+
result = service.clear_variables(
|
|
1853
|
+
alias=project,
|
|
1854
|
+
component_id=component_id,
|
|
1855
|
+
config_id=config_id,
|
|
1856
|
+
branch_id=effective_branch,
|
|
1857
|
+
)
|
|
1858
|
+
except KeboolaApiError as exc:
|
|
1859
|
+
formatter.error(
|
|
1860
|
+
message=exc.message,
|
|
1861
|
+
error_code=exc.error_code,
|
|
1862
|
+
retryable=exc.retryable,
|
|
1863
|
+
)
|
|
1864
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1865
|
+
except ConfigError as exc:
|
|
1866
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1867
|
+
raise typer.Exit(code=5) from None
|
|
1868
|
+
|
|
1869
|
+
if formatter.json_mode:
|
|
1870
|
+
formatter.output(result)
|
|
1871
|
+
else:
|
|
1872
|
+
_format_variables_clear(formatter, result)
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def _format_variables_get(formatter: Any, result: dict) -> None:
|
|
1876
|
+
if not result.get("linked"):
|
|
1877
|
+
formatter.console.print(
|
|
1878
|
+
"[yellow]No variables linked[/yellow] to "
|
|
1879
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1880
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan]."
|
|
1881
|
+
)
|
|
1882
|
+
return
|
|
1883
|
+
|
|
1884
|
+
formatter.console.print(
|
|
1885
|
+
f"[bold]Variables on[/bold] "
|
|
1886
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1887
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan] "
|
|
1888
|
+
f"[dim](variables_id={escape(result['variables_id'] or '')}, "
|
|
1889
|
+
f"values_id={escape(result['values_id'] or '')})[/dim]"
|
|
1890
|
+
)
|
|
1891
|
+
if not result["values"]:
|
|
1892
|
+
formatter.console.print(" [dim](no values set)[/dim]")
|
|
1893
|
+
return
|
|
1894
|
+
for k in sorted(result["values"]):
|
|
1895
|
+
v = result["values"][k]
|
|
1896
|
+
display_v = "<encrypted>" if k.startswith("#") else escape(str(v))
|
|
1897
|
+
formatter.console.print(f" [green]{escape(k)}[/green] = {display_v}")
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _format_variables_set(formatter: Any, result: dict) -> None:
|
|
1901
|
+
action_label = (
|
|
1902
|
+
"[green]created[/green]" if result["action"] == "created" else "[yellow]updated[/yellow]"
|
|
1903
|
+
)
|
|
1904
|
+
formatter.console.print(
|
|
1905
|
+
f"Variables {action_label} on "
|
|
1906
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1907
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan]"
|
|
1908
|
+
)
|
|
1909
|
+
formatter.console.print(f" variables_id: [cyan]{escape(result['variables_id'])}[/cyan]")
|
|
1910
|
+
formatter.console.print(f" values_id: [cyan]{escape(result['values_id'])}[/cyan]")
|
|
1911
|
+
if result.get("encrypted_keys"):
|
|
1912
|
+
joined = ", ".join(escape(k) for k in result["encrypted_keys"])
|
|
1913
|
+
formatter.console.print(f" [dim]encrypted: {joined}[/dim]")
|
|
1914
|
+
formatter.console.print("[bold]Final values:[/bold]")
|
|
1915
|
+
for k in sorted(result["values"]):
|
|
1916
|
+
v = result["values"][k]
|
|
1917
|
+
display_v = "<encrypted>" if k.startswith("#") else escape(str(v))
|
|
1918
|
+
formatter.console.print(f" [green]{escape(k)}[/green] = {display_v}")
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def _format_variables_clear(formatter: Any, result: dict) -> None:
|
|
1922
|
+
if not result["was_linked"]:
|
|
1923
|
+
formatter.console.print(
|
|
1924
|
+
f"[yellow]Nothing to clear[/yellow]: "
|
|
1925
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1926
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan] had no variables linked."
|
|
1927
|
+
)
|
|
1928
|
+
return
|
|
1929
|
+
formatter.console.print(
|
|
1930
|
+
f"[green]Unlinked[/green] variables from "
|
|
1931
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1932
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan] "
|
|
1933
|
+
f"[dim](was variables_id={escape(result['unlinked_variables_id'] or '')}, "
|
|
1934
|
+
f"values_id={escape(result['unlinked_values_id'] or '')})[/dim]"
|
|
1935
|
+
)
|
|
1936
|
+
formatter.console.print(
|
|
1937
|
+
"[dim]The underlying keboola.variables config was NOT deleted. "
|
|
1938
|
+
"Use 'kbagent config delete' to remove it if no other config references it.[/dim]"
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def _format_variables_dry_run(formatter: Any, result: dict) -> None:
|
|
1943
|
+
verb = "create" if result["action"] == "would_create" else "update"
|
|
1944
|
+
formatter.console.print(
|
|
1945
|
+
f"[yellow]DRY RUN[/yellow]: would {verb} variables on "
|
|
1946
|
+
f"[cyan]{escape(result['parent_component_id'])}[/cyan]/"
|
|
1947
|
+
f"[cyan]{escape(result['parent_config_id'])}[/cyan]"
|
|
1948
|
+
)
|
|
1949
|
+
if result["was_linked"]:
|
|
1950
|
+
formatter.console.print(
|
|
1951
|
+
f" current variables_id: [cyan]{escape(result['current_variables_id'] or '')}[/cyan]"
|
|
1952
|
+
)
|
|
1953
|
+
else:
|
|
1954
|
+
formatter.console.print(" [dim]no variables currently linked[/dim]")
|
|
1955
|
+
|
|
1956
|
+
current_keys = set(result["current_values"])
|
|
1957
|
+
proposed_keys = set(result["would_write"])
|
|
1958
|
+
for k in sorted(current_keys | proposed_keys):
|
|
1959
|
+
current_v = result["current_values"].get(k)
|
|
1960
|
+
proposed_v = result["would_write"].get(k)
|
|
1961
|
+
if k not in proposed_keys:
|
|
1962
|
+
display = "<encrypted>" if k.startswith("#") else escape(str(current_v))
|
|
1963
|
+
formatter.console.print(f" [red]- {escape(k)}[/red] = {display} [dim](dropped)[/dim]")
|
|
1964
|
+
elif k not in current_keys:
|
|
1965
|
+
display = "<encrypted>" if k.startswith("#") else escape(str(proposed_v))
|
|
1966
|
+
formatter.console.print(f" [green]+ {escape(k)}[/green] = {display}")
|
|
1967
|
+
elif current_v != proposed_v:
|
|
1968
|
+
display_cur = "<encrypted>" if k.startswith("#") else escape(str(current_v))
|
|
1969
|
+
display_new = "<encrypted>" if k.startswith("#") else escape(str(proposed_v))
|
|
1970
|
+
formatter.console.print(
|
|
1971
|
+
f" [yellow]~ {escape(k)}[/yellow] = {display_cur} -> {display_new}"
|
|
1972
|
+
)
|
|
1973
|
+
else:
|
|
1974
|
+
display = "<encrypted>" if k.startswith("#") else escape(str(current_v))
|
|
1975
|
+
formatter.console.print(f" [dim]= {escape(k)} = {display}[/dim]")
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
# ── config row-create ──────────────────────────────────────────────────────────
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
@config_app.command("row-create", rich_help_panel="Rows")
|
|
1982
|
+
def config_row_create(
|
|
1983
|
+
ctx: typer.Context,
|
|
1984
|
+
project: str = typer.Option(
|
|
1985
|
+
...,
|
|
1986
|
+
"--project",
|
|
1987
|
+
help="Project alias",
|
|
1988
|
+
),
|
|
1989
|
+
component_id: str = typer.Option(
|
|
1990
|
+
...,
|
|
1991
|
+
"--component-id",
|
|
1992
|
+
help="Component ID (e.g. keboola.python-transformation-v2)",
|
|
1993
|
+
),
|
|
1994
|
+
config_id: str = typer.Option(
|
|
1995
|
+
...,
|
|
1996
|
+
"--config-id",
|
|
1997
|
+
help="Configuration ID to add the row to",
|
|
1998
|
+
),
|
|
1999
|
+
name: str = typer.Option(
|
|
2000
|
+
...,
|
|
2001
|
+
"--name",
|
|
2002
|
+
help="Row name",
|
|
2003
|
+
),
|
|
2004
|
+
description: str = typer.Option(
|
|
2005
|
+
"",
|
|
2006
|
+
"--description",
|
|
2007
|
+
help="Row description",
|
|
2008
|
+
),
|
|
2009
|
+
configuration: str | None = typer.Option(
|
|
2010
|
+
None,
|
|
2011
|
+
"--configuration",
|
|
2012
|
+
help="Row configuration JSON: inline, @file.json, or - for stdin",
|
|
2013
|
+
),
|
|
2014
|
+
is_disabled: bool = typer.Option(
|
|
2015
|
+
False,
|
|
2016
|
+
"--is-disabled",
|
|
2017
|
+
help="Create the row in disabled state (excluded from job runs)",
|
|
2018
|
+
),
|
|
2019
|
+
branch: int | None = typer.Option(
|
|
2020
|
+
None,
|
|
2021
|
+
"--branch",
|
|
2022
|
+
help="Create in a specific dev branch ID (defaults to active branch)",
|
|
2023
|
+
),
|
|
2024
|
+
allow_plaintext: bool = typer.Option(
|
|
2025
|
+
False,
|
|
2026
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
2027
|
+
help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
|
|
2028
|
+
),
|
|
2029
|
+
) -> None:
|
|
2030
|
+
"""Create a new configuration row.
|
|
2031
|
+
|
|
2032
|
+
\b
|
|
2033
|
+
Examples:
|
|
2034
|
+
# Create a row with a name only (empty configuration)
|
|
2035
|
+
kbagent config row-create --project P --component-id C --config-id ID --name "Row 1"
|
|
2036
|
+
|
|
2037
|
+
# Create a row with configuration content
|
|
2038
|
+
kbagent config row-create --project P --component-id C --config-id ID \\
|
|
2039
|
+
--name "Row 1" --configuration '{"parameters": {"table": "orders"}}'
|
|
2040
|
+
|
|
2041
|
+
# Create from a JSON file
|
|
2042
|
+
kbagent config row-create --project P --component-id C --config-id ID \\
|
|
2043
|
+
--name "Row 1" --configuration @row.json
|
|
2044
|
+
|
|
2045
|
+
# Create a disabled row
|
|
2046
|
+
kbagent config row-create --project P --component-id C --config-id ID \\
|
|
2047
|
+
--name "Row 1" --is-disabled
|
|
2048
|
+
"""
|
|
2049
|
+
formatter = get_formatter(ctx)
|
|
2050
|
+
service = get_service(ctx, "config_service")
|
|
2051
|
+
|
|
2052
|
+
config_dict: dict | None = None
|
|
2053
|
+
if configuration:
|
|
2054
|
+
try:
|
|
2055
|
+
config_dict = _parse_json_input(configuration)
|
|
2056
|
+
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
|
2057
|
+
formatter.error(
|
|
2058
|
+
message=f"Invalid --configuration input: {exc}",
|
|
2059
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
2060
|
+
)
|
|
2061
|
+
raise typer.Exit(code=2) from None
|
|
2062
|
+
|
|
2063
|
+
try:
|
|
2064
|
+
result = service.create_config_row(
|
|
2065
|
+
alias=project,
|
|
2066
|
+
component_id=component_id,
|
|
2067
|
+
config_id=config_id,
|
|
2068
|
+
name=name,
|
|
2069
|
+
description=description,
|
|
2070
|
+
configuration=config_dict,
|
|
2071
|
+
is_disabled=is_disabled,
|
|
2072
|
+
branch_id=branch,
|
|
2073
|
+
allow_plaintext_fallback=allow_plaintext,
|
|
2074
|
+
)
|
|
2075
|
+
except ConfigError as exc:
|
|
2076
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
2077
|
+
raise typer.Exit(code=5) from None
|
|
2078
|
+
except KeboolaApiError as exc:
|
|
2079
|
+
formatter.error(
|
|
2080
|
+
message=exc.message,
|
|
2081
|
+
error_code=exc.error_code,
|
|
2082
|
+
retryable=exc.retryable,
|
|
2083
|
+
)
|
|
2084
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
2085
|
+
|
|
2086
|
+
if formatter.json_mode:
|
|
2087
|
+
formatter.output(result)
|
|
2088
|
+
else:
|
|
2089
|
+
row_name = result.get("name", name)
|
|
2090
|
+
row_id = result.get("id", "")
|
|
2091
|
+
branch_info = ""
|
|
2092
|
+
if result.get("branch_id"):
|
|
2093
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
2094
|
+
formatter.success(
|
|
2095
|
+
f"Created row '{escape(row_name)}' [{row_id}] "
|
|
2096
|
+
f"in {escape(component_id)}/{escape(config_id)}{branch_info}"
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
# ── config row-update ──────────────────────────────────────────────────────────
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
@config_app.command("row-update", rich_help_panel="Rows")
|
|
2104
|
+
def config_row_update(
|
|
2105
|
+
ctx: typer.Context,
|
|
2106
|
+
project: str = typer.Option(
|
|
2107
|
+
...,
|
|
2108
|
+
"--project",
|
|
2109
|
+
help="Project alias",
|
|
2110
|
+
),
|
|
2111
|
+
component_id: str = typer.Option(
|
|
2112
|
+
...,
|
|
2113
|
+
"--component-id",
|
|
2114
|
+
help="Component ID",
|
|
2115
|
+
),
|
|
2116
|
+
config_id: str = typer.Option(
|
|
2117
|
+
...,
|
|
2118
|
+
"--config-id",
|
|
2119
|
+
help="Configuration ID",
|
|
2120
|
+
),
|
|
2121
|
+
row_id: str = typer.Option(
|
|
2122
|
+
...,
|
|
2123
|
+
"--row-id",
|
|
2124
|
+
help="Row ID to update",
|
|
2125
|
+
),
|
|
2126
|
+
name: str | None = typer.Option(
|
|
2127
|
+
None,
|
|
2128
|
+
"--name",
|
|
2129
|
+
help="New row name",
|
|
2130
|
+
),
|
|
2131
|
+
description: str | None = typer.Option(
|
|
2132
|
+
None,
|
|
2133
|
+
"--description",
|
|
2134
|
+
help="New row description",
|
|
2135
|
+
),
|
|
2136
|
+
configuration: str | None = typer.Option(
|
|
2137
|
+
None,
|
|
2138
|
+
"--configuration",
|
|
2139
|
+
help="Row configuration JSON: inline, @file.json, or - for stdin",
|
|
2140
|
+
),
|
|
2141
|
+
set_values: list[str] | None = typer.Option(
|
|
2142
|
+
None,
|
|
2143
|
+
"--set",
|
|
2144
|
+
help="Set a nested value: PATH=VALUE (e.g. --set 'parameters.table=orders')",
|
|
2145
|
+
),
|
|
2146
|
+
merge: bool = typer.Option(
|
|
2147
|
+
False,
|
|
2148
|
+
"--merge",
|
|
2149
|
+
help="Deep-merge into existing row config instead of replacing",
|
|
2150
|
+
),
|
|
2151
|
+
dry_run: bool = typer.Option(
|
|
2152
|
+
False,
|
|
2153
|
+
"--dry-run",
|
|
2154
|
+
help="Show what would change without applying",
|
|
2155
|
+
),
|
|
2156
|
+
is_disabled: bool = typer.Option(
|
|
2157
|
+
False,
|
|
2158
|
+
"--is-disabled",
|
|
2159
|
+
help="Disable the row (mutually exclusive with --is-enabled)",
|
|
2160
|
+
),
|
|
2161
|
+
is_enabled: bool = typer.Option(
|
|
2162
|
+
False,
|
|
2163
|
+
"--is-enabled",
|
|
2164
|
+
help="Enable the row (mutually exclusive with --is-disabled)",
|
|
2165
|
+
),
|
|
2166
|
+
branch: int | None = typer.Option(
|
|
2167
|
+
None,
|
|
2168
|
+
"--branch",
|
|
2169
|
+
help="Update in a specific dev branch ID (defaults to active branch)",
|
|
2170
|
+
),
|
|
2171
|
+
allow_plaintext: bool = typer.Option(
|
|
2172
|
+
False,
|
|
2173
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
2174
|
+
help="Allow write even if secret encryption fails (DANGEROUS: secrets stored as plaintext)",
|
|
2175
|
+
),
|
|
2176
|
+
) -> None:
|
|
2177
|
+
"""Update an existing configuration row.
|
|
2178
|
+
|
|
2179
|
+
\b
|
|
2180
|
+
Content options modify the row configuration JSON:
|
|
2181
|
+
--configuration : provide a full JSON blob (inline, @file, or -)
|
|
2182
|
+
--set PATH=VALUE : set a single nested key (repeatable)
|
|
2183
|
+
--merge : deep-merge into existing row config (preserves sibling keys)
|
|
2184
|
+
--dry-run : preview changes without applying
|
|
2185
|
+
|
|
2186
|
+
\b
|
|
2187
|
+
Examples:
|
|
2188
|
+
# Update just the name
|
|
2189
|
+
kbagent config row-update --project P --component-id C --config-id ID --row-id R --name "New name"
|
|
2190
|
+
|
|
2191
|
+
# Replace row configuration from a file
|
|
2192
|
+
kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
|
|
2193
|
+
--configuration @row.json
|
|
2194
|
+
|
|
2195
|
+
# Set a single nested value (merge implied)
|
|
2196
|
+
kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
|
|
2197
|
+
--set 'parameters.table=new_table'
|
|
2198
|
+
|
|
2199
|
+
# Preview changes without applying
|
|
2200
|
+
kbagent config row-update --project P --component-id C --config-id ID --row-id R \\
|
|
2201
|
+
--set 'parameters.table=new_table' --dry-run
|
|
2202
|
+
|
|
2203
|
+
# Disable a row (excludes it from job runs)
|
|
2204
|
+
kbagent config row-update --project P --component-id C --config-id ID --row-id R --is-disabled
|
|
2205
|
+
"""
|
|
2206
|
+
if is_disabled and is_enabled:
|
|
2207
|
+
formatter = get_formatter(ctx)
|
|
2208
|
+
formatter.error(
|
|
2209
|
+
message="--is-disabled and --is-enabled are mutually exclusive.",
|
|
2210
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
2211
|
+
)
|
|
2212
|
+
raise typer.Exit(code=2) from None
|
|
2213
|
+
|
|
2214
|
+
is_disabled_value: bool | None = None
|
|
2215
|
+
if is_disabled:
|
|
2216
|
+
is_disabled_value = True
|
|
2217
|
+
elif is_enabled:
|
|
2218
|
+
is_disabled_value = False
|
|
2219
|
+
|
|
2220
|
+
formatter = get_formatter(ctx)
|
|
2221
|
+
service = get_service(ctx, "config_service")
|
|
2222
|
+
|
|
2223
|
+
config_dict: dict | None = None
|
|
2224
|
+
if configuration:
|
|
2225
|
+
try:
|
|
2226
|
+
config_dict = _parse_json_input(configuration)
|
|
2227
|
+
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
|
2228
|
+
formatter.error(
|
|
2229
|
+
message=f"Invalid --configuration input: {exc}",
|
|
2230
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
2231
|
+
)
|
|
2232
|
+
raise typer.Exit(code=2) from None
|
|
2233
|
+
|
|
2234
|
+
parsed_sets: list[tuple[str, object]] | None = None
|
|
2235
|
+
if set_values:
|
|
2236
|
+
parsed_sets = []
|
|
2237
|
+
for item in set_values:
|
|
2238
|
+
if "=" not in item:
|
|
2239
|
+
formatter.error(
|
|
2240
|
+
message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.",
|
|
2241
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
2242
|
+
)
|
|
2243
|
+
raise typer.Exit(code=2) from None
|
|
2244
|
+
path, _, raw_value = item.partition("=")
|
|
2245
|
+
parsed_sets.append((path.strip(), _parse_set_value(raw_value.strip())))
|
|
2246
|
+
|
|
2247
|
+
effective_merge = merge or bool(parsed_sets)
|
|
2248
|
+
|
|
2249
|
+
try:
|
|
2250
|
+
result = service.update_config_row(
|
|
2251
|
+
alias=project,
|
|
2252
|
+
component_id=component_id,
|
|
2253
|
+
config_id=config_id,
|
|
2254
|
+
row_id=row_id,
|
|
2255
|
+
name=name,
|
|
2256
|
+
description=description,
|
|
2257
|
+
configuration=config_dict,
|
|
2258
|
+
set_paths=parsed_sets,
|
|
2259
|
+
merge=effective_merge,
|
|
2260
|
+
dry_run=dry_run,
|
|
2261
|
+
is_disabled=is_disabled_value,
|
|
2262
|
+
branch_id=branch,
|
|
2263
|
+
allow_plaintext_fallback=allow_plaintext,
|
|
2264
|
+
)
|
|
2265
|
+
except ConfigError as exc:
|
|
2266
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
2267
|
+
raise typer.Exit(code=5) from None
|
|
2268
|
+
except KeboolaApiError as exc:
|
|
2269
|
+
formatter.error(
|
|
2270
|
+
message=exc.message,
|
|
2271
|
+
error_code=exc.error_code,
|
|
2272
|
+
retryable=exc.retryable,
|
|
2273
|
+
)
|
|
2274
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
2275
|
+
|
|
2276
|
+
if result.get("dry_run"):
|
|
2277
|
+
changes = result.get("changes", [])
|
|
2278
|
+
if formatter.json_mode:
|
|
2279
|
+
formatter.output(result)
|
|
2280
|
+
else:
|
|
2281
|
+
if not changes:
|
|
2282
|
+
formatter.success("No changes detected.")
|
|
2283
|
+
else:
|
|
2284
|
+
formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n")
|
|
2285
|
+
for change in changes:
|
|
2286
|
+
formatter.console.print(f" {change}")
|
|
2287
|
+
formatter.console.print()
|
|
2288
|
+
return
|
|
2289
|
+
|
|
2290
|
+
if formatter.json_mode:
|
|
2291
|
+
formatter.output(result)
|
|
2292
|
+
else:
|
|
2293
|
+
updated_name = result.get("name", row_id)
|
|
2294
|
+
branch_info = ""
|
|
2295
|
+
if result.get("branch_id"):
|
|
2296
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
2297
|
+
formatter.success(
|
|
2298
|
+
f"Updated row '{escape(updated_name)}' [{row_id}] "
|
|
2299
|
+
f"in {escape(component_id)}/{escape(config_id)}{branch_info}"
|
|
2300
|
+
)
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
# ── config row-delete ──────────────────────────────────────────────────────────
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
@config_app.command("row-delete", rich_help_panel="Rows")
|
|
2307
|
+
def config_row_delete(
|
|
2308
|
+
ctx: typer.Context,
|
|
2309
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
2310
|
+
component_id: str = typer.Option(..., "--component-id", help="Component ID"),
|
|
2311
|
+
config_id: str = typer.Option(..., "--config-id", help="Configuration ID"),
|
|
2312
|
+
row_id: str = typer.Option(..., "--row-id", help="Row ID to delete"),
|
|
2313
|
+
branch: int | None = typer.Option(
|
|
2314
|
+
None, "--branch", help="Delete from a specific dev branch ID (defaults to active branch)"
|
|
2315
|
+
),
|
|
2316
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
2317
|
+
) -> None:
|
|
2318
|
+
"""Delete a configuration row.
|
|
2319
|
+
|
|
2320
|
+
\b
|
|
2321
|
+
Examples:
|
|
2322
|
+
kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW
|
|
2323
|
+
kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW --yes
|
|
2324
|
+
"""
|
|
2325
|
+
formatter = get_formatter(ctx)
|
|
2326
|
+
|
|
2327
|
+
if (
|
|
2328
|
+
not yes
|
|
2329
|
+
and not formatter.json_mode
|
|
2330
|
+
and not typer.confirm(f"Delete row [{row_id}] from {component_id}/{config_id}?")
|
|
2331
|
+
):
|
|
2332
|
+
formatter.console.print("Aborted.")
|
|
2333
|
+
raise typer.Exit(code=0)
|
|
2334
|
+
|
|
2335
|
+
service = get_service(ctx, "config_service")
|
|
2336
|
+
|
|
2337
|
+
try:
|
|
2338
|
+
result = service.delete_config_row(
|
|
2339
|
+
alias=project,
|
|
2340
|
+
component_id=component_id,
|
|
2341
|
+
config_id=config_id,
|
|
2342
|
+
row_id=row_id,
|
|
2343
|
+
branch_id=branch,
|
|
2344
|
+
)
|
|
2345
|
+
except ConfigError as exc:
|
|
2346
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
2347
|
+
raise typer.Exit(code=5) from None
|
|
2348
|
+
except KeboolaApiError as exc:
|
|
2349
|
+
formatter.error(
|
|
2350
|
+
message=exc.message,
|
|
2351
|
+
error_code=exc.error_code,
|
|
2352
|
+
retryable=exc.retryable,
|
|
2353
|
+
)
|
|
2354
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
2355
|
+
|
|
2356
|
+
if formatter.json_mode:
|
|
2357
|
+
formatter.output(result)
|
|
2358
|
+
else:
|
|
2359
|
+
branch_info = ""
|
|
2360
|
+
if result.get("branch_id"):
|
|
2361
|
+
branch_info = f" (branch {result['branch_id']})"
|
|
2362
|
+
formatter.success(
|
|
2363
|
+
f"Deleted row [{row_id}] from {escape(component_id)}/{escape(config_id)}{branch_info}"
|
|
2364
|
+
)
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
# ── config oauth-url ───────────────────────────────────────────────────────────
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
@config_app.command(
|
|
2371
|
+
"oauth-url",
|
|
2372
|
+
rich_help_panel="OAuth",
|
|
2373
|
+
help=(
|
|
2374
|
+
"Requires master token. Generate an OAuth authorization URL for a component configuration."
|
|
2375
|
+
),
|
|
2376
|
+
)
|
|
2377
|
+
def config_oauth_url(
|
|
2378
|
+
ctx: typer.Context,
|
|
2379
|
+
project: str = typer.Option(
|
|
2380
|
+
...,
|
|
2381
|
+
"--project",
|
|
2382
|
+
help="Project alias",
|
|
2383
|
+
),
|
|
2384
|
+
component_id: str = typer.Option(
|
|
2385
|
+
...,
|
|
2386
|
+
"--component-id",
|
|
2387
|
+
help="Component ID (e.g. keboola.ex-google-drive)",
|
|
2388
|
+
),
|
|
2389
|
+
config_id: str = typer.Option(
|
|
2390
|
+
...,
|
|
2391
|
+
"--config-id",
|
|
2392
|
+
help="Configuration ID to authorize",
|
|
2393
|
+
),
|
|
2394
|
+
redirect_url: str | None = typer.Option(
|
|
2395
|
+
None,
|
|
2396
|
+
"--redirect-url",
|
|
2397
|
+
help="Optional URL to return to after the OAuth flow completes (sets returnUrl query param)",
|
|
2398
|
+
),
|
|
2399
|
+
) -> None:
|
|
2400
|
+
"""Generate an OAuth authorization URL for a component configuration.
|
|
2401
|
+
|
|
2402
|
+
Opens a short-lived, component-scoped authorization link.
|
|
2403
|
+
The user must open this URL in a browser and grant access.
|
|
2404
|
+
|
|
2405
|
+
\b
|
|
2406
|
+
Examples:
|
|
2407
|
+
kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID
|
|
2408
|
+
|
|
2409
|
+
# Redirect back to a custom URL after the OAuth flow completes
|
|
2410
|
+
kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID \\
|
|
2411
|
+
--redirect-url https://example.com/oauth-done
|
|
2412
|
+
"""
|
|
2413
|
+
formatter = get_formatter(ctx)
|
|
2414
|
+
service = get_service(ctx, "config_service")
|
|
2415
|
+
|
|
2416
|
+
try:
|
|
2417
|
+
result = service.get_oauth_url(
|
|
2418
|
+
alias=project,
|
|
2419
|
+
component_id=component_id,
|
|
2420
|
+
config_id=config_id,
|
|
2421
|
+
redirect_url=redirect_url,
|
|
2422
|
+
)
|
|
2423
|
+
except ConfigError as exc:
|
|
2424
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
2425
|
+
raise typer.Exit(code=5) from None
|
|
2426
|
+
except KeboolaApiError as exc:
|
|
2427
|
+
formatter.error(
|
|
2428
|
+
message=exc.message,
|
|
2429
|
+
error_code=exc.error_code,
|
|
2430
|
+
retryable=exc.retryable,
|
|
2431
|
+
)
|
|
2432
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
2433
|
+
|
|
2434
|
+
if formatter.json_mode:
|
|
2435
|
+
formatter.output(result)
|
|
2436
|
+
else:
|
|
2437
|
+
formatter.console.print(
|
|
2438
|
+
f"[bold]OAuth URL for[/bold] [cyan]{escape(component_id)}[/cyan]/"
|
|
2439
|
+
f"[cyan]{escape(config_id)}[/cyan]:\n"
|
|
2440
|
+
)
|
|
2441
|
+
formatter.console.print(f" [link]{result['url']}[/link]")
|
|
2442
|
+
formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]")
|