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,1279 @@
|
|
|
1
|
+
"""Data-app commands -- create, list, detail, deploy, start, stop, delete, password, logs.
|
|
2
|
+
|
|
3
|
+
Thin CLI layer that delegates to :class:`DataAppService`. The underlying
|
|
4
|
+
Keboola Data Science API is not idempotent and has several footguns
|
|
5
|
+
(redeploy contract, cross-project KMS, transient stopped during initial
|
|
6
|
+
deploy); the service encodes them. The command layer's job is argument
|
|
7
|
+
parsing, mutual-exclusion validation, dual JSON / human output, and
|
|
8
|
+
exit-code mapping.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.markup import escape
|
|
21
|
+
|
|
22
|
+
from ..constants import DEFAULT_JOB_RUN_TIMEOUT
|
|
23
|
+
from ..errors import ConfigError, ErrorCode, KeboolaApiError
|
|
24
|
+
from ._data_app_git import register_git_commands
|
|
25
|
+
from ._helpers import (
|
|
26
|
+
check_cli_permission,
|
|
27
|
+
emit_project_warnings,
|
|
28
|
+
get_formatter,
|
|
29
|
+
get_service,
|
|
30
|
+
map_error_to_exit_code,
|
|
31
|
+
resolve_manage_token,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Canonical Keboola help-doc references appended to each --help epilog so
|
|
35
|
+
# operators have a one-click path to the rule a flag enforces.
|
|
36
|
+
_REF_PYTHON_JS = "https://help.keboola.com/data-apps/python-js/"
|
|
37
|
+
_REF_STORAGE_ACCESS = "https://help.keboola.com/data-apps/storage-access/"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
data_app_app = typer.Typer(help="Keboola data-app lifecycle (create, deploy, manage)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@data_app_app.callback(invoke_without_command=True)
|
|
44
|
+
def _data_app_permission_check(ctx: typer.Context) -> None:
|
|
45
|
+
check_cli_permission(ctx, "data-app")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _print_data_app_table(formatter, result: dict) -> None:
|
|
49
|
+
"""Compact human-readable list of data apps across projects."""
|
|
50
|
+
apps = result.get("apps", [])
|
|
51
|
+
if not apps:
|
|
52
|
+
formatter.console.print("[dim]No data apps found.[/dim]")
|
|
53
|
+
return
|
|
54
|
+
for app in apps:
|
|
55
|
+
formatter.console.print(
|
|
56
|
+
f" [bold]{app['app_id']}[/bold] "
|
|
57
|
+
f"[cyan]{app.get('name', '')}[/cyan] "
|
|
58
|
+
f"({app.get('type', '?')}) "
|
|
59
|
+
f"state=[yellow]{app.get('state', '?')}[/yellow] "
|
|
60
|
+
f"desired={app.get('desired_state', '?')} "
|
|
61
|
+
f"v{app.get('config_version', '?')} "
|
|
62
|
+
f"in [magenta]{app['project_alias']}[/magenta]"
|
|
63
|
+
)
|
|
64
|
+
if app.get("url"):
|
|
65
|
+
formatter.console.print(f" [dim]{app['url']}[/dim]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_pat_from_env(env_var: str) -> str:
|
|
69
|
+
value = os.environ.get(env_var, "")
|
|
70
|
+
if not value:
|
|
71
|
+
raise typer.BadParameter(
|
|
72
|
+
f"Environment variable {env_var} is unset or empty.",
|
|
73
|
+
param_hint="--git-pat-env",
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_pat_from_file(path: Path) -> str:
|
|
79
|
+
try:
|
|
80
|
+
return path.read_text(encoding="utf-8").strip()
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise typer.BadParameter(
|
|
83
|
+
f"Cannot read PAT file {path}: {exc}",
|
|
84
|
+
param_hint="--git-pat-file",
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# data-app list
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@data_app_app.command("list")
|
|
94
|
+
def data_app_list(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
project: list[str] | None = typer.Option(
|
|
97
|
+
None,
|
|
98
|
+
"--project",
|
|
99
|
+
help="Project alias to query (repeatable). None = all projects.",
|
|
100
|
+
),
|
|
101
|
+
branch: int | None = typer.Option(
|
|
102
|
+
None,
|
|
103
|
+
"--branch",
|
|
104
|
+
help="Storage branch ID for the config-name lookup (defaults to production).",
|
|
105
|
+
),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""List data apps across one or more registered projects."""
|
|
108
|
+
formatter = get_formatter(ctx)
|
|
109
|
+
service = get_service(ctx, "data_app_service")
|
|
110
|
+
try:
|
|
111
|
+
result = service.list_data_apps(aliases=project, branch_id=branch)
|
|
112
|
+
except KeboolaApiError as exc:
|
|
113
|
+
formatter.error(
|
|
114
|
+
message=exc.message,
|
|
115
|
+
error_code=exc.error_code,
|
|
116
|
+
retryable=exc.retryable,
|
|
117
|
+
details=exc.details,
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
120
|
+
except ConfigError as exc:
|
|
121
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
122
|
+
raise typer.Exit(code=5) from None
|
|
123
|
+
|
|
124
|
+
if formatter.json_mode:
|
|
125
|
+
formatter.output(result)
|
|
126
|
+
else:
|
|
127
|
+
_print_data_app_table(formatter, result)
|
|
128
|
+
emit_project_warnings(formatter, result)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# data-app detail
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@data_app_app.command("detail")
|
|
137
|
+
def data_app_detail(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
140
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
141
|
+
branch: int | None = typer.Option(
|
|
142
|
+
None,
|
|
143
|
+
"--branch",
|
|
144
|
+
help="Storage branch ID for the linked config (defaults to production).",
|
|
145
|
+
),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Show merged Data Science + Storage detail for one data app."""
|
|
148
|
+
formatter = get_formatter(ctx)
|
|
149
|
+
service = get_service(ctx, "data_app_service")
|
|
150
|
+
try:
|
|
151
|
+
result = service.get_data_app(alias=project, app_id=app_id, branch_id=branch)
|
|
152
|
+
except KeboolaApiError as exc:
|
|
153
|
+
formatter.error(
|
|
154
|
+
message=exc.message,
|
|
155
|
+
error_code=exc.error_code,
|
|
156
|
+
retryable=exc.retryable,
|
|
157
|
+
details=exc.details,
|
|
158
|
+
)
|
|
159
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
160
|
+
except ConfigError as exc:
|
|
161
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
162
|
+
raise typer.Exit(code=5) from None
|
|
163
|
+
|
|
164
|
+
formatter.output(
|
|
165
|
+
result,
|
|
166
|
+
lambda c, d: (
|
|
167
|
+
c.print(f"\n[bold]Data app:[/bold] {d.get('name', '')} ({d['app_id']})"),
|
|
168
|
+
c.print(f" [bold]Project:[/bold] {d['project_alias']}"),
|
|
169
|
+
c.print(f" [bold]Slug:[/bold] {d.get('slug', '')}"),
|
|
170
|
+
c.print(f" [bold]Type:[/bold] {d.get('type', '')}"),
|
|
171
|
+
c.print(
|
|
172
|
+
f" [bold]State:[/bold] [yellow]{d.get('state', '?')}[/yellow] "
|
|
173
|
+
f"(desired={d.get('desired_state', '?')})"
|
|
174
|
+
),
|
|
175
|
+
c.print(
|
|
176
|
+
f" [bold]Config version:[/bold] storage="
|
|
177
|
+
f"{d.get('config_version_storage', '?')}, "
|
|
178
|
+
f"deployed={d.get('config_version_deployed', '?')}"
|
|
179
|
+
),
|
|
180
|
+
c.print(f" [bold]Size:[/bold] {d.get('size', '')}"),
|
|
181
|
+
c.print(f" [bold]Auto-suspend:[/bold] {d.get('auto_suspend_after_seconds', '?')}s"),
|
|
182
|
+
c.print(f" [bold]URL:[/bold] {d.get('url', '')}"),
|
|
183
|
+
c.print(f" [bold]Last started:[/bold] {d.get('last_start_timestamp', '')}"),
|
|
184
|
+
c.print(f" [bold]Git:[/bold] {d.get('git', {})}"),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# data-app create
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@data_app_app.command("create")
|
|
195
|
+
def data_app_create(
|
|
196
|
+
ctx: typer.Context,
|
|
197
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
198
|
+
name: str = typer.Option(..., "--name", help="Display name shown in the Keboola UI"),
|
|
199
|
+
description: str = typer.Option(
|
|
200
|
+
"",
|
|
201
|
+
"--description",
|
|
202
|
+
help="Long-form description (markdown). Mutually exclusive with --description-file.",
|
|
203
|
+
),
|
|
204
|
+
description_file: Path | None = typer.Option(
|
|
205
|
+
None,
|
|
206
|
+
"--description-file",
|
|
207
|
+
help="Read description from a file. Mutually exclusive with --description.",
|
|
208
|
+
exists=True,
|
|
209
|
+
readable=True,
|
|
210
|
+
),
|
|
211
|
+
slug: str = typer.Option(
|
|
212
|
+
..., "--slug", help="URL slug (lowercase alphanumeric, hyphens; 2-64 chars)"
|
|
213
|
+
),
|
|
214
|
+
git_repo: str = typer.Option(..., "--git-repo", help="GitHub repository URL"),
|
|
215
|
+
git_branch: str = typer.Option("main", "--git-branch", help="Git branch to clone"),
|
|
216
|
+
git_public: bool = typer.Option(
|
|
217
|
+
False,
|
|
218
|
+
"--git-public/--no-git-public",
|
|
219
|
+
help="Mark the repository as public (no credentials needed).",
|
|
220
|
+
),
|
|
221
|
+
git_username: str | None = typer.Option(
|
|
222
|
+
None, "--git-username", help="GitHub username (required for private repos)"
|
|
223
|
+
),
|
|
224
|
+
git_pat_env: str | None = typer.Option(
|
|
225
|
+
None,
|
|
226
|
+
"--git-pat-env",
|
|
227
|
+
help="Environment variable containing the plaintext PAT (recommended).",
|
|
228
|
+
),
|
|
229
|
+
git_pat_file: Path | None = typer.Option(
|
|
230
|
+
None,
|
|
231
|
+
"--git-pat-file",
|
|
232
|
+
help="File containing the plaintext PAT.",
|
|
233
|
+
exists=True,
|
|
234
|
+
readable=True,
|
|
235
|
+
),
|
|
236
|
+
git_pat_encrypted: str | None = typer.Option(
|
|
237
|
+
None,
|
|
238
|
+
"--git-pat-encrypted",
|
|
239
|
+
help=(
|
|
240
|
+
"Pre-encrypted PAT (KBC::Project... ciphertext). Must be encrypted "
|
|
241
|
+
"against THIS project's KMS -- ciphertext does not cross projects."
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
auth: str = typer.Option(
|
|
245
|
+
"password",
|
|
246
|
+
"--auth",
|
|
247
|
+
help="Authentication mode: 'password' (simpleAuth) or 'public' (no auth gate).",
|
|
248
|
+
),
|
|
249
|
+
size: str = typer.Option("tiny", "--size", help="Runtime size: tiny, small, medium, or large."),
|
|
250
|
+
auto_suspend: int = typer.Option(
|
|
251
|
+
900,
|
|
252
|
+
"--auto-suspend",
|
|
253
|
+
help="Auto-suspend after N seconds idle (0 disables).",
|
|
254
|
+
),
|
|
255
|
+
type_: str = typer.Option(
|
|
256
|
+
"python-js",
|
|
257
|
+
"--type",
|
|
258
|
+
help="Runtime type. Default 'python-js' covers Python AND Node apps.",
|
|
259
|
+
),
|
|
260
|
+
branch: int | None = typer.Option(
|
|
261
|
+
None,
|
|
262
|
+
"--branch",
|
|
263
|
+
help="Keboola dev branch ID (defaults to production).",
|
|
264
|
+
),
|
|
265
|
+
no_deploy: bool = typer.Option(
|
|
266
|
+
False,
|
|
267
|
+
"--no-deploy",
|
|
268
|
+
help="Skip the deploy step; create the shell + Storage config only.",
|
|
269
|
+
),
|
|
270
|
+
wait: bool = typer.Option(
|
|
271
|
+
False,
|
|
272
|
+
"--wait",
|
|
273
|
+
help="Block until state == running (or error). Respects pitfall #1: stopped is not terminal.",
|
|
274
|
+
),
|
|
275
|
+
timeout: float = typer.Option(
|
|
276
|
+
DEFAULT_JOB_RUN_TIMEOUT,
|
|
277
|
+
"--timeout",
|
|
278
|
+
help="Maximum seconds to wait for state == running (default 300).",
|
|
279
|
+
),
|
|
280
|
+
keep_on_failure: bool = typer.Option(
|
|
281
|
+
False,
|
|
282
|
+
"--keep-on-failure",
|
|
283
|
+
help="Keep the orphan deployment shell if PUT or initial deploy fails (forensics).",
|
|
284
|
+
),
|
|
285
|
+
dry_run: bool = typer.Option(
|
|
286
|
+
False,
|
|
287
|
+
"--dry-run",
|
|
288
|
+
help="Print the three request bodies without making any API call.",
|
|
289
|
+
),
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Create a Keboola data app end-to-end (POST + encrypt + PUT + deploy)."""
|
|
292
|
+
formatter = get_formatter(ctx)
|
|
293
|
+
service = get_service(ctx, "data_app_service")
|
|
294
|
+
|
|
295
|
+
# Mutual exclusion: --description vs --description-file
|
|
296
|
+
if description and description_file:
|
|
297
|
+
formatter.error(
|
|
298
|
+
message="Specify either --description or --description-file, not both.",
|
|
299
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
300
|
+
)
|
|
301
|
+
raise typer.Exit(code=2)
|
|
302
|
+
effective_description = description
|
|
303
|
+
if description_file is not None:
|
|
304
|
+
try:
|
|
305
|
+
effective_description = description_file.read_text(encoding="utf-8")
|
|
306
|
+
except OSError as exc:
|
|
307
|
+
formatter.error(
|
|
308
|
+
message=f"Cannot read --description-file {description_file}: {exc}",
|
|
309
|
+
error_code=ErrorCode.READ_ERROR,
|
|
310
|
+
)
|
|
311
|
+
raise typer.Exit(code=2) from None
|
|
312
|
+
|
|
313
|
+
# Mutual exclusion of git PAT input modes (CLI layer).
|
|
314
|
+
pat_inputs_set = sum(1 for v in (git_pat_env, git_pat_file, git_pat_encrypted) if v is not None)
|
|
315
|
+
if pat_inputs_set > 1:
|
|
316
|
+
formatter.error(
|
|
317
|
+
message=(
|
|
318
|
+
"Specify exactly one of --git-pat-env / --git-pat-file / "
|
|
319
|
+
"--git-pat-encrypted; they are mutually exclusive."
|
|
320
|
+
),
|
|
321
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
322
|
+
)
|
|
323
|
+
raise typer.Exit(code=2)
|
|
324
|
+
|
|
325
|
+
# Resolve PAT plaintext if needed (env / file). Encrypted form passes
|
|
326
|
+
# through; service validates the prefix.
|
|
327
|
+
pat_plaintext: str | None = None
|
|
328
|
+
if git_pat_env is not None:
|
|
329
|
+
pat_plaintext = _read_pat_from_env(git_pat_env)
|
|
330
|
+
elif git_pat_file is not None:
|
|
331
|
+
pat_plaintext = _read_pat_from_file(git_pat_file)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
result = service.create_data_app(
|
|
335
|
+
alias=project,
|
|
336
|
+
name=name,
|
|
337
|
+
description=effective_description,
|
|
338
|
+
slug=slug,
|
|
339
|
+
git_repo=git_repo,
|
|
340
|
+
git_branch=git_branch,
|
|
341
|
+
git_public=git_public,
|
|
342
|
+
git_username=git_username,
|
|
343
|
+
git_pat_plaintext=pat_plaintext,
|
|
344
|
+
git_pat_encrypted=git_pat_encrypted,
|
|
345
|
+
auth=auth,
|
|
346
|
+
size=size,
|
|
347
|
+
auto_suspend_after_seconds=auto_suspend,
|
|
348
|
+
type_=type_,
|
|
349
|
+
branch_id=branch,
|
|
350
|
+
deploy=not no_deploy,
|
|
351
|
+
wait=wait,
|
|
352
|
+
timeout_seconds=timeout,
|
|
353
|
+
keep_on_failure=keep_on_failure,
|
|
354
|
+
dry_run=dry_run,
|
|
355
|
+
)
|
|
356
|
+
except KeboolaApiError as exc:
|
|
357
|
+
formatter.error(
|
|
358
|
+
message=exc.message,
|
|
359
|
+
error_code=exc.error_code,
|
|
360
|
+
retryable=exc.retryable,
|
|
361
|
+
details=exc.details,
|
|
362
|
+
)
|
|
363
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
364
|
+
except ConfigError as exc:
|
|
365
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
366
|
+
raise typer.Exit(code=5) from None
|
|
367
|
+
|
|
368
|
+
if formatter.json_mode:
|
|
369
|
+
formatter.output(result)
|
|
370
|
+
else:
|
|
371
|
+
if result.get("dry_run"):
|
|
372
|
+
formatter.console.print("[bold]DRY RUN -- no API calls were made.[/bold]")
|
|
373
|
+
formatter.console.print(result["requests"])
|
|
374
|
+
else:
|
|
375
|
+
formatter.console.print(
|
|
376
|
+
f"[bold green]Success:[/bold green] {result.get('message', '')}"
|
|
377
|
+
)
|
|
378
|
+
formatter.console.print(f" [bold]App ID:[/bold] {result['app_id']}")
|
|
379
|
+
formatter.console.print(f" [bold]Config ID:[/bold] {result['config_id']}")
|
|
380
|
+
if result.get("url"):
|
|
381
|
+
formatter.console.print(f" [bold]URL:[/bold] {result['url']}")
|
|
382
|
+
formatter.console.print(
|
|
383
|
+
f" [bold]State:[/bold] {result.get('state', '?')} "
|
|
384
|
+
f"(desired={result.get('desired_state', '?')})"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# data-app deploy / start / stop
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _run_lifecycle(
|
|
394
|
+
ctx: typer.Context,
|
|
395
|
+
service_method: str,
|
|
396
|
+
*,
|
|
397
|
+
project: str,
|
|
398
|
+
app_id: str,
|
|
399
|
+
wait: bool,
|
|
400
|
+
timeout: float,
|
|
401
|
+
extra: dict | None = None,
|
|
402
|
+
) -> None:
|
|
403
|
+
formatter = get_formatter(ctx)
|
|
404
|
+
service = get_service(ctx, "data_app_service")
|
|
405
|
+
method = getattr(service, service_method)
|
|
406
|
+
kwargs = {"alias": project, "app_id": app_id, "wait": wait, "timeout_seconds": timeout}
|
|
407
|
+
if extra:
|
|
408
|
+
kwargs.update(extra)
|
|
409
|
+
try:
|
|
410
|
+
result = method(**kwargs)
|
|
411
|
+
except KeboolaApiError as exc:
|
|
412
|
+
formatter.error(
|
|
413
|
+
message=exc.message,
|
|
414
|
+
error_code=exc.error_code,
|
|
415
|
+
retryable=exc.retryable,
|
|
416
|
+
details=exc.details,
|
|
417
|
+
)
|
|
418
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
419
|
+
except ConfigError as exc:
|
|
420
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
421
|
+
raise typer.Exit(code=5) from None
|
|
422
|
+
formatter.output(
|
|
423
|
+
result,
|
|
424
|
+
lambda c, d: c.print(f"[bold green]Success:[/bold green] {d.get('message', '')}"),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@data_app_app.command("deploy")
|
|
429
|
+
def data_app_deploy(
|
|
430
|
+
ctx: typer.Context,
|
|
431
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
432
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
433
|
+
config_version: str | None = typer.Option(
|
|
434
|
+
None,
|
|
435
|
+
"--config-version",
|
|
436
|
+
help="Pin a specific Storage config version (defaults to latest).",
|
|
437
|
+
),
|
|
438
|
+
wait: bool = typer.Option(False, "--wait", help="Block until running or error."),
|
|
439
|
+
timeout: float = typer.Option(
|
|
440
|
+
DEFAULT_JOB_RUN_TIMEOUT, "--timeout", help="Max seconds to wait."
|
|
441
|
+
),
|
|
442
|
+
branch: int | None = typer.Option(
|
|
443
|
+
None,
|
|
444
|
+
"--branch",
|
|
445
|
+
help="Storage branch for reading the latest version (defaults to production).",
|
|
446
|
+
),
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Deploy the latest Storage config (the §9 redeploy contract)."""
|
|
449
|
+
_run_lifecycle(
|
|
450
|
+
ctx,
|
|
451
|
+
"deploy_data_app",
|
|
452
|
+
project=project,
|
|
453
|
+
app_id=app_id,
|
|
454
|
+
wait=wait,
|
|
455
|
+
timeout=timeout,
|
|
456
|
+
extra={"config_version": config_version, "branch_id": branch},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@data_app_app.command("start")
|
|
461
|
+
def data_app_start(
|
|
462
|
+
ctx: typer.Context,
|
|
463
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
464
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
465
|
+
wait: bool = typer.Option(False, "--wait", help="Block until running or error."),
|
|
466
|
+
timeout: float = typer.Option(
|
|
467
|
+
DEFAULT_JOB_RUN_TIMEOUT, "--timeout", help="Max seconds to wait."
|
|
468
|
+
),
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Wake an auto-suspended data app at its currently-pinned configVersion."""
|
|
471
|
+
_run_lifecycle(
|
|
472
|
+
ctx,
|
|
473
|
+
"start_data_app",
|
|
474
|
+
project=project,
|
|
475
|
+
app_id=app_id,
|
|
476
|
+
wait=wait,
|
|
477
|
+
timeout=timeout,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@data_app_app.command("stop")
|
|
482
|
+
def data_app_stop(
|
|
483
|
+
ctx: typer.Context,
|
|
484
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
485
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
486
|
+
wait: bool = typer.Option(False, "--wait", help="Block until stopped."),
|
|
487
|
+
timeout: float = typer.Option(
|
|
488
|
+
DEFAULT_JOB_RUN_TIMEOUT, "--timeout", help="Max seconds to wait."
|
|
489
|
+
),
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Stop a running data app (preserves the URL and Storage config)."""
|
|
492
|
+
_run_lifecycle(
|
|
493
|
+
ctx,
|
|
494
|
+
"stop_data_app",
|
|
495
|
+
project=project,
|
|
496
|
+
app_id=app_id,
|
|
497
|
+
wait=wait,
|
|
498
|
+
timeout=timeout,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# data-app delete
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@data_app_app.command("delete")
|
|
508
|
+
def data_app_delete(
|
|
509
|
+
ctx: typer.Context,
|
|
510
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
511
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
512
|
+
yes: bool = typer.Option(
|
|
513
|
+
False,
|
|
514
|
+
"--yes",
|
|
515
|
+
"-y",
|
|
516
|
+
help="Skip the confirmation prompt.",
|
|
517
|
+
),
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Delete the deployment AND the Storage config (cascade, irreversible)."""
|
|
520
|
+
formatter = get_formatter(ctx)
|
|
521
|
+
service = get_service(ctx, "data_app_service")
|
|
522
|
+
|
|
523
|
+
if (
|
|
524
|
+
not yes
|
|
525
|
+
and not formatter.json_mode
|
|
526
|
+
and not typer.confirm(
|
|
527
|
+
f"Delete data app {app_id} in '{project}'? "
|
|
528
|
+
"This deletes the deployment AND the Storage config (irreversible)."
|
|
529
|
+
)
|
|
530
|
+
):
|
|
531
|
+
formatter.console.print("Aborted.")
|
|
532
|
+
raise typer.Exit(code=0)
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
result = service.delete_data_app(alias=project, app_id=app_id)
|
|
536
|
+
except KeboolaApiError as exc:
|
|
537
|
+
formatter.error(
|
|
538
|
+
message=exc.message,
|
|
539
|
+
error_code=exc.error_code,
|
|
540
|
+
retryable=exc.retryable,
|
|
541
|
+
details=exc.details,
|
|
542
|
+
)
|
|
543
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
544
|
+
except ConfigError as exc:
|
|
545
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
546
|
+
raise typer.Exit(code=5) from None
|
|
547
|
+
|
|
548
|
+
formatter.output(
|
|
549
|
+
result,
|
|
550
|
+
lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# data-app password (requires Manage token)
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@data_app_app.command("password")
|
|
560
|
+
def data_app_password(
|
|
561
|
+
ctx: typer.Context,
|
|
562
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
563
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
564
|
+
) -> None:
|
|
565
|
+
"""Retrieve the simpleAuth password for a password-gated data app.
|
|
566
|
+
|
|
567
|
+
Requires the Manage API token in addition to the project's Storage
|
|
568
|
+
token. Default-deny since 0.28.0: read from an interactive hidden
|
|
569
|
+
prompt; pass top-level --allow-env-manage-token to read
|
|
570
|
+
KBC_MANAGE_API_TOKEN from env (CI/CD). Never persisted, never logged.
|
|
571
|
+
"""
|
|
572
|
+
formatter = get_formatter(ctx)
|
|
573
|
+
service = get_service(ctx, "data_app_service")
|
|
574
|
+
manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"])
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
result = service.get_data_app_password(
|
|
578
|
+
alias=project, app_id=app_id, manage_token=manage_token
|
|
579
|
+
)
|
|
580
|
+
except KeboolaApiError as exc:
|
|
581
|
+
formatter.error(
|
|
582
|
+
message=exc.message,
|
|
583
|
+
error_code=exc.error_code,
|
|
584
|
+
retryable=exc.retryable,
|
|
585
|
+
details=exc.details,
|
|
586
|
+
)
|
|
587
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
588
|
+
except ConfigError as exc:
|
|
589
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
590
|
+
raise typer.Exit(code=5) from None
|
|
591
|
+
|
|
592
|
+
formatter.output(
|
|
593
|
+
result,
|
|
594
|
+
lambda c, d: (
|
|
595
|
+
c.print(f"[bold green]Success:[/bold green] {d['message']}"),
|
|
596
|
+
c.print(f"\n[bold yellow]Password:[/bold yellow] {d['password']}"),
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
# data-app logs (Data Science /apps/{id}/logs/tail)
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@data_app_app.command("logs")
|
|
607
|
+
def data_app_logs(
|
|
608
|
+
ctx: typer.Context,
|
|
609
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
610
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
611
|
+
lines: int | None = typer.Option(
|
|
612
|
+
None,
|
|
613
|
+
"--lines",
|
|
614
|
+
help=(
|
|
615
|
+
"Tail the last N lines (default 500 when neither --lines nor "
|
|
616
|
+
"--since is set). Pass 0 to fetch the full current container "
|
|
617
|
+
"buffer (no server-side cap). Mutually exclusive with --since."
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
since: str | None = typer.Option(
|
|
621
|
+
None,
|
|
622
|
+
"--since",
|
|
623
|
+
help=(
|
|
624
|
+
"Fetch lines since this ISO 8601 timestamp WITH timezone "
|
|
625
|
+
"(e.g. '2026-05-21T13:00:00Z' or '2026-05-21T13:00:00+00:00'). "
|
|
626
|
+
"Mutually exclusive with --lines."
|
|
627
|
+
),
|
|
628
|
+
),
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Tail the container logs for a deployed data app.
|
|
631
|
+
|
|
632
|
+
Returns the full container stdout/stderr buffer including the spin-up
|
|
633
|
+
trace ([TIMING] git_clone, uv install, supervisord, runtime stack
|
|
634
|
+
traces). App must be running or recently-stopped -- never-started
|
|
635
|
+
apps return HTTP 400 "App X is not running"; recover with
|
|
636
|
+
``kbagent data-app start`` or ``data-app deploy``.
|
|
637
|
+
|
|
638
|
+
Auth: project Storage token only. No Manage token required.
|
|
639
|
+
|
|
640
|
+
Note: the log buffer can echo runtime secrets the app printed to
|
|
641
|
+
stdout/stderr (tracebacks, debug os.environ dumps). Consider secret
|
|
642
|
+
hygiene before piping --json output into AI agent context.
|
|
643
|
+
"""
|
|
644
|
+
formatter = get_formatter(ctx)
|
|
645
|
+
service = get_service(ctx, "data_app_service")
|
|
646
|
+
|
|
647
|
+
# Command-layer validations (UX-level usage errors -> exit 2). The
|
|
648
|
+
# service has its own mutex guard for `kbagent serve` / programmatic
|
|
649
|
+
# callers; see DataAppService.get_app_logs.
|
|
650
|
+
if lines is not None and since is not None:
|
|
651
|
+
formatter.error(
|
|
652
|
+
message="--lines and --since are mutually exclusive; pass exactly one.",
|
|
653
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
654
|
+
)
|
|
655
|
+
raise typer.Exit(code=2) from None
|
|
656
|
+
|
|
657
|
+
if lines is not None and lines < 0:
|
|
658
|
+
formatter.error(
|
|
659
|
+
message="--lines must be 0 (full buffer) or a positive integer.",
|
|
660
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
661
|
+
)
|
|
662
|
+
raise typer.Exit(code=2) from None
|
|
663
|
+
|
|
664
|
+
if since is not None:
|
|
665
|
+
try:
|
|
666
|
+
parsed = datetime.fromisoformat(since)
|
|
667
|
+
except ValueError as exc:
|
|
668
|
+
formatter.error(
|
|
669
|
+
message=(f"--since must be ISO 8601 (e.g. '2026-05-21T13:00:00Z'): {exc}"),
|
|
670
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
671
|
+
)
|
|
672
|
+
raise typer.Exit(code=2) from None
|
|
673
|
+
if parsed.tzinfo is None:
|
|
674
|
+
formatter.error(
|
|
675
|
+
message=(
|
|
676
|
+
"--since must include a timezone (e.g. 'Z' or '+00:00'); "
|
|
677
|
+
"the server rejects naive datetimes."
|
|
678
|
+
),
|
|
679
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
680
|
+
)
|
|
681
|
+
raise typer.Exit(code=2) from None
|
|
682
|
+
|
|
683
|
+
# Translate the CLI sentinels into the service kwargs:
|
|
684
|
+
# - neither set -> apply default lines=500
|
|
685
|
+
# - lines=0 -> opt-in to full buffer (no params sent)
|
|
686
|
+
# - lines>0 or since=Y -> pass through verbatim
|
|
687
|
+
if lines is None and since is None:
|
|
688
|
+
lines_arg: int | None = 500
|
|
689
|
+
elif lines == 0:
|
|
690
|
+
lines_arg = None
|
|
691
|
+
else:
|
|
692
|
+
lines_arg = lines
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
result = service.get_app_logs(
|
|
696
|
+
alias=project,
|
|
697
|
+
app_id=app_id,
|
|
698
|
+
lines=lines_arg,
|
|
699
|
+
since=since,
|
|
700
|
+
)
|
|
701
|
+
except KeboolaApiError as exc:
|
|
702
|
+
formatter.error(
|
|
703
|
+
message=exc.message,
|
|
704
|
+
error_code=exc.error_code,
|
|
705
|
+
retryable=exc.retryable,
|
|
706
|
+
details=exc.details,
|
|
707
|
+
)
|
|
708
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
709
|
+
except ConfigError as exc:
|
|
710
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
711
|
+
raise typer.Exit(code=5) from None
|
|
712
|
+
|
|
713
|
+
def _print_logs(c: Console, d: dict) -> None:
|
|
714
|
+
c.print(
|
|
715
|
+
f"\n[bold]Logs[/bold] for data app [cyan]{escape(str(d['app_id']))}[/cyan] "
|
|
716
|
+
f"in [magenta]{escape(d['project_alias'])}[/magenta] "
|
|
717
|
+
f"([dim]{d['lines_returned']} lines[/dim])"
|
|
718
|
+
)
|
|
719
|
+
# ``markup=False`` so literal [TIMING], [INFO], etc. in the log
|
|
720
|
+
# payload are not interpreted as Rich tags. ``highlight=False``
|
|
721
|
+
# disables Rich's auto-highlighter for URLs / IPs / timestamps
|
|
722
|
+
# in log lines (false positives that just add visual noise).
|
|
723
|
+
# ``end=""`` because the server includes a trailing newline.
|
|
724
|
+
c.print(d["text"], markup=False, highlight=False, end="")
|
|
725
|
+
|
|
726
|
+
formatter.output(result, _print_logs)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
# data-app secrets-{set|list|get|remove} -- flat commands matching the
|
|
731
|
+
# existing branch.metadata-* / config.variables-* pattern. Subgroups under
|
|
732
|
+
# Typer subgroups conflict with the flat permission/hint registry.
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _parse_secret_arg(arg: str) -> tuple[str, str]:
|
|
737
|
+
"""Split ``#KEY=VALUE`` into ``(key, value)``.
|
|
738
|
+
|
|
739
|
+
The value may contain ``=``; only the FIRST ``=`` is the separator.
|
|
740
|
+
"""
|
|
741
|
+
if "=" not in arg:
|
|
742
|
+
raise typer.BadParameter(
|
|
743
|
+
f"Expected '#KEY=VALUE'; got {arg!r} (no '=' separator).",
|
|
744
|
+
param_hint="--secret",
|
|
745
|
+
)
|
|
746
|
+
key, _, value = arg.partition("=")
|
|
747
|
+
if not key:
|
|
748
|
+
raise typer.BadParameter(
|
|
749
|
+
f"Empty secret key in {arg!r}; expected '#KEY=VALUE'.",
|
|
750
|
+
param_hint="--secret",
|
|
751
|
+
)
|
|
752
|
+
return key, value
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _read_secrets_file(path: Path) -> dict[str, str]:
|
|
756
|
+
try:
|
|
757
|
+
text = path.read_text(encoding="utf-8")
|
|
758
|
+
except OSError as exc:
|
|
759
|
+
raise typer.BadParameter(
|
|
760
|
+
f"Cannot read secrets file {path}: {exc}",
|
|
761
|
+
param_hint="--secrets-file",
|
|
762
|
+
) from exc
|
|
763
|
+
try:
|
|
764
|
+
parsed = json.loads(text)
|
|
765
|
+
except json.JSONDecodeError as exc:
|
|
766
|
+
raise typer.BadParameter(
|
|
767
|
+
f"Secrets file {path} is not valid JSON: {exc}",
|
|
768
|
+
param_hint="--secrets-file",
|
|
769
|
+
) from exc
|
|
770
|
+
if not isinstance(parsed, dict):
|
|
771
|
+
raise typer.BadParameter(
|
|
772
|
+
f"Secrets file {path} must be a JSON object mapping #KEY -> value.",
|
|
773
|
+
param_hint="--secrets-file",
|
|
774
|
+
)
|
|
775
|
+
out: dict[str, str] = {}
|
|
776
|
+
for key, value in parsed.items():
|
|
777
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
778
|
+
raise typer.BadParameter(
|
|
779
|
+
f"Secrets file {path} contains non-string entry for {key!r}.",
|
|
780
|
+
param_hint="--secrets-file",
|
|
781
|
+
)
|
|
782
|
+
out[key] = value
|
|
783
|
+
if not out:
|
|
784
|
+
raise typer.BadParameter(
|
|
785
|
+
f"Secrets file {path} is empty.",
|
|
786
|
+
param_hint="--secrets-file",
|
|
787
|
+
)
|
|
788
|
+
return out
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@data_app_app.command("secrets-set")
|
|
792
|
+
def data_app_secrets_set(
|
|
793
|
+
ctx: typer.Context,
|
|
794
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
795
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
796
|
+
secret: list[str] | None = typer.Option(
|
|
797
|
+
None,
|
|
798
|
+
"--secret",
|
|
799
|
+
help=(
|
|
800
|
+
"One or more '#KEY=VALUE' plaintext entries. Repeatable. "
|
|
801
|
+
"Mutually exclusive with --secrets-file."
|
|
802
|
+
),
|
|
803
|
+
),
|
|
804
|
+
secrets_file: Path | None = typer.Option(
|
|
805
|
+
None,
|
|
806
|
+
"--secrets-file",
|
|
807
|
+
help="Path to a JSON file mapping '#KEY' -> 'plaintext value'.",
|
|
808
|
+
exists=True,
|
|
809
|
+
readable=True,
|
|
810
|
+
dir_okay=False,
|
|
811
|
+
),
|
|
812
|
+
branch: int | None = typer.Option(
|
|
813
|
+
None,
|
|
814
|
+
"--branch",
|
|
815
|
+
help="Storage branch ID for the linked config (defaults to production).",
|
|
816
|
+
),
|
|
817
|
+
allow_plaintext_on_encrypt_failure: bool = typer.Option(
|
|
818
|
+
False,
|
|
819
|
+
"--allow-plaintext-on-encrypt-failure",
|
|
820
|
+
help=(
|
|
821
|
+
"Bootstrap/debug only: write the value as-is if the Encryption API "
|
|
822
|
+
"did not return a project-scoped ciphertext. NEVER use in production."
|
|
823
|
+
),
|
|
824
|
+
),
|
|
825
|
+
dry_run: bool = typer.Option(
|
|
826
|
+
False,
|
|
827
|
+
"--dry-run",
|
|
828
|
+
help="Show the encryption request and Storage PUT body without making either call.",
|
|
829
|
+
),
|
|
830
|
+
no_hint_next: bool = typer.Option(
|
|
831
|
+
False,
|
|
832
|
+
"--no-hint-next",
|
|
833
|
+
help="Suppress the 'now run kbagent data-app deploy' hint in the output.",
|
|
834
|
+
),
|
|
835
|
+
) -> None:
|
|
836
|
+
"""Encrypt and write app-runtime secrets to the linked Storage config.
|
|
837
|
+
|
|
838
|
+
The '#'-prefix is required on every key (Keboola encryption convention).
|
|
839
|
+
The runtime exposes each secret as an env var with '#' stripped, '-'
|
|
840
|
+
replaced with '_', and uppercased ('#my-api-key' -> 'MY_API_KEY').
|
|
841
|
+
|
|
842
|
+
The command never auto-deploys; the running container keeps the old
|
|
843
|
+
config until the next 'kbagent data-app deploy' call.
|
|
844
|
+
|
|
845
|
+
Reference: https://help.keboola.com/data-apps/python-js/
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
formatter = get_formatter(ctx)
|
|
849
|
+
service = get_service(ctx, "data_app_service")
|
|
850
|
+
|
|
851
|
+
if secret and secrets_file:
|
|
852
|
+
formatter.error(
|
|
853
|
+
message=("--secret and --secrets-file are mutually exclusive; pick one input mode."),
|
|
854
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
855
|
+
)
|
|
856
|
+
raise typer.Exit(code=2) from None
|
|
857
|
+
|
|
858
|
+
if not secret and not secrets_file:
|
|
859
|
+
formatter.error(
|
|
860
|
+
message=("Provide at least one --secret '#KEY=VALUE' or --secrets-file PATH."),
|
|
861
|
+
error_code=ErrorCode.MISSING_PARAMETER,
|
|
862
|
+
)
|
|
863
|
+
raise typer.Exit(code=2) from None
|
|
864
|
+
|
|
865
|
+
secrets_map: dict[str, str] = {}
|
|
866
|
+
if secret:
|
|
867
|
+
for entry in secret:
|
|
868
|
+
try:
|
|
869
|
+
key, value = _parse_secret_arg(entry)
|
|
870
|
+
except typer.BadParameter as exc:
|
|
871
|
+
formatter.error(
|
|
872
|
+
message=str(exc),
|
|
873
|
+
error_code=ErrorCode.DATA_APP_INVALID_SECRET,
|
|
874
|
+
)
|
|
875
|
+
raise typer.Exit(code=2) from None
|
|
876
|
+
secrets_map[key] = value
|
|
877
|
+
if secrets_file:
|
|
878
|
+
try:
|
|
879
|
+
secrets_map.update(_read_secrets_file(secrets_file))
|
|
880
|
+
except typer.BadParameter as exc:
|
|
881
|
+
formatter.error(
|
|
882
|
+
message=str(exc),
|
|
883
|
+
error_code=ErrorCode.DATA_APP_INVALID_SECRET,
|
|
884
|
+
)
|
|
885
|
+
raise typer.Exit(code=2) from None
|
|
886
|
+
|
|
887
|
+
try:
|
|
888
|
+
result = service.set_data_app_secrets(
|
|
889
|
+
alias=project,
|
|
890
|
+
app_id=app_id,
|
|
891
|
+
secrets=secrets_map,
|
|
892
|
+
branch_id=branch,
|
|
893
|
+
allow_plaintext_on_encrypt_failure=allow_plaintext_on_encrypt_failure,
|
|
894
|
+
dry_run=dry_run,
|
|
895
|
+
)
|
|
896
|
+
except KeboolaApiError as exc:
|
|
897
|
+
formatter.error(
|
|
898
|
+
message=exc.message,
|
|
899
|
+
error_code=exc.error_code,
|
|
900
|
+
retryable=exc.retryable,
|
|
901
|
+
details=exc.details,
|
|
902
|
+
)
|
|
903
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
904
|
+
except ConfigError as exc:
|
|
905
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
906
|
+
raise typer.Exit(code=5) from None
|
|
907
|
+
|
|
908
|
+
# Reserved-name shadowing -- emit stderr WARN per collision so a
|
|
909
|
+
# script piping stdout to a JSON parser is unaffected.
|
|
910
|
+
shadowed = result.get("shadowed_by_runtime", [])
|
|
911
|
+
if shadowed and not formatter.json_mode:
|
|
912
|
+
for env_var in shadowed:
|
|
913
|
+
formatter.err_console.print(
|
|
914
|
+
f"[yellow]Warning:[/yellow] {env_var} is auto-injected by the data-app "
|
|
915
|
+
f"runtime; the platform value silently shadows yours. See {_REF_STORAGE_ACCESS}.",
|
|
916
|
+
style="yellow",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
if no_hint_next and isinstance(result, dict):
|
|
920
|
+
result.pop("next_step", None)
|
|
921
|
+
|
|
922
|
+
formatter.output(
|
|
923
|
+
result,
|
|
924
|
+
lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"),
|
|
925
|
+
)
|
|
926
|
+
if not no_hint_next and not formatter.json_mode and result.get("next_step"):
|
|
927
|
+
formatter.console.print(f"[dim]Next: {result['next_step']}[/dim]")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
@data_app_app.command("secrets-list")
|
|
931
|
+
def data_app_secrets_list(
|
|
932
|
+
ctx: typer.Context,
|
|
933
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
934
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
935
|
+
branch: int | None = typer.Option(
|
|
936
|
+
None,
|
|
937
|
+
"--branch",
|
|
938
|
+
help="Storage branch ID for the linked config (defaults to production).",
|
|
939
|
+
),
|
|
940
|
+
show_fingerprint: bool = typer.Option(
|
|
941
|
+
False,
|
|
942
|
+
"--show-fingerprint",
|
|
943
|
+
help="Include a short ciphertext fingerprint per key. Default omits to keep --json safe to paste into tickets.",
|
|
944
|
+
),
|
|
945
|
+
) -> None:
|
|
946
|
+
"""List the keys in parameters.dataApp.secrets, with derived runtime env-var names.
|
|
947
|
+
|
|
948
|
+
Never echoes the encrypted ciphertext in full and never decrypts.
|
|
949
|
+
|
|
950
|
+
Reference: https://help.keboola.com/data-apps/python-js/
|
|
951
|
+
"""
|
|
952
|
+
|
|
953
|
+
formatter = get_formatter(ctx)
|
|
954
|
+
service = get_service(ctx, "data_app_service")
|
|
955
|
+
try:
|
|
956
|
+
result = service.list_data_app_secrets(
|
|
957
|
+
alias=project,
|
|
958
|
+
app_id=app_id,
|
|
959
|
+
branch_id=branch,
|
|
960
|
+
show_fingerprint=show_fingerprint,
|
|
961
|
+
)
|
|
962
|
+
except KeboolaApiError as exc:
|
|
963
|
+
formatter.error(
|
|
964
|
+
message=exc.message,
|
|
965
|
+
error_code=exc.error_code,
|
|
966
|
+
retryable=exc.retryable,
|
|
967
|
+
details=exc.details,
|
|
968
|
+
)
|
|
969
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
970
|
+
except ConfigError as exc:
|
|
971
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
972
|
+
raise typer.Exit(code=5) from None
|
|
973
|
+
|
|
974
|
+
if formatter.json_mode:
|
|
975
|
+
formatter.output(result)
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
if not result["secrets"]:
|
|
979
|
+
formatter.console.print("[dim]No secrets set on this data app.[/dim]")
|
|
980
|
+
return
|
|
981
|
+
formatter.console.print(
|
|
982
|
+
f"\n[bold]{result['count']} secret(s)[/bold] on data app "
|
|
983
|
+
f"[cyan]{result['app_id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:"
|
|
984
|
+
)
|
|
985
|
+
for entry in result["secrets"]:
|
|
986
|
+
marker = (
|
|
987
|
+
" [yellow](shadowed by runtime)[/yellow]" if entry.get("shadowed_by_runtime") else ""
|
|
988
|
+
)
|
|
989
|
+
line = f" [bold]{entry['key']}[/bold] -> env [cyan]{entry['env_var']}[/cyan]{marker}"
|
|
990
|
+
if "fingerprint" in entry:
|
|
991
|
+
line += f" [dim]fingerprint={entry['fingerprint']} prefix={entry.get('encryption_prefix', '')}[/dim]"
|
|
992
|
+
formatter.console.print(line)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@data_app_app.command("secrets-get")
|
|
996
|
+
def data_app_secrets_get(
|
|
997
|
+
ctx: typer.Context,
|
|
998
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
999
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
1000
|
+
key: str = typer.Option(
|
|
1001
|
+
..., "--key", help="Env-var key (with optional '#' prefix for encrypted secrets)."
|
|
1002
|
+
),
|
|
1003
|
+
branch: int | None = typer.Option(
|
|
1004
|
+
None,
|
|
1005
|
+
"--branch",
|
|
1006
|
+
help="Storage branch ID for the linked config (defaults to production).",
|
|
1007
|
+
),
|
|
1008
|
+
) -> None:
|
|
1009
|
+
"""Show ONE key from parameters.dataApp.secrets.
|
|
1010
|
+
|
|
1011
|
+
For an ENCRYPTED ('#') secret this is metadata only -- the Encryption
|
|
1012
|
+
API has no decrypt endpoint, so the CLI never echoes the decrypted
|
|
1013
|
+
value. For a PLAIN (unencrypted) config value the literal value is
|
|
1014
|
+
shown; it is already stored in clear and visible via `config detail`.
|
|
1015
|
+
|
|
1016
|
+
Reference: https://help.keboola.com/data-apps/python-js/
|
|
1017
|
+
"""
|
|
1018
|
+
|
|
1019
|
+
formatter = get_formatter(ctx)
|
|
1020
|
+
service = get_service(ctx, "data_app_service")
|
|
1021
|
+
try:
|
|
1022
|
+
result = service.get_data_app_secret(
|
|
1023
|
+
alias=project,
|
|
1024
|
+
app_id=app_id,
|
|
1025
|
+
key=key,
|
|
1026
|
+
branch_id=branch,
|
|
1027
|
+
)
|
|
1028
|
+
except KeboolaApiError as exc:
|
|
1029
|
+
formatter.error(
|
|
1030
|
+
message=exc.message,
|
|
1031
|
+
error_code=exc.error_code,
|
|
1032
|
+
retryable=exc.retryable,
|
|
1033
|
+
details=exc.details,
|
|
1034
|
+
)
|
|
1035
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1036
|
+
except ConfigError as exc:
|
|
1037
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1038
|
+
raise typer.Exit(code=5) from None
|
|
1039
|
+
|
|
1040
|
+
if formatter.json_mode:
|
|
1041
|
+
formatter.output(result)
|
|
1042
|
+
return
|
|
1043
|
+
formatter.console.print(
|
|
1044
|
+
f"\n[bold]{result['key']}[/bold] -> env [cyan]{result['env_var']}[/cyan]"
|
|
1045
|
+
)
|
|
1046
|
+
if result.get("encrypted"):
|
|
1047
|
+
formatter.console.print(
|
|
1048
|
+
f" [dim]fingerprint={result['fingerprint']} prefix={result['encryption_prefix']}[/dim]"
|
|
1049
|
+
)
|
|
1050
|
+
else:
|
|
1051
|
+
formatter.console.print(f" value (plaintext, unencrypted): {result['value']}")
|
|
1052
|
+
formatter.err_console.print(
|
|
1053
|
+
" [yellow]Note:[/yellow] this value is stored unencrypted in the config. "
|
|
1054
|
+
"Use `data-app secrets-set '#KEY=...'` to store sensitive values encrypted."
|
|
1055
|
+
)
|
|
1056
|
+
if result.get("shadowed_by_runtime"):
|
|
1057
|
+
# Same stdout/stderr-separation rationale as secrets-set: keep
|
|
1058
|
+
# warnings off stdout so a script piping the metadata to a parser
|
|
1059
|
+
# is unaffected.
|
|
1060
|
+
formatter.err_console.print(
|
|
1061
|
+
f" [yellow]Warning:[/yellow] {result['env_var']} is auto-injected by "
|
|
1062
|
+
f"the data-app runtime; the platform value silently shadows yours. "
|
|
1063
|
+
f"See {_REF_STORAGE_ACCESS}."
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@data_app_app.command("secrets-remove")
|
|
1068
|
+
def data_app_secrets_remove(
|
|
1069
|
+
ctx: typer.Context,
|
|
1070
|
+
project: str = typer.Option(..., "--project", help="Project alias"),
|
|
1071
|
+
app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"),
|
|
1072
|
+
key: list[str] = typer.Option(
|
|
1073
|
+
...,
|
|
1074
|
+
"--key",
|
|
1075
|
+
help="Env-var key to remove (with optional '#' prefix). Repeatable.",
|
|
1076
|
+
),
|
|
1077
|
+
branch: int | None = typer.Option(
|
|
1078
|
+
None,
|
|
1079
|
+
"--branch",
|
|
1080
|
+
help="Storage branch ID for the linked config (defaults to production).",
|
|
1081
|
+
),
|
|
1082
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
1083
|
+
dry_run: bool = typer.Option(
|
|
1084
|
+
False, "--dry-run", help="Preview the Storage PUT body without making the call."
|
|
1085
|
+
),
|
|
1086
|
+
) -> None:
|
|
1087
|
+
"""Remove one or more app-runtime secrets. Idempotent (missing keys are exit 0).
|
|
1088
|
+
|
|
1089
|
+
A removal can break the running app at the next deploy if it relied on
|
|
1090
|
+
the secret; the command flags this in the response and never auto-deploys.
|
|
1091
|
+
|
|
1092
|
+
Reference: https://help.keboola.com/data-apps/python-js/
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
formatter = get_formatter(ctx)
|
|
1096
|
+
service = get_service(ctx, "data_app_service")
|
|
1097
|
+
|
|
1098
|
+
if (
|
|
1099
|
+
not yes
|
|
1100
|
+
and not formatter.json_mode
|
|
1101
|
+
and not dry_run
|
|
1102
|
+
and not typer.confirm(
|
|
1103
|
+
f"Remove {len(key)} secret(s) from data app {app_id} in '{project}'? "
|
|
1104
|
+
"This may break the app at next deploy if it depends on these values."
|
|
1105
|
+
)
|
|
1106
|
+
):
|
|
1107
|
+
formatter.console.print("Aborted.")
|
|
1108
|
+
raise typer.Exit(code=0)
|
|
1109
|
+
|
|
1110
|
+
try:
|
|
1111
|
+
result = service.remove_data_app_secrets(
|
|
1112
|
+
alias=project,
|
|
1113
|
+
app_id=app_id,
|
|
1114
|
+
keys=key,
|
|
1115
|
+
branch_id=branch,
|
|
1116
|
+
dry_run=dry_run,
|
|
1117
|
+
)
|
|
1118
|
+
except KeboolaApiError as exc:
|
|
1119
|
+
formatter.error(
|
|
1120
|
+
message=exc.message,
|
|
1121
|
+
error_code=exc.error_code,
|
|
1122
|
+
retryable=exc.retryable,
|
|
1123
|
+
details=exc.details,
|
|
1124
|
+
)
|
|
1125
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1126
|
+
except ConfigError as exc:
|
|
1127
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1128
|
+
raise typer.Exit(code=5) from None
|
|
1129
|
+
|
|
1130
|
+
formatter.output(
|
|
1131
|
+
result,
|
|
1132
|
+
lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"),
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# ---------------------------------------------------------------------------
|
|
1137
|
+
# data-app validate-repo
|
|
1138
|
+
# ---------------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
@data_app_app.command("validate-repo")
|
|
1142
|
+
def data_app_validate_repo(
|
|
1143
|
+
ctx: typer.Context,
|
|
1144
|
+
git_repo: str = typer.Option(
|
|
1145
|
+
..., "--git-repo", help="GitHub repo URL (https://github.com/owner/repo)."
|
|
1146
|
+
),
|
|
1147
|
+
git_branch: str = typer.Option(
|
|
1148
|
+
"main", "--git-branch", help="Git ref to validate (default: main)."
|
|
1149
|
+
),
|
|
1150
|
+
git_public: bool = typer.Option(
|
|
1151
|
+
True,
|
|
1152
|
+
"--git-public/--no-git-public",
|
|
1153
|
+
help="Public repo (no PAT). Use --no-git-public for private repos and pass --git-pat-env / --git-pat-file.",
|
|
1154
|
+
),
|
|
1155
|
+
git_pat_env: str | None = typer.Option(
|
|
1156
|
+
None,
|
|
1157
|
+
"--git-pat-env",
|
|
1158
|
+
help="Read GitHub PAT from this env var (recommended; no argv leak).",
|
|
1159
|
+
),
|
|
1160
|
+
git_pat_file: Path | None = typer.Option(
|
|
1161
|
+
None,
|
|
1162
|
+
"--git-pat-file",
|
|
1163
|
+
help="Read GitHub PAT from this file.",
|
|
1164
|
+
exists=True,
|
|
1165
|
+
readable=True,
|
|
1166
|
+
dir_okay=False,
|
|
1167
|
+
),
|
|
1168
|
+
type_: str = typer.Option(
|
|
1169
|
+
"python-js",
|
|
1170
|
+
"--type",
|
|
1171
|
+
help="Repo layout to validate against. Currently only 'python-js' is supported; other types tracked as follow-up.",
|
|
1172
|
+
),
|
|
1173
|
+
strict: bool = typer.Option(
|
|
1174
|
+
False, "--strict", help="Treat WARN findings as failures (exit 1)."
|
|
1175
|
+
),
|
|
1176
|
+
) -> None:
|
|
1177
|
+
"""Pre-flight check that a git repo follows the Keboola data-app Golden Rule.
|
|
1178
|
+
|
|
1179
|
+
Walks the repo via GitHub Contents + Trees API and validates the
|
|
1180
|
+
documented structure (keboola-config/ tree, pyproject.toml, no
|
|
1181
|
+
'pip install' in setup.sh, requires-python at-or-below the runtime
|
|
1182
|
+
pin, etc.). Each check emits BLOCKING / WARN / OK with a citation
|
|
1183
|
+
to the help-doc anchor that defines the rule.
|
|
1184
|
+
|
|
1185
|
+
Reference: https://help.keboola.com/data-apps/python-js/
|
|
1186
|
+
"""
|
|
1187
|
+
|
|
1188
|
+
formatter = get_formatter(ctx)
|
|
1189
|
+
service = get_service(ctx, "repo_validate_service")
|
|
1190
|
+
|
|
1191
|
+
if git_pat_env and git_pat_file:
|
|
1192
|
+
formatter.error(
|
|
1193
|
+
message="--git-pat-env and --git-pat-file are mutually exclusive.",
|
|
1194
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
1195
|
+
)
|
|
1196
|
+
raise typer.Exit(code=2) from None
|
|
1197
|
+
|
|
1198
|
+
pat_supplied = git_pat_env is not None or git_pat_file is not None
|
|
1199
|
+
if pat_supplied and git_public:
|
|
1200
|
+
# The default --git-public means "anonymous fetch"; sending a PAT
|
|
1201
|
+
# with it is a contradiction (the resulting 404 would lead to a
|
|
1202
|
+
# 'private repo -- pass --git-pat-env' message recommending the
|
|
1203
|
+
# flag the user already passed). Fail loud instead.
|
|
1204
|
+
formatter.error(
|
|
1205
|
+
message=(
|
|
1206
|
+
"--git-pat-env / --git-pat-file requires --no-git-public; the "
|
|
1207
|
+
"default --git-public flag opts into an anonymous fetch and "
|
|
1208
|
+
"would silently drop the PAT."
|
|
1209
|
+
),
|
|
1210
|
+
error_code=ErrorCode.USAGE_ERROR,
|
|
1211
|
+
)
|
|
1212
|
+
raise typer.Exit(code=2) from None
|
|
1213
|
+
|
|
1214
|
+
pat: str | None = None
|
|
1215
|
+
if git_pat_env is not None:
|
|
1216
|
+
pat = _read_pat_from_env(git_pat_env)
|
|
1217
|
+
elif git_pat_file is not None:
|
|
1218
|
+
pat = _read_pat_from_file(git_pat_file)
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
result = service.validate_repo(
|
|
1222
|
+
git_repo=git_repo,
|
|
1223
|
+
git_branch=git_branch,
|
|
1224
|
+
git_public=git_public,
|
|
1225
|
+
git_pat=pat,
|
|
1226
|
+
type_=type_,
|
|
1227
|
+
strict=strict,
|
|
1228
|
+
)
|
|
1229
|
+
except KeboolaApiError as exc:
|
|
1230
|
+
formatter.error(
|
|
1231
|
+
message=exc.message,
|
|
1232
|
+
error_code=exc.error_code,
|
|
1233
|
+
retryable=exc.retryable,
|
|
1234
|
+
details=exc.details,
|
|
1235
|
+
)
|
|
1236
|
+
raise typer.Exit(code=map_error_to_exit_code(exc)) from None
|
|
1237
|
+
except ConfigError as exc:
|
|
1238
|
+
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
|
|
1239
|
+
raise typer.Exit(code=5) from None
|
|
1240
|
+
|
|
1241
|
+
if formatter.json_mode:
|
|
1242
|
+
formatter.output(result)
|
|
1243
|
+
else:
|
|
1244
|
+
verdict_colour = (
|
|
1245
|
+
"red"
|
|
1246
|
+
if result["verdict"] == "BLOCKING"
|
|
1247
|
+
else "yellow"
|
|
1248
|
+
if result["verdict"] == "WARN"
|
|
1249
|
+
else "green"
|
|
1250
|
+
)
|
|
1251
|
+
formatter.console.print(
|
|
1252
|
+
f"\n[bold {verdict_colour}]{result['verdict']}[/bold {verdict_colour}] "
|
|
1253
|
+
f"-- {result['blocking_count']} BLOCKING, "
|
|
1254
|
+
f"{result['warn_count']} WARN, {result['ok_count']} OK"
|
|
1255
|
+
)
|
|
1256
|
+
for check in result["checks"]:
|
|
1257
|
+
sev = check["severity"]
|
|
1258
|
+
colour = "red" if sev == "BLOCKING" else "yellow" if sev == "WARN" else "green"
|
|
1259
|
+
line = f" [{colour}]{sev:<8}[/{colour}] {check['name']}"
|
|
1260
|
+
if check.get("message"):
|
|
1261
|
+
line += f" -- {check['message']}"
|
|
1262
|
+
formatter.console.print(line)
|
|
1263
|
+
formatter.console.print(f"\n[dim]{result['message']}[/dim]")
|
|
1264
|
+
|
|
1265
|
+
if result.get("is_failure"):
|
|
1266
|
+
# validate-repo's own exit code: BLOCKING (or strict-WARN) -> 1.
|
|
1267
|
+
# We bypass the structured-error formatter because validate-repo
|
|
1268
|
+
# output is itself the structured error envelope.
|
|
1269
|
+
if formatter.json_mode:
|
|
1270
|
+
# JSON envelope is already printed; just exit non-zero.
|
|
1271
|
+
raise typer.Exit(code=1)
|
|
1272
|
+
# Human mode: the verdict line above conveyed the failure.
|
|
1273
|
+
raise typer.Exit(code=1)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
# Attach the data-app git-* commands. They live in _data_app_git.py to keep
|
|
1277
|
+
# this module under the file-size budget (CONTRIBUTING.md "File-size budgets");
|
|
1278
|
+
# they still register as `kbagent data-app git-*` on the same sub-app.
|
|
1279
|
+
register_git_commands(data_app_app)
|