stackport 0.4.7__tar.gz → 0.4.8__tar.gz

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.
Files changed (172) hide show
  1. {stackport-0.4.7/stackport.egg-info → stackport-0.4.8}/PKG-INFO +3 -2
  2. {stackport-0.4.7 → stackport-0.4.8}/README.md +2 -1
  3. {stackport-0.4.7 → stackport-0.4.8}/backend/aws_client.py +2 -0
  4. {stackport-0.4.7 → stackport-0.4.8}/backend/cli.py +45 -16
  5. {stackport-0.4.7 → stackport-0.4.8}/backend/config.py +9 -2
  6. {stackport-0.4.7 → stackport-0.4.8}/backend/connection.py +28 -7
  7. stackport-0.4.8/backend/learn/cli_setup.py +114 -0
  8. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/common.py +22 -20
  9. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/endpoints.py +3 -2
  10. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/learn.py +12 -0
  11. {stackport-0.4.7 → stackport-0.4.8}/backend/websocket.py +125 -50
  12. {stackport-0.4.7 → stackport-0.4.8}/pyproject.toml +1 -1
  13. {stackport-0.4.7 → stackport-0.4.8/stackport.egg-info}/PKG-INFO +3 -2
  14. {stackport-0.4.7 → stackport-0.4.8}/stackport.egg-info/SOURCES.txt +28 -26
  15. {stackport-0.4.7 → stackport-0.4.8}/tests/test_aws_client_tls.py +43 -0
  16. stackport-0.4.8/tests/test_cli.py +370 -0
  17. {stackport-0.4.7 → stackport-0.4.8}/tests/test_connection.py +95 -0
  18. {stackport-0.4.7 → stackport-0.4.8}/tests/test_endpoint_store_env.py +10 -0
  19. stackport-0.4.8/tests/test_learn_cli_setup.py +153 -0
  20. {stackport-0.4.7 → stackport-0.4.8}/tests/test_multi_endpoint.py +12 -8
  21. stackport-0.4.8/tests/test_websocket.py +409 -0
  22. stackport-0.4.7/ui/dist/assets/CloudscapeAbout-C464xiLP.js → stackport-0.4.8/ui/dist/assets/CloudscapeAbout-ykzJ5x3Y.js +1 -1
  23. stackport-0.4.7/ui/dist/assets/CloudscapeDashboard--Puj8I6B.js → stackport-0.4.8/ui/dist/assets/CloudscapeDashboard-CJt6f1T3.js +1 -1
  24. stackport-0.4.7/ui/dist/assets/CloudscapeLearn-CT7ucW_g.js → stackport-0.4.8/ui/dist/assets/CloudscapeLearn-l4d6rjUk.js +1 -1
  25. stackport-0.4.7/ui/dist/assets/CloudscapeResourceBrowser-CprDqhcQ.js → stackport-0.4.8/ui/dist/assets/CloudscapeResourceBrowser-DETT9g8r.js +3 -3
  26. stackport-0.4.7/ui/dist/assets/CloudscapeSettings-D_9KUPvP.js → stackport-0.4.8/ui/dist/assets/CloudscapeSettings-Hg6Upkx0.js +1 -1
  27. stackport-0.4.7/ui/dist/assets/CloudscapeShell-BCyqqtsn.js → stackport-0.4.8/ui/dist/assets/CloudscapeShell-S38ixn79.js +1 -1
  28. stackport-0.4.7/ui/dist/assets/ServiceStatus-KR6X2qeu.js → stackport-0.4.8/ui/dist/assets/ServiceStatus-BHSv7SA3.js +1 -1
  29. stackport-0.4.7/ui/dist/assets/StateMachineGraph-D9IP3035.js → stackport-0.4.8/ui/dist/assets/StateMachineGraph-DUiGayyA.js +1 -1
  30. stackport-0.4.8/ui/dist/assets/index-CTGN6ph7.js +67 -0
  31. stackport-0.4.7/ui/dist/assets/index-WR8pyfQO.js → stackport-0.4.8/ui/dist/assets/index-ClXofh-m.js +1 -1
  32. stackport-0.4.7/ui/dist/assets/index-DKnYLurL.js → stackport-0.4.8/ui/dist/assets/index-DEgNFpXr.js +1 -1
  33. stackport-0.4.7/ui/dist/assets/index-WNsJzp13.js → stackport-0.4.8/ui/dist/assets/index-SWJZXC-H.js +1 -1
  34. stackport-0.4.7/ui/dist/assets/index-CHdSsqYw.js → stackport-0.4.8/ui/dist/assets/index-v1JyxN8N.js +1 -1
  35. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/index.html +1 -1
  36. stackport-0.4.7/tests/test_cli.py +0 -211
  37. stackport-0.4.7/tests/test_websocket.py +0 -58
  38. stackport-0.4.7/ui/dist/assets/index-CvtAnA9F.js +0 -67
  39. {stackport-0.4.7 → stackport-0.4.8}/LICENSE +0 -0
  40. {stackport-0.4.7 → stackport-0.4.8}/MANIFEST.in +0 -0
  41. {stackport-0.4.7 → stackport-0.4.8}/backend/__init__.py +0 -0
  42. {stackport-0.4.7 → stackport-0.4.8}/backend/aws_emulator.py +0 -0
  43. {stackport-0.4.7 → stackport-0.4.8}/backend/aws_errors.py +0 -0
  44. {stackport-0.4.7 → stackport-0.4.8}/backend/cache.py +0 -0
  45. {stackport-0.4.7 → stackport-0.4.8}/backend/emulators.py +0 -0
  46. {stackport-0.4.7 → stackport-0.4.8}/backend/endpoint_store.py +0 -0
  47. {stackport-0.4.7 → stackport-0.4.8}/backend/error_handlers.py +0 -0
  48. {stackport-0.4.7 → stackport-0.4.8}/backend/learn/__init__.py +0 -0
  49. {stackport-0.4.7 → stackport-0.4.8}/backend/learn/content.py +0 -0
  50. {stackport-0.4.7 → stackport-0.4.8}/backend/learn/hotspots.py +0 -0
  51. {stackport-0.4.7 → stackport-0.4.8}/backend/learn/trails/aws-first-steps.json +0 -0
  52. {stackport-0.4.7 → stackport-0.4.8}/backend/learn/verify.py +0 -0
  53. {stackport-0.4.7 → stackport-0.4.8}/backend/learn_store.py +0 -0
  54. {stackport-0.4.7 → stackport-0.4.8}/backend/main.py +0 -0
  55. {stackport-0.4.7 → stackport-0.4.8}/backend/origins.py +0 -0
  56. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/__init__.py +0 -0
  57. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/dynamodb.py +0 -0
  58. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/ec2.py +0 -0
  59. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/iam.py +0 -0
  60. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/kms.py +0 -0
  61. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/lambda_svc.py +0 -0
  62. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/logs.py +0 -0
  63. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/monitoring.py +0 -0
  64. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/rds.py +0 -0
  65. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/resources.py +0 -0
  66. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/s3.py +0 -0
  67. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/secretsmanager.py +0 -0
  68. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/sns.py +0 -0
  69. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/sqs.py +0 -0
  70. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/stats.py +0 -0
  71. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/stepfunctions.py +0 -0
  72. {stackport-0.4.7 → stackport-0.4.8}/backend/routes/tags.py +0 -0
  73. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/__init__.py +0 -0
  74. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/dynamodb.py +0 -0
  75. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/endpoints.py +0 -0
  76. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/lambda_svc.py +0 -0
  77. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/learn.py +0 -0
  78. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/logs.py +0 -0
  79. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/monitoring.py +0 -0
  80. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/s3.py +0 -0
  81. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/secretsmanager.py +0 -0
  82. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/sns.py +0 -0
  83. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/sqs.py +0 -0
  84. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/stepfunctions.py +0 -0
  85. {stackport-0.4.7 → stackport-0.4.8}/backend/schemas/tags.py +0 -0
  86. {stackport-0.4.7 → stackport-0.4.8}/setup.cfg +0 -0
  87. {stackport-0.4.7 → stackport-0.4.8}/stackport.egg-info/dependency_links.txt +0 -0
  88. {stackport-0.4.7 → stackport-0.4.8}/stackport.egg-info/entry_points.txt +0 -0
  89. {stackport-0.4.7 → stackport-0.4.8}/stackport.egg-info/requires.txt +0 -0
  90. {stackport-0.4.7 → stackport-0.4.8}/stackport.egg-info/top_level.txt +0 -0
  91. {stackport-0.4.7 → stackport-0.4.8}/tests/test_aws_emulator.py +0 -0
  92. {stackport-0.4.7 → stackport-0.4.8}/tests/test_aws_errors.py +0 -0
  93. {stackport-0.4.7 → stackport-0.4.8}/tests/test_cache.py +0 -0
  94. {stackport-0.4.7 → stackport-0.4.8}/tests/test_client.py +0 -0
  95. {stackport-0.4.7 → stackport-0.4.8}/tests/test_config.py +0 -0
  96. {stackport-0.4.7 → stackport-0.4.8}/tests/test_create_resources.py +0 -0
  97. {stackport-0.4.7 → stackport-0.4.8}/tests/test_dynamodb_routes.py +0 -0
  98. {stackport-0.4.7 → stackport-0.4.8}/tests/test_ec2_routes.py +0 -0
  99. {stackport-0.4.7 → stackport-0.4.8}/tests/test_emulators.py +0 -0
  100. {stackport-0.4.7 → stackport-0.4.8}/tests/test_endpoint_store.py +0 -0
  101. {stackport-0.4.7 → stackport-0.4.8}/tests/test_endpoints.py +0 -0
  102. {stackport-0.4.7 → stackport-0.4.8}/tests/test_endpoints_tls.py +0 -0
  103. {stackport-0.4.7 → stackport-0.4.8}/tests/test_error_handlers.py +0 -0
  104. {stackport-0.4.7 → stackport-0.4.8}/tests/test_iam_routes.py +0 -0
  105. {stackport-0.4.7 → stackport-0.4.8}/tests/test_kms_routes.py +0 -0
  106. {stackport-0.4.7 → stackport-0.4.8}/tests/test_lambda_code_download.py +0 -0
  107. {stackport-0.4.7 → stackport-0.4.8}/tests/test_lambda_routes.py +0 -0
  108. {stackport-0.4.7 → stackport-0.4.8}/tests/test_learn_content.py +0 -0
  109. {stackport-0.4.7 → stackport-0.4.8}/tests/test_learn_routes.py +0 -0
  110. {stackport-0.4.7 → stackport-0.4.8}/tests/test_learn_store.py +0 -0
  111. {stackport-0.4.7 → stackport-0.4.8}/tests/test_logs_routes.py +0 -0
  112. {stackport-0.4.7 → stackport-0.4.8}/tests/test_logs_tail.py +0 -0
  113. {stackport-0.4.7 → stackport-0.4.8}/tests/test_monitoring_routes.py +0 -0
  114. {stackport-0.4.7 → stackport-0.4.8}/tests/test_rds_routes.py +0 -0
  115. {stackport-0.4.7 → stackport-0.4.8}/tests/test_readonly_middleware.py +0 -0
  116. {stackport-0.4.7 → stackport-0.4.8}/tests/test_registries.py +0 -0
  117. {stackport-0.4.7 → stackport-0.4.8}/tests/test_request_safety.py +0 -0
  118. {stackport-0.4.7 → stackport-0.4.8}/tests/test_routes.py +0 -0
  119. {stackport-0.4.7 → stackport-0.4.8}/tests/test_s3_routes.py +0 -0
  120. {stackport-0.4.7 → stackport-0.4.8}/tests/test_s3_upload_limit_env.py +0 -0
  121. {stackport-0.4.7 → stackport-0.4.8}/tests/test_secretsmanager_routes.py +0 -0
  122. {stackport-0.4.7 → stackport-0.4.8}/tests/test_sns_routes.py +0 -0
  123. {stackport-0.4.7 → stackport-0.4.8}/tests/test_sqs_routes.py +0 -0
  124. {stackport-0.4.7 → stackport-0.4.8}/tests/test_stats_probe.py +0 -0
  125. {stackport-0.4.7 → stackport-0.4.8}/tests/test_tags_routes.py +0 -0
  126. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/CloudscapeResourceBrowser-BR6m7Ajg.css +0 -0
  127. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/CloudscapeSettings-DI0pOcAA.css +0 -0
  128. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/CloudscapeShell-oIMuVycJ.css +0 -0
  129. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/ServiceStatus-DNBdeKId.css +0 -0
  130. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/index-DKIdXFX0.css +0 -0
  131. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/index-D__irddE.css +0 -0
  132. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/index-DoY0HscW.css +0 -0
  133. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/index-RwoKZF4y.css +0 -0
  134. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  135. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/assets/utils-Dm4tJbJR.js +0 -0
  136. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/acm.svg +0 -0
  137. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/apigateway.svg +0 -0
  138. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/appsync.svg +0 -0
  139. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/athena.svg +0 -0
  140. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/cloudformation.svg +0 -0
  141. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/cloudfront.svg +0 -0
  142. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  143. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/dynamodb.svg +0 -0
  144. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/ec2.svg +0 -0
  145. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/ecr.svg +0 -0
  146. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/ecs.svg +0 -0
  147. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/elasticache.svg +0 -0
  148. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  149. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  150. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  151. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/events.svg +0 -0
  152. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/firehose.svg +0 -0
  153. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/glue.svg +0 -0
  154. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/iam.svg +0 -0
  155. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/kinesis.svg +0 -0
  156. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/kms.svg +0 -0
  157. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/lambda.svg +0 -0
  158. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/logs.svg +0 -0
  159. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/monitoring.svg +0 -0
  160. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/rds.svg +0 -0
  161. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/route53.svg +0 -0
  162. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/s3.svg +0 -0
  163. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  164. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/ses.svg +0 -0
  165. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/sns.svg +0 -0
  166. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/sqs.svg +0 -0
  167. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/ssm.svg +0 -0
  168. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  169. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/aws-icons/wafv2.svg +0 -0
  170. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/favicon.png +0 -0
  171. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/favicon.svg +0 -0
  172. {stackport-0.4.7 → stackport-0.4.8}/ui/dist/stackport-logo.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.7
3
+ Version: 0.4.8
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -314,7 +314,7 @@ stackport export lambda --format json
314
314
  stackport export ec2 --format csv
315
315
  ```
316
316
 
317
- `status`, `list`, `describe`, and `export` accept `--endpoint URL` and `--region REGION`. Without them they use `AWS_ENDPOINT_URL` and `AWS_REGION`, or the default endpoint saved in Settings.
317
+ `status`, `list`, `describe`, and `export` accept `--endpoint` (a saved endpoint name or a URL) and `--region REGION`. Without `--endpoint` they use `AWS_ENDPOINT_URL` or the default endpoint saved in Settings. A saved endpoint brings its profile or keys, region and TLS settings (CA bundle, verification off) with it, and `--region` overrides its region. A URL passed directly uses no saved settings.
318
318
 
319
319
  ## Keyboard shortcuts
320
320
 
@@ -341,6 +341,7 @@ Press `?` anywhere to see all shortcuts.
341
341
  | `AWS_REGION` | `us-east-1` | AWS region |
342
342
  | `AWS_ACCESS_KEY_ID` | *(unset)* | AWS access key. Unset = use credential chain |
343
343
  | `AWS_SECRET_ACCESS_KEY` | *(unset)* | AWS secret key. Unset = use credential chain |
344
+ | `AWS_SESSION_TOKEN` | *(unset)* | Session token for temporary credentials (SSO exports, assumed roles). Sent only with the two keys above. Read at start-up, so a new token needs a restart |
344
345
  | `AWS_PROFILE` | *(unset)* | AWS named profile from `~/.aws/config` |
345
346
  | `STACKPORT_PORT` | `8080` | StackPort server port |
346
347
  | `STACKPORT_HOST` | `127.0.0.1` | Interface StackPort listens on (`0.0.0.0` in the Docker image). StackPort has no login, so open it to the network only where that's fine |
@@ -278,7 +278,7 @@ stackport export lambda --format json
278
278
  stackport export ec2 --format csv
279
279
  ```
280
280
 
281
- `status`, `list`, `describe`, and `export` accept `--endpoint URL` and `--region REGION`. Without them they use `AWS_ENDPOINT_URL` and `AWS_REGION`, or the default endpoint saved in Settings.
281
+ `status`, `list`, `describe`, and `export` accept `--endpoint` (a saved endpoint name or a URL) and `--region REGION`. Without `--endpoint` they use `AWS_ENDPOINT_URL` or the default endpoint saved in Settings. A saved endpoint brings its profile or keys, region and TLS settings (CA bundle, verification off) with it, and `--region` overrides its region. A URL passed directly uses no saved settings.
282
282
 
283
283
  ## Keyboard shortcuts
284
284
 
@@ -305,6 +305,7 @@ Press `?` anywhere to see all shortcuts.
305
305
  | `AWS_REGION` | `us-east-1` | AWS region |
306
306
  | `AWS_ACCESS_KEY_ID` | *(unset)* | AWS access key. Unset = use credential chain |
307
307
  | `AWS_SECRET_ACCESS_KEY` | *(unset)* | AWS secret key. Unset = use credential chain |
308
+ | `AWS_SESSION_TOKEN` | *(unset)* | Session token for temporary credentials (SSO exports, assumed roles). Sent only with the two keys above. Read at start-up, so a new token needs a restart |
308
309
  | `AWS_PROFILE` | *(unset)* | AWS named profile from `~/.aws/config` |
309
310
  | `STACKPORT_PORT` | `8080` | StackPort server port |
310
311
  | `STACKPORT_HOST` | `127.0.0.1` | Interface StackPort listens on (`0.0.0.0` in the Docker image). StackPort has no login, so open it to the network only where that's fine |
@@ -47,6 +47,8 @@ def _build_client(service_name: str, plan: ConnectionPlan):
47
47
  session_kwargs["aws_access_key_id"] = plan.access_key_id
48
48
  if plan.secret_access_key is not None:
49
49
  session_kwargs["aws_secret_access_key"] = plan.secret_access_key
50
+ if plan.session_token is not None:
51
+ session_kwargs["aws_session_token"] = plan.session_token
50
52
  session = boto3.Session(**session_kwargs)
51
53
 
52
54
  client_kwargs: dict = {"service_name": service_name}
@@ -12,13 +12,15 @@ import uvicorn
12
12
  from backend.aws_client import get_client
13
13
  from backend.config import (
14
14
  AWS_ENDPOINT_URL,
15
- AWS_REGION,
15
+ ENDPOINTS,
16
16
  LOG_LEVEL,
17
17
  STACKPORT_HOST,
18
18
  STACKPORT_PORT,
19
19
  STACKPORT_SERVICES,
20
20
  endpoint_store,
21
21
  )
22
+ from backend.connection import EndpointInfo, InvalidEndpointUrl
23
+ from backend.routes.common import UnknownEndpoint, endpoint_info_for
22
24
  from backend.routes.resources import (
23
25
  DESCRIBE_REGISTRY,
24
26
  _PREFERRED_ID_FIELD,
@@ -30,16 +32,43 @@ from backend.routes.stats import SERVICE_REGISTRY, _METHOD_KWARGS, _count_items,
30
32
  logger = logging.getLogger(__name__)
31
33
 
32
34
 
35
+ def _startup_endpoint_info() -> EndpointInfo:
36
+ """Connection info for AWS_ENDPOINT_URL, with the settings of the env endpoint it defines.
37
+
38
+ The store points that env endpoint (normally "default") at the environment's URL
39
+ for the run. If it was deleted, the URL is used on its own.
40
+ """
41
+ name = next((n for n, url in ENDPOINTS.items() if url == AWS_ENDPOINT_URL), None)
42
+ entry = endpoint_store.get(name) if name is not None else None
43
+ if entry is not None and entry["source"] == "env" and entry["url"] == AWS_ENDPOINT_URL:
44
+ return EndpointInfo.from_entry(entry)
45
+ return endpoint_info_for(AWS_ENDPOINT_URL)
46
+
47
+
33
48
  def _client_kwargs(endpoint: str | None, region: str | None) -> dict:
34
- """Resolve where a CLI command talks to.
49
+ """Resolve where a CLI command talks to, the way the web app does.
35
50
 
36
- An explicit --endpoint (or AWS_ENDPOINT_URL at startup) wins. Without one,
37
- fall back to the saved default endpoint, the same one the web UI uses.
38
- Passing the result to get_client explicitly is what makes the flag work
39
- once ~/.stackport/endpoints.json exists.
51
+ ``endpoint`` is a saved endpoint's name or a URL. Without it, AWS_ENDPOINT_URL
52
+ at startup wins (with the settings of the env endpoint that defines it), then
53
+ the saved default endpoint. A saved endpoint brings its auth and TLS settings
54
+ along; an explicit ``region`` overrides its region. Passing the result to
55
+ get_client explicitly is what makes the flag work once
56
+ ~/.stackport/endpoints.json exists.
57
+
58
+ Raises:
59
+ click.BadParameter: the name matches no saved endpoint, or the URL is refused.
40
60
  """
41
- url = endpoint if endpoint is not None else endpoint_store.get_default_url()
42
- return {"endpoint_url": url, "region": region}
61
+ try:
62
+ if endpoint is None and AWS_ENDPOINT_URL is not None:
63
+ info = _startup_endpoint_info()
64
+ else:
65
+ info = endpoint_info_for(endpoint, strict=True)
66
+ except (InvalidEndpointUrl, UnknownEndpoint) as exc:
67
+ raise click.BadParameter(str(exc), param_hint="--endpoint") from exc
68
+ kwargs = info.client_kwargs()
69
+ if region is not None:
70
+ kwargs["region"] = region
71
+ return kwargs
43
72
 
44
73
 
45
74
  @click.group(invoke_without_command=True)
@@ -60,8 +89,8 @@ def serve(port, host):
60
89
 
61
90
 
62
91
  @cli.command()
63
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
64
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
92
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
93
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
65
94
  @click.option("--output", type=click.Choice(["json", "table"]), default="table", help="Output format")
66
95
  def status(endpoint, region, output):
67
96
  """Show all services with availability and resource counts."""
@@ -102,8 +131,8 @@ def status(endpoint, region, output):
102
131
 
103
132
  @cli.command()
104
133
  @click.argument("service")
105
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
106
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
134
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
135
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
107
136
  @click.option("--output", type=click.Choice(["json", "table", "csv"]), default="table", help="Output format")
108
137
  def list(service, endpoint, region, output):
109
138
  """List resources for a service."""
@@ -171,8 +200,8 @@ def list(service, endpoint, region, output):
171
200
  @click.argument("service")
172
201
  @click.argument("resource_type")
173
202
  @click.argument("resource_id")
174
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
175
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
203
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
204
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
176
205
  @click.option("--output", type=click.Choice(["json", "table"]), default="json", help="Output format")
177
206
  def describe(service, resource_type, resource_id, endpoint, region, output):
178
207
  """Describe a specific resource."""
@@ -253,8 +282,8 @@ def describe(service, resource_type, resource_id, endpoint, region, output):
253
282
 
254
283
  @cli.command()
255
284
  @click.argument("service")
256
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
257
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
285
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
286
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
258
287
  @click.option("--format", "format_", type=click.Choice(["json", "csv"]), default="json", help="Output format")
259
288
  @click.pass_context
260
289
  def export(ctx, service, endpoint, region, format_):
@@ -10,6 +10,11 @@ def _parse_aws_endpoint_url(environ: Mapping[str, str]) -> str | None:
10
10
  return environ.get("AWS_ENDPOINT_URL") or None
11
11
 
12
12
 
13
+ def _parse_aws_credential(environ: Mapping[str, str], name: str) -> str | None:
14
+ """An access key, secret key or session token. Empty counts as unset (``AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}`` in Compose)."""
15
+ return environ.get(name, "").strip() or None
16
+
17
+
13
18
  AWS_ENDPOINT_URL: str | None = _parse_aws_endpoint_url(os.environ)
14
19
 
15
20
  # Remove AWS_ENDPOINT_URL from the process environment so boto3's internal
@@ -17,8 +22,10 @@ AWS_ENDPOINT_URL: str | None = _parse_aws_endpoint_url(os.environ)
17
22
  if "AWS_ENDPOINT_URL" in os.environ:
18
23
  del os.environ["AWS_ENDPOINT_URL"]
19
24
  AWS_REGION: str = os.environ.get("AWS_REGION", "us-east-1")
20
- AWS_ACCESS_KEY_ID: str | None = os.environ.get("AWS_ACCESS_KEY_ID")
21
- AWS_SECRET_ACCESS_KEY: str | None = os.environ.get("AWS_SECRET_ACCESS_KEY")
25
+ AWS_ACCESS_KEY_ID: str | None = _parse_aws_credential(os.environ, "AWS_ACCESS_KEY_ID")
26
+ AWS_SECRET_ACCESS_KEY: str | None = _parse_aws_credential(os.environ, "AWS_SECRET_ACCESS_KEY")
27
+ # Belongs to the two keys above (SSO exports, assumed roles). Read once at start-up.
28
+ AWS_SESSION_TOKEN: str | None = _parse_aws_credential(os.environ, "AWS_SESSION_TOKEN")
22
29
  STACKPORT_ALLOW_WRITES: bool = os.environ.get("STACKPORT_ALLOW_WRITES", "true").lower() in ("1", "true", "yes")
23
30
  STACKPORT_PORT: int = int(os.environ.get("STACKPORT_PORT", "8080"))
24
31
  # Interface the server listens on. Loopback by default: StackPort has no login
@@ -8,7 +8,9 @@ Credentials, most specific first:
8
8
 
9
9
  1. the endpoint's own auth (a profile, or an access key pair);
10
10
  2. AWS Emulator's ``emulator_credentials`` for this endpoint, when present;
11
- 3. ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` from StackPort's environment;
11
+ 3. ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` from StackPort's environment,
12
+ with ``AWS_SESSION_TOKEN`` when it is set (temporary credentials). The token
13
+ belongs to that pair only: no other source below or above ever gets it;
12
14
  4. placeholder keys when AWS Emulator's folder exists without keys (it runs
13
15
  with ``--auth off``, or it is stopped), so the default chain never sends the
14
16
  access key ID of a real account to it, and a stopped emulator shows up as
@@ -32,6 +34,7 @@ A plan is hashable and carries the emulator files' stat stamp, so
32
34
 
33
35
  from __future__ import annotations
34
36
 
37
+ import hashlib
35
38
  import os
36
39
  import ssl
37
40
  from dataclasses import dataclass, field
@@ -43,7 +46,7 @@ from urllib3.exceptions import LocationParseError
43
46
  from urllib3.util import parse_url
44
47
 
45
48
  from backend import aws_emulator
46
- from backend.config import AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, is_local_endpoint
49
+ from backend.config import AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, is_local_endpoint
47
50
 
48
51
  CredentialSource = Literal[
49
52
  "endpoint-profile",
@@ -95,14 +98,21 @@ class EndpointInfo:
95
98
  """Prefix for cache keys holding this endpoint's results.
96
99
 
97
100
  Two saved endpoints can share a URL with different auth or TLS settings,
98
- so all of them are in it (never the secret key). It starts with the URL,
99
- so editing an endpoint, which drops ``"<url>:"`` keys, drops these too.
101
+ so all of them are in it. The secret key is there only as a short
102
+ digest, enough to tell two keys apart and useless for recovering one.
103
+ It starts with the URL, so editing an endpoint, which drops
104
+ ``"<url>:"`` keys, drops these too.
100
105
  """
106
+ secret_digest = (
107
+ hashlib.sha256(self.auth_secret_access_key.encode()).hexdigest()[:12]
108
+ if self.auth_secret_access_key
109
+ else None
110
+ )
101
111
  return ":".join(
102
112
  str(part)
103
113
  for part in (
104
114
  self.url, self.region, self.auth_type, self.auth_profile,
105
- self.auth_access_key_id, self.tls_verify, self.ca_bundle,
115
+ self.auth_access_key_id, secret_digest, self.tls_verify, self.ca_bundle,
106
116
  )
107
117
  )
108
118
 
@@ -131,6 +141,7 @@ class ConnectionPlan:
131
141
  profile: str | None = None
132
142
  access_key_id: str | None = field(default=None, repr=False)
133
143
  secret_access_key: str | None = field(default=None, repr=False)
144
+ session_token: str | None = field(default=None, repr=False)
134
145
  # None leaves verification to botocore (system trust, or AWS_CA_BUNDLE).
135
146
  verify: bool | str | None = None
136
147
  # A local emulator rather than AWS: see aws_client._build_client for what changes.
@@ -142,6 +153,15 @@ class InvalidEndpointUrl(ValueError):
142
153
  """An endpoint URL StackPort refuses to use."""
143
154
 
144
155
 
156
+ def is_direct_endpoint_url(value: str | None) -> bool:
157
+ """True when an endpoint reference is a URL rather than a saved endpoint's name.
158
+
159
+ The scheme is case-insensitive (``HTTP://host`` is a URL); whether the URL
160
+ is acceptable is ``check_endpoint_url``'s call.
161
+ """
162
+ return value is not None and value.lower().startswith(("http://", "https://"))
163
+
164
+
145
165
  def check_endpoint_url(url: str) -> str:
146
166
  """Return ``url`` if it is a plain http(s) URL every parser reads the same way.
147
167
 
@@ -202,7 +222,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
202
222
  files = aws_emulator.discover(ep.url) if ep.url and not (has_own_credentials and has_own_trust) else None
203
223
 
204
224
  # Credentials
205
- profile = key = secret = None
225
+ profile = key = secret = token = None
206
226
  source: CredentialSource
207
227
  if ep.auth_type == "profile" and ep.auth_profile:
208
228
  source, profile = "endpoint-profile", ep.auth_profile
@@ -212,7 +232,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
212
232
  key, secret = aws_emulator.read_credentials(files.credentials, files.stamp[1])
213
233
  source = "aws-emulator"
214
234
  elif AWS_ACCESS_KEY_ID is not None or AWS_SECRET_ACCESS_KEY is not None:
215
- source, key, secret = "environment", AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
235
+ source, key, secret, token = "environment", AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
216
236
  elif files is not None:
217
237
  source, key, secret = "placeholder", PLACEHOLDER_ACCESS_KEY_ID, PLACEHOLDER_SECRET_ACCESS_KEY
218
238
  else:
@@ -240,6 +260,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
240
260
  profile=profile,
241
261
  access_key_id=key,
242
262
  secret_access_key=secret,
263
+ session_token=token,
243
264
  verify=verify,
244
265
  local_endpoint=is_local_endpoint(ep.url),
245
266
  emulator_files=files,
@@ -0,0 +1,114 @@
1
+ """Extra shell setup the AWS CLI needs for the endpoint a learner is using.
2
+
3
+ Lessons show commands like ``aws --endpoint-url={{endpointUrl}} s3 ls``. That
4
+ is all MiniStack, Floci, LocalStack and Moto need. Some emulators also want
5
+ environment variables, for example AWS Emulator, which serves HTTPS with a
6
+ certificate and keys it creates at startup. The console steps work without
7
+ any of that because StackPort reads the same files itself, so only the CLI
8
+ path needs the learner to be told.
9
+
10
+ ``CLI_SETUP`` maps an emulator id (as reported by ``backend.emulators``) to a
11
+ function returning the setup for it. An emulator that needs nothing has no
12
+ entry, and the lesson shows nothing extra.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shlex
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from backend import aws_emulator
23
+ from backend.aws_emulator import AwsEmulatorFilesError
24
+ from backend.connection import ConnectionPlan, plan_connection
25
+ from backend.emulators import detect_emulator_quickly
26
+ from backend.routes.common import EndpointInfo
27
+
28
+ # Shell credentials outrank AWS_SHARED_CREDENTIALS_FILE, so leftovers would
29
+ # sign requests with keys the emulator does not know.
30
+ _CLEAR_CREDENTIALS = "unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CliSetup:
35
+ """Shell lines to run once, and an optional sentence about where they come from."""
36
+
37
+ lines: list[str]
38
+ note: str | None = None
39
+
40
+
41
+ def running_in_container() -> bool:
42
+ """True inside Docker or Podman, where StackPort's filesystem is not the learner's."""
43
+ return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
44
+
45
+
46
+ def _shell_path(path: Path) -> str:
47
+ """Render a path for pasting into a shell, keeping ``~`` for the home folder."""
48
+ shown = aws_emulator.display_path(path)
49
+ if not shown.startswith("~/"):
50
+ return shlex.quote(shown)
51
+ rest = shown[2:]
52
+ quoted = shlex.quote(rest)
53
+ return f"~/{rest}" if quoted == rest else f'"$HOME"/{quoted}'
54
+
55
+
56
+ def _aws_emulator_setup(ep: EndpointInfo, plan: ConnectionPlan) -> CliSetup | None:
57
+ # Only when StackPort itself uses the emulator's files: an endpoint with its
58
+ # own keys or CA bundle is not one the CLI needs them for.
59
+ if "aws-emulator" not in (plan.credential_source, plan.trust_source):
60
+ return None
61
+ directory = aws_emulator.files_dir_for(ep.url)
62
+ if directory is None:
63
+ return None
64
+
65
+ note = None
66
+ if running_in_container():
67
+ # The folder StackPort sees is inside its container. AWS Emulator's own
68
+ # guide puts the files in the home folder of the machine running it.
69
+ port = directory.name
70
+ directory = Path.home() / ".aws" / "emulator" / port
71
+ note = (
72
+ "StackPort runs in a container, so these are the paths on your machine, where AWS Emulator "
73
+ "keeps its files when it runs there. With the Compose example the files live in the emulator's "
74
+ "volume: copy them out with `mkdir -p ~/.aws/emulator && docker compose -f "
75
+ f"docker-compose.aws-emulator.yml cp aws-emulator:/home/emulator/.aws/emulator/{port} "
76
+ "~/.aws/emulator/`, again after each restart."
77
+ )
78
+ return CliSetup(
79
+ lines=[
80
+ _CLEAR_CREDENTIALS,
81
+ f"export AWS_SHARED_CREDENTIALS_FILE={_shell_path(directory / aws_emulator.CREDENTIALS_FILE)}",
82
+ f"export AWS_CA_BUNDLE={_shell_path(directory / aws_emulator.CA_FILE)}",
83
+ # The emulator serves every service on one host, so the CLI must not
84
+ # prepend a service name to it (for example "s3-control.")
85
+ "export AWS_DISABLE_HOST_PREFIX_INJECTION=true",
86
+ ],
87
+ note=note,
88
+ )
89
+
90
+
91
+ CLI_SETUP: dict[str, Callable[[EndpointInfo, ConnectionPlan], CliSetup | None]] = {
92
+ "aws-emulator": _aws_emulator_setup,
93
+ }
94
+
95
+
96
+ def cli_setup_for(ep: EndpointInfo) -> dict | None:
97
+ """The setup block for this endpoint, or None when the plain command works.
98
+
99
+ ``{"emulator": <display name>, "script": <shell lines>, "note": <sentence or None>}``.
100
+ Never probes an endpoint that is not saved, and waits only briefly for an
101
+ emulator that has not been identified yet, so the UI asks again later.
102
+ """
103
+ emulator = detect_emulator_quickly(ep)
104
+ builder = CLI_SETUP.get(emulator["id"]) if emulator else None
105
+ if builder is None:
106
+ return None
107
+ try:
108
+ plan = plan_connection(ep)
109
+ except AwsEmulatorFilesError:
110
+ return None
111
+ setup = builder(ep, plan)
112
+ if setup is None:
113
+ return None
114
+ return {"emulator": emulator["name"], "script": "\n".join(setup.lines), "note": setup.note}
@@ -3,13 +3,19 @@
3
3
  from fastapi import HTTPException, Query
4
4
 
5
5
  from backend.config import endpoint_store
6
- from backend.connection import EndpointInfo, InvalidEndpointUrl, check_endpoint_url
6
+ from backend.connection import EndpointInfo, InvalidEndpointUrl, check_endpoint_url, is_direct_endpoint_url
7
7
 
8
- __all__ = ["EndpointInfo", "endpoint_info_for", "endpoint_info_for_url", "get_endpoint_info", "get_endpoint_url"]
8
+ __all__ = [
9
+ "EndpointInfo",
10
+ "UnknownEndpoint",
11
+ "endpoint_info_for",
12
+ "get_endpoint_info",
13
+ "get_endpoint_url",
14
+ ]
9
15
 
10
16
 
11
- def _is_direct_url(endpoint: str | None) -> bool:
12
- return endpoint is not None and endpoint.startswith(("http://", "https://"))
17
+ class UnknownEndpoint(LookupError):
18
+ """A name that matches no saved endpoint."""
13
19
 
14
20
 
15
21
  def get_endpoint_url(endpoint: str | None = Query(None, description="Endpoint name or URL")) -> str | None:
@@ -21,22 +27,29 @@ def get_endpoint_url(endpoint: str | None = Query(None, description="Endpoint na
21
27
  Returns:
22
28
  Endpoint URL to use for AWS API calls, or None for real AWS.
23
29
  """
24
- if _is_direct_url(endpoint):
30
+ if is_direct_endpoint_url(endpoint):
25
31
  try:
26
- check_endpoint_url(endpoint)
32
+ return check_endpoint_url(endpoint)
27
33
  except InvalidEndpointUrl as exc:
28
34
  raise HTTPException(status_code=422, detail=str(exc)) from exc
29
35
  return endpoint_store.resolve(endpoint)
30
36
 
31
37
 
32
- def endpoint_info_for(endpoint: str | None) -> EndpointInfo:
38
+ def endpoint_info_for(endpoint: str | None, strict: bool = False) -> EndpointInfo:
33
39
  """Resolve an endpoint name, a direct URL, or None (the default) to connection info.
34
40
 
41
+ An unknown name falls back to the default endpoint, which suits HTTP routes;
42
+ ``strict`` raises instead, for callers where a typo shouldn't pick another endpoint.
43
+
35
44
  Raises:
36
45
  InvalidEndpointUrl: a direct URL StackPort refuses to use.
46
+ UnknownEndpoint: ``strict`` and the name matches no saved endpoint.
37
47
  """
38
- if _is_direct_url(endpoint):
39
- check_endpoint_url(endpoint)
48
+ if is_direct_endpoint_url(endpoint):
49
+ return EndpointInfo(url=check_endpoint_url(endpoint), region=None)
50
+ if strict and endpoint is not None and endpoint_store.get(endpoint) is None:
51
+ known = ", ".join(sorted(endpoint_store.list_all())) or "none"
52
+ raise UnknownEndpoint(f"'{endpoint}' is not a saved endpoint name or an http(s) URL. Saved endpoints: {known}.")
40
53
  entry = endpoint_store.resolve_full(endpoint)
41
54
  if entry:
42
55
  return EndpointInfo.from_entry(entry)
@@ -45,17 +58,6 @@ def endpoint_info_for(endpoint: str | None) -> EndpointInfo:
45
58
  return EndpointInfo(url=url, region=region)
46
59
 
47
60
 
48
- def endpoint_info_for_url(url: str | None) -> EndpointInfo:
49
- """Connection info for a URL, using the first saved endpoint with that URL, if any.
50
-
51
- For callers that only kept the URL, such as the WebSocket probe loop.
52
- """
53
- for entry in endpoint_store.list_all().values():
54
- if entry["url"] == url:
55
- return EndpointInfo.from_entry(entry)
56
- return EndpointInfo(url=url, region=None)
57
-
58
-
59
61
  def get_endpoint_info(endpoint: str | None = Query(None, description="Endpoint name or URL")) -> EndpointInfo:
60
62
  """Resolve endpoint query param to URL, region, auth, and TLS config.
61
63
 
@@ -190,8 +190,9 @@ async def update_endpoint(name: str, body: UpdateEndpointBody):
190
190
  if old_url:
191
191
  cache.delete_by_prefix(f"{old_url}:")
192
192
 
193
- # Broadcast change to connected clients
194
- from backend.websocket import broadcast_endpoints_changed
193
+ # Broadcast change to connected clients; live stats kept under the old settings go too
194
+ from backend.websocket import broadcast_endpoints_changed, remove_endpoint_from_stats
195
+ remove_endpoint_from_stats(old_url)
195
196
  asyncio.create_task(broadcast_endpoints_changed())
196
197
 
197
198
  return {"name": name, **_public_entry(entry)}
@@ -19,6 +19,7 @@ from typing import Any
19
19
  from fastapi import APIRouter, Depends, HTTPException
20
20
 
21
21
  from backend.config import STACKPORT_DATA_DIR
22
+ from backend.learn.cli_setup import cli_setup_for
22
23
  from backend.learn.verify import run_check
23
24
  from backend.learn_store import LearnProgressStore
24
25
  from backend.routes.common import EndpointInfo, get_endpoint_info
@@ -155,6 +156,17 @@ def get_trail(trail_id: str, ep: EndpointInfo = Depends(get_endpoint_info)) -> d
155
156
  }
156
157
 
157
158
 
159
+ @router.get("/cli-setup")
160
+ def get_cli_setup(ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
161
+ """What the CLI needs for this endpoint beyond ``--endpoint-url``, or null.
162
+
163
+ Separate from the trail because it can change without the trail changing:
164
+ an emulator is identified in the background, so the first answer may be
165
+ empty and the UI asks again.
166
+ """
167
+ return {"cliSetup": cli_setup_for(ep)}
168
+
169
+
158
170
  def _progress_payload(trail_id: str, progress: dict) -> dict[str, Any]:
159
171
  return {
160
172
  "completed": progress["completed"].get(trail_id, {}),