morphcloud 0.1.112__tar.gz → 0.1.113__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.
- {morphcloud-0.1.112 → morphcloud-0.1.113}/PKG-INFO +37 -4
- {morphcloud-0.1.112 → morphcloud-0.1.113}/README.md +36 -3
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/api.py +214 -2
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/cli.py +256 -4
- {morphcloud-0.1.112 → morphcloud-0.1.113}/pyproject.toml +1 -1
- morphcloud-0.1.113/tests/integration/test_managed_ssh_keys.py +146 -0
- morphcloud-0.1.113/tests/unit/test_cli_managed_ssh_keys.py +249 -0
- morphcloud-0.1.113/tests/unit/test_managed_ssh_keys_api.py +233 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/.github/workflows/publish.yaml +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/.gitignore +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/LICENSE +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/Makefile +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/conf.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/examples/browser_example.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/examples/devbox_example.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/index.rst +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/make.bat +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_asyncify.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_bash_interpreter.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_http_transport.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_llm.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_scramble.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_ssh.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_utils.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/cli_helpers.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/config.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/cli.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/config.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/api_error.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/client_wrapper.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/datetime_utils.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/file.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/force_multipart.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/jsonable_encoder.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/pydantic_utilities.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/query_encoder.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/remove_none_from_dict.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/request_options.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/serialization.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/unprocessable_entity_error.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/alias_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/branch_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/delete_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_detail_list_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_list_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_refs.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_spec.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_ssh_credentials_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_summary_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service_auth_mode.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_validation_error.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/networking.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/secret_value_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/snapshot_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/success_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_request.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_start_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_detail.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_list_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_share_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_status.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_model.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_status.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_summary.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/timeline_event.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_debug_info_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_install_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_new_session_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_send_keys_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_session.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_sessions_list.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_snapshot_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_target.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/ttl.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/upload_response.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error_loc_item.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/wake_on.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/raw_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/openapi.json +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/template_local_runner.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/template_runner.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/terminals.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/experimental/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/experimental/browser.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/cli.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/installer.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/py.typed +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/simple_index/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/simple_index/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/cli.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/requirements.txt +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/scripts/abuild_smoke.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/scripts/increment_version.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/__init__.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/conftest.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/pytest.ini +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_as_container.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_branching.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_command_execution.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_file_operations.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_from_tag_multiple.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_http_service.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_metadata.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_simple.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_snapshot_operations.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_ssh_key_rotation.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_ttl.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_user.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_devbox.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_devbox_terminal.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_plugins.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_secrets.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_snapshots.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_volumes.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_devbox_terminals.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_http_transport_sharding.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_plugin_installer.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_profiles.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_simple_index_client.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_snapshot_ttl.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_ssh_connect_retry.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_template_local_runner.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_template_runner.py +0 -0
- {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_volumes_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: morphcloud
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.113
|
|
4
4
|
Summary: A Python SDK and CLI tool for creating, managing, and interacting with Morph Cloud VMs.
|
|
5
5
|
Author-email: Morph Labs <jobs@morph.so>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -720,16 +720,49 @@ morphcloud user api-key create
|
|
|
720
720
|
# Delete an API key
|
|
721
721
|
morphcloud user api-key delete <api_key_id>
|
|
722
722
|
|
|
723
|
-
#
|
|
724
|
-
morphcloud user ssh-key
|
|
723
|
+
# List named SSH public keys (fingerprints, expiry, and status)
|
|
724
|
+
morphcloud user ssh-key list
|
|
725
|
+
|
|
726
|
+
# Add a user-owned public key; never provide the private key
|
|
727
|
+
morphcloud user ssh-key add --name "Work laptop" --public-key-file ~/.ssh/id_ed25519.pub
|
|
728
|
+
|
|
729
|
+
# Inspect and edit one managed key by its stable ID
|
|
730
|
+
morphcloud user ssh-key get <ssh_key_id>
|
|
731
|
+
morphcloud user ssh-key edit <ssh_key_id> --expires 2030-01-01T00:00:00Z
|
|
732
|
+
|
|
733
|
+
# Revoke only that key across your current organization memberships
|
|
734
|
+
morphcloud user ssh-key revoke <ssh_key_id>
|
|
725
735
|
|
|
726
|
-
#
|
|
736
|
+
# Legacy singular compatibility commands remain available
|
|
737
|
+
morphcloud user ssh-key get
|
|
727
738
|
morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
|
|
728
739
|
|
|
729
740
|
# View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
|
|
730
741
|
morphcloud user usage --interval 3h
|
|
731
742
|
```
|
|
732
743
|
|
|
744
|
+
The same managed-key lifecycle is typed in the SDK, with async methods using the
|
|
745
|
+
usual `a` prefix (`alist_ssh_keys`, `aadd_ssh_key`, and so on):
|
|
746
|
+
|
|
747
|
+
```python
|
|
748
|
+
from pathlib import Path
|
|
749
|
+
|
|
750
|
+
from morphcloud.api import MorphCloudClient
|
|
751
|
+
|
|
752
|
+
client = MorphCloudClient()
|
|
753
|
+
key = client.user.add_ssh_key(
|
|
754
|
+
name="Work laptop",
|
|
755
|
+
public_key=Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
|
|
756
|
+
)
|
|
757
|
+
key = client.user.get_managed_ssh_key(key.id)
|
|
758
|
+
key = client.user.edit_ssh_key(key.id, name="Work laptop (2026)")
|
|
759
|
+
client.user.revoke_ssh_key(key.id)
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
Managed account public keys are separate from Morph-generated instance
|
|
763
|
+
credentials and browser terminal access. A managed key is owned by one user and
|
|
764
|
+
authorizes direct SSH through that user's current organization memberships.
|
|
765
|
+
|
|
733
766
|
## Advanced Features
|
|
734
767
|
|
|
735
768
|
### Profiles
|
|
@@ -689,16 +689,49 @@ morphcloud user api-key create
|
|
|
689
689
|
# Delete an API key
|
|
690
690
|
morphcloud user api-key delete <api_key_id>
|
|
691
691
|
|
|
692
|
-
#
|
|
693
|
-
morphcloud user ssh-key
|
|
692
|
+
# List named SSH public keys (fingerprints, expiry, and status)
|
|
693
|
+
morphcloud user ssh-key list
|
|
694
|
+
|
|
695
|
+
# Add a user-owned public key; never provide the private key
|
|
696
|
+
morphcloud user ssh-key add --name "Work laptop" --public-key-file ~/.ssh/id_ed25519.pub
|
|
697
|
+
|
|
698
|
+
# Inspect and edit one managed key by its stable ID
|
|
699
|
+
morphcloud user ssh-key get <ssh_key_id>
|
|
700
|
+
morphcloud user ssh-key edit <ssh_key_id> --expires 2030-01-01T00:00:00Z
|
|
701
|
+
|
|
702
|
+
# Revoke only that key across your current organization memberships
|
|
703
|
+
morphcloud user ssh-key revoke <ssh_key_id>
|
|
694
704
|
|
|
695
|
-
#
|
|
705
|
+
# Legacy singular compatibility commands remain available
|
|
706
|
+
morphcloud user ssh-key get
|
|
696
707
|
morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
|
|
697
708
|
|
|
698
709
|
# View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
|
|
699
710
|
morphcloud user usage --interval 3h
|
|
700
711
|
```
|
|
701
712
|
|
|
713
|
+
The same managed-key lifecycle is typed in the SDK, with async methods using the
|
|
714
|
+
usual `a` prefix (`alist_ssh_keys`, `aadd_ssh_key`, and so on):
|
|
715
|
+
|
|
716
|
+
```python
|
|
717
|
+
from pathlib import Path
|
|
718
|
+
|
|
719
|
+
from morphcloud.api import MorphCloudClient
|
|
720
|
+
|
|
721
|
+
client = MorphCloudClient()
|
|
722
|
+
key = client.user.add_ssh_key(
|
|
723
|
+
name="Work laptop",
|
|
724
|
+
public_key=Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
|
|
725
|
+
)
|
|
726
|
+
key = client.user.get_managed_ssh_key(key.id)
|
|
727
|
+
key = client.user.edit_ssh_key(key.id, name="Work laptop (2026)")
|
|
728
|
+
client.user.revoke_ssh_key(key.id)
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
Managed account public keys are separate from Morph-generated instance
|
|
732
|
+
credentials and browser terminal access. A managed key is owned by one user and
|
|
733
|
+
authorizes direct SSH through that user's current organization memberships.
|
|
734
|
+
|
|
702
735
|
## Advanced Features
|
|
703
736
|
|
|
704
737
|
### Profiles
|
|
@@ -13,7 +13,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
13
13
|
from functools import lru_cache
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
|
-
from pydantic import BaseModel, Field, PrivateAttr
|
|
16
|
+
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
|
17
17
|
|
|
18
18
|
# Import Rich for fancy printing
|
|
19
19
|
from rich.console import Console
|
|
@@ -52,10 +52,29 @@ class ApiError(Exception):
|
|
|
52
52
|
def __init__(self, message: str, status_code: int, response_body: str):
|
|
53
53
|
self.status_code = status_code
|
|
54
54
|
self.response_body = response_body
|
|
55
|
+
self.error_code: typing.Optional[str] = None
|
|
56
|
+
self.detail: typing.Optional[str] = None
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(response_body)
|
|
59
|
+
if isinstance(payload, dict):
|
|
60
|
+
error_code = payload.get("error_code")
|
|
61
|
+
detail = payload.get("detail")
|
|
62
|
+
if isinstance(error_code, str):
|
|
63
|
+
self.error_code = error_code
|
|
64
|
+
if isinstance(detail, str):
|
|
65
|
+
self.detail = detail
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
pass
|
|
55
68
|
super().__init__(
|
|
56
69
|
f"{message}\nStatus Code: {status_code}\nResponse Body: {response_body}"
|
|
57
70
|
)
|
|
58
71
|
|
|
72
|
+
def as_managed_ssh_key_error(self) -> typing.Optional["ManagedSSHKeyError"]:
|
|
73
|
+
"""Return a typed managed-key error when the response uses that contract."""
|
|
74
|
+
if self.error_code is None or self.detail is None:
|
|
75
|
+
return None
|
|
76
|
+
return ManagedSSHKeyError(error_code=self.error_code, detail=self.detail)
|
|
77
|
+
|
|
59
78
|
|
|
60
79
|
class ApiClient(httpx.Client):
|
|
61
80
|
def raise_for_status(self, response: httpx.Response) -> None:
|
|
@@ -441,28 +460,146 @@ class UserAPI(BaseAPI):
|
|
|
441
460
|
await self._client._async_http_client.delete(f"/user/secret/{secret_name}")
|
|
442
461
|
|
|
443
462
|
# ────────────────────────────────
|
|
444
|
-
# SSH
|
|
463
|
+
# Managed SSH public keys
|
|
464
|
+
# ────────────────────────────────
|
|
465
|
+
def list_ssh_keys(self) -> typing.List["ManagedSSHKey"]:
|
|
466
|
+
"""List all managed SSH public keys owned by the authenticated user."""
|
|
467
|
+
response = self._client._http_client.get("/user/ssh-keys")
|
|
468
|
+
return ManagedSSHKeyList.model_validate(response.json()).data
|
|
469
|
+
|
|
470
|
+
async def alist_ssh_keys(self) -> typing.List["ManagedSSHKey"]:
|
|
471
|
+
"""Asynchronously list all managed SSH public keys owned by the user."""
|
|
472
|
+
response = await self._client._async_http_client.get("/user/ssh-keys")
|
|
473
|
+
return ManagedSSHKeyList.model_validate(response.json()).data
|
|
474
|
+
|
|
475
|
+
def get_managed_ssh_key(self, ssh_key_id: str) -> "ManagedSSHKey":
|
|
476
|
+
"""Get one managed SSH public key by its stable ID."""
|
|
477
|
+
response = self._client._http_client.get(f"/user/ssh-keys/{ssh_key_id}")
|
|
478
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
479
|
+
|
|
480
|
+
async def aget_managed_ssh_key(self, ssh_key_id: str) -> "ManagedSSHKey":
|
|
481
|
+
"""Asynchronously get one managed SSH public key by its stable ID."""
|
|
482
|
+
response = await self._client._async_http_client.get(
|
|
483
|
+
f"/user/ssh-keys/{ssh_key_id}"
|
|
484
|
+
)
|
|
485
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
486
|
+
|
|
487
|
+
def add_ssh_key(
|
|
488
|
+
self,
|
|
489
|
+
name: str,
|
|
490
|
+
public_key: str,
|
|
491
|
+
expires: typing.Optional[int] = None,
|
|
492
|
+
) -> "ManagedSSHKey":
|
|
493
|
+
"""Add a named SSH public key, optionally expiring at Unix seconds."""
|
|
494
|
+
request_body = ManagedSSHKeyCreateRequest(
|
|
495
|
+
name=name, public_key=public_key, expires=expires
|
|
496
|
+
)
|
|
497
|
+
response = self._client._http_client.post(
|
|
498
|
+
"/user/ssh-keys", json=request_body.model_dump(exclude_none=True)
|
|
499
|
+
)
|
|
500
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
501
|
+
|
|
502
|
+
async def aadd_ssh_key(
|
|
503
|
+
self,
|
|
504
|
+
name: str,
|
|
505
|
+
public_key: str,
|
|
506
|
+
expires: typing.Optional[int] = None,
|
|
507
|
+
) -> "ManagedSSHKey":
|
|
508
|
+
"""Asynchronously add a named SSH public key."""
|
|
509
|
+
request_body = ManagedSSHKeyCreateRequest(
|
|
510
|
+
name=name, public_key=public_key, expires=expires
|
|
511
|
+
)
|
|
512
|
+
response = await self._client._async_http_client.post(
|
|
513
|
+
"/user/ssh-keys", json=request_body.model_dump(exclude_none=True)
|
|
514
|
+
)
|
|
515
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
516
|
+
|
|
517
|
+
def edit_ssh_key(
|
|
518
|
+
self,
|
|
519
|
+
ssh_key_id: str,
|
|
520
|
+
*,
|
|
521
|
+
name: typing.Optional[str] = None,
|
|
522
|
+
expires: typing.Optional[int] = None,
|
|
523
|
+
clear_expiry: bool = False,
|
|
524
|
+
) -> "ManagedSSHKey":
|
|
525
|
+
"""Edit a key's name or expiry without changing its public material.
|
|
526
|
+
|
|
527
|
+
Pass ``clear_expiry=True`` to send an explicit JSON null for ``expires``.
|
|
528
|
+
"""
|
|
529
|
+
request_body = _managed_ssh_key_patch_request(
|
|
530
|
+
name=name, expires=expires, clear_expiry=clear_expiry
|
|
531
|
+
)
|
|
532
|
+
response = self._client._http_client.patch(
|
|
533
|
+
f"/user/ssh-keys/{ssh_key_id}",
|
|
534
|
+
json=request_body.model_dump(exclude_unset=True),
|
|
535
|
+
)
|
|
536
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
537
|
+
|
|
538
|
+
async def aedit_ssh_key(
|
|
539
|
+
self,
|
|
540
|
+
ssh_key_id: str,
|
|
541
|
+
*,
|
|
542
|
+
name: typing.Optional[str] = None,
|
|
543
|
+
expires: typing.Optional[int] = None,
|
|
544
|
+
clear_expiry: bool = False,
|
|
545
|
+
) -> "ManagedSSHKey":
|
|
546
|
+
"""Asynchronously edit a key's name or expiry."""
|
|
547
|
+
request_body = _managed_ssh_key_patch_request(
|
|
548
|
+
name=name, expires=expires, clear_expiry=clear_expiry
|
|
549
|
+
)
|
|
550
|
+
response = await self._client._async_http_client.patch(
|
|
551
|
+
f"/user/ssh-keys/{ssh_key_id}",
|
|
552
|
+
json=request_body.model_dump(exclude_unset=True),
|
|
553
|
+
)
|
|
554
|
+
return ManagedSSHKey.model_validate(response.json())
|
|
555
|
+
|
|
556
|
+
def revoke_ssh_key(self, ssh_key_id: str) -> None:
|
|
557
|
+
"""Permanently revoke one managed SSH public key by stable ID."""
|
|
558
|
+
self._client._http_client.delete(f"/user/ssh-keys/{ssh_key_id}")
|
|
559
|
+
|
|
560
|
+
async def arevoke_ssh_key(self, ssh_key_id: str) -> None:
|
|
561
|
+
"""Asynchronously revoke one managed SSH public key by stable ID."""
|
|
562
|
+
await self._client._async_http_client.delete(f"/user/ssh-keys/{ssh_key_id}")
|
|
563
|
+
|
|
564
|
+
# ────────────────────────────────
|
|
565
|
+
# Legacy singular SSH key compatibility
|
|
445
566
|
# ────────────────────────────────
|
|
446
567
|
def get_ssh_key(self) -> "UserSSHKey":
|
|
568
|
+
"""Get the legacy singular key through its compatibility endpoint."""
|
|
447
569
|
response = self._client._http_client.get("/user/ssh-key")
|
|
448
570
|
return UserSSHKey.model_validate(response.json())
|
|
449
571
|
|
|
450
572
|
async def aget_ssh_key(self) -> "UserSSHKey":
|
|
573
|
+
"""Asynchronously get the legacy singular compatibility key."""
|
|
451
574
|
response = await self._client._async_http_client.get("/user/ssh-key")
|
|
452
575
|
return UserSSHKey.model_validate(response.json())
|
|
453
576
|
|
|
454
577
|
def update_ssh_key(self, public_key: str) -> "UserSSHKey":
|
|
578
|
+
"""Set the legacy singular compatibility key.
|
|
579
|
+
|
|
580
|
+
New integrations should use :meth:`add_ssh_key`, then revoke the old key
|
|
581
|
+
only after verifying the replacement.
|
|
582
|
+
"""
|
|
455
583
|
response = self._client._http_client.put(
|
|
456
584
|
"/user/ssh-key", json={"public_key": public_key}
|
|
457
585
|
)
|
|
458
586
|
return UserSSHKey.model_validate(response.json())
|
|
459
587
|
|
|
460
588
|
async def aupdate_ssh_key(self, public_key: str) -> "UserSSHKey":
|
|
589
|
+
"""Asynchronously set the legacy singular compatibility key."""
|
|
461
590
|
response = await self._client._async_http_client.put(
|
|
462
591
|
"/user/ssh-key", json={"public_key": public_key}
|
|
463
592
|
)
|
|
464
593
|
return UserSSHKey.model_validate(response.json())
|
|
465
594
|
|
|
595
|
+
def set_ssh_key(self, public_key: str) -> "UserSSHKey":
|
|
596
|
+
"""Compatibility alias for :meth:`update_ssh_key`."""
|
|
597
|
+
return self.update_ssh_key(public_key)
|
|
598
|
+
|
|
599
|
+
async def aset_ssh_key(self, public_key: str) -> "UserSSHKey":
|
|
600
|
+
"""Async compatibility alias for :meth:`aupdate_ssh_key`."""
|
|
601
|
+
return await self.aupdate_ssh_key(public_key)
|
|
602
|
+
|
|
466
603
|
# ────────────────────────────────
|
|
467
604
|
# Usage
|
|
468
605
|
# ────────────────────────────────
|
|
@@ -535,8 +672,83 @@ class CreateSecretRequest(BaseModel):
|
|
|
535
672
|
|
|
536
673
|
|
|
537
674
|
class UserSSHKey(BaseModel):
|
|
675
|
+
"""Legacy singular user SSH-key response."""
|
|
676
|
+
|
|
677
|
+
public_key: str
|
|
678
|
+
created: int
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class ManagedSSHKeyStatus(StrEnum):
|
|
682
|
+
ACTIVE = "active"
|
|
683
|
+
EXPIRED = "expired"
|
|
684
|
+
REVOKED = "revoked"
|
|
685
|
+
MIGRATION_WARNING = "migration_warning"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class ManagedSSHKey(BaseModel):
|
|
689
|
+
"""A user-owned SSH public key accepted through current org memberships."""
|
|
690
|
+
|
|
691
|
+
object: typing.Literal["user_ssh_key"] = "user_ssh_key"
|
|
692
|
+
id: str
|
|
693
|
+
name: str
|
|
538
694
|
public_key: str
|
|
695
|
+
fingerprint: str
|
|
696
|
+
algorithm: str
|
|
539
697
|
created: int
|
|
698
|
+
updated: int
|
|
699
|
+
expires: typing.Optional[int] = None
|
|
700
|
+
last_used: typing.Optional[int] = None
|
|
701
|
+
revoked: typing.Optional[int] = None
|
|
702
|
+
status: ManagedSSHKeyStatus
|
|
703
|
+
migrated_from_legacy: bool = False
|
|
704
|
+
migration_warning: bool = False
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class ManagedSSHKeyList(BaseModel):
|
|
708
|
+
object: typing.Literal["list"] = "list"
|
|
709
|
+
data: typing.List[ManagedSSHKey]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class ManagedSSHKeyCreateRequest(BaseModel):
|
|
713
|
+
name: str
|
|
714
|
+
public_key: str
|
|
715
|
+
expires: typing.Optional[int] = None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class ManagedSSHKeyPatchRequest(BaseModel):
|
|
719
|
+
name: typing.Optional[str] = None
|
|
720
|
+
expires: typing.Optional[int] = None
|
|
721
|
+
|
|
722
|
+
@model_validator(mode="after")
|
|
723
|
+
def require_change(self) -> "ManagedSSHKeyPatchRequest":
|
|
724
|
+
if not self.model_fields_set:
|
|
725
|
+
raise ValueError("at least one of name or expires must be provided")
|
|
726
|
+
return self
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class ManagedSSHKeyError(BaseModel):
|
|
730
|
+
"""Stable machine-readable error returned by managed-key endpoints."""
|
|
731
|
+
|
|
732
|
+
detail: str
|
|
733
|
+
error_code: str
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _managed_ssh_key_patch_request(
|
|
737
|
+
*,
|
|
738
|
+
name: typing.Optional[str],
|
|
739
|
+
expires: typing.Optional[int],
|
|
740
|
+
clear_expiry: bool,
|
|
741
|
+
) -> ManagedSSHKeyPatchRequest:
|
|
742
|
+
if clear_expiry and expires is not None:
|
|
743
|
+
raise ValueError("expires and clear_expiry cannot be used together")
|
|
744
|
+
values: typing.Dict[str, typing.Any] = {}
|
|
745
|
+
if name is not None:
|
|
746
|
+
values["name"] = name
|
|
747
|
+
if clear_expiry:
|
|
748
|
+
values["expires"] = None
|
|
749
|
+
elif expires is not None:
|
|
750
|
+
values["expires"] = expires
|
|
751
|
+
return ManagedSSHKeyPatchRequest(**values)
|
|
540
752
|
|
|
541
753
|
|
|
542
754
|
class UserInstanceUsage(BaseModel):
|
|
@@ -4,6 +4,7 @@ import datetime
|
|
|
4
4
|
import importlib.metadata
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import pathlib
|
|
7
8
|
import shlex
|
|
8
9
|
import sys
|
|
9
10
|
import termios
|
|
@@ -861,20 +862,153 @@ def user_secret_delete(name):
|
|
|
861
862
|
cli.add_command(user_secret, name="secrets")
|
|
862
863
|
|
|
863
864
|
|
|
864
|
-
# ── SSH
|
|
865
|
+
# ── Managed SSH public keys ─────────────────────────────────
|
|
865
866
|
|
|
866
867
|
|
|
867
868
|
@user.group(cls=AliasedGroup, name="ssh-key")
|
|
868
869
|
def user_ssh_key():
|
|
869
|
-
"""Manage user SSH public
|
|
870
|
+
"""Manage user-owned SSH public keys used for direct SSH."""
|
|
870
871
|
pass
|
|
871
872
|
|
|
872
873
|
|
|
874
|
+
def _read_ssh_public_key(
|
|
875
|
+
public_key: typing.Optional[str], public_key_file: typing.Optional[str]
|
|
876
|
+
) -> str:
|
|
877
|
+
if public_key is not None and public_key_file is not None:
|
|
878
|
+
raise click.ClickException(
|
|
879
|
+
"Use either --public-key or --public-key-file, not both."
|
|
880
|
+
)
|
|
881
|
+
if public_key_file is not None:
|
|
882
|
+
path = pathlib.Path(public_key_file)
|
|
883
|
+
if path.suffix.lower() != ".pub":
|
|
884
|
+
raise click.ClickException(
|
|
885
|
+
"--public-key-file must point to a public .pub file, never a private key."
|
|
886
|
+
)
|
|
887
|
+
try:
|
|
888
|
+
public_key = path.read_text(encoding="utf-8")
|
|
889
|
+
except (OSError, UnicodeError) as exc:
|
|
890
|
+
raise click.ClickException(
|
|
891
|
+
f"Could not read public-key file: {exc}"
|
|
892
|
+
) from exc
|
|
893
|
+
if public_key is None:
|
|
894
|
+
raise click.ClickException(
|
|
895
|
+
"Provide the public key with --public-key or --public-key-file."
|
|
896
|
+
)
|
|
897
|
+
value = public_key.strip()
|
|
898
|
+
if not value:
|
|
899
|
+
raise click.ClickException("SSH public key cannot be empty.")
|
|
900
|
+
if "PRIVATE KEY" in value:
|
|
901
|
+
raise click.ClickException(
|
|
902
|
+
"Private keys are not accepted. Provide only the public .pub value."
|
|
903
|
+
)
|
|
904
|
+
if "\n" in value or "\r" in value:
|
|
905
|
+
raise click.ClickException("Provide exactly one SSH public key.")
|
|
906
|
+
return value
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _parse_ssh_expiry(
|
|
910
|
+
ctx: click.Context,
|
|
911
|
+
param: typing.Optional[click.Parameter],
|
|
912
|
+
value: typing.Optional[str],
|
|
913
|
+
) -> typing.Optional[int]:
|
|
914
|
+
if value is None:
|
|
915
|
+
return None
|
|
916
|
+
try:
|
|
917
|
+
return int(value)
|
|
918
|
+
except ValueError:
|
|
919
|
+
pass
|
|
920
|
+
try:
|
|
921
|
+
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
922
|
+
except ValueError as exc:
|
|
923
|
+
raise click.BadParameter(
|
|
924
|
+
"must be Unix seconds or an ISO 8601 datetime", ctx=ctx, param=param
|
|
925
|
+
) from exc
|
|
926
|
+
if parsed.tzinfo is None:
|
|
927
|
+
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
|
928
|
+
return int(parsed.timestamp())
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@user_ssh_key.command(name="list", aliases=["ls"])
|
|
932
|
+
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
933
|
+
def user_ssh_key_list(json_mode):
|
|
934
|
+
"""List managed SSH public keys."""
|
|
935
|
+
client = get_client()
|
|
936
|
+
try:
|
|
937
|
+
keys = client.user.list_ssh_keys()
|
|
938
|
+
if json_mode:
|
|
939
|
+
click.echo(
|
|
940
|
+
json.dumps([key.model_dump(mode="json") for key in keys], indent=2)
|
|
941
|
+
)
|
|
942
|
+
else:
|
|
943
|
+
headers = [
|
|
944
|
+
"ID",
|
|
945
|
+
"Name",
|
|
946
|
+
"Algorithm",
|
|
947
|
+
"Fingerprint",
|
|
948
|
+
"Status",
|
|
949
|
+
"Created",
|
|
950
|
+
"Last Used",
|
|
951
|
+
"Expires",
|
|
952
|
+
]
|
|
953
|
+
rows = [
|
|
954
|
+
[
|
|
955
|
+
key.id,
|
|
956
|
+
key.name,
|
|
957
|
+
key.algorithm,
|
|
958
|
+
key.fingerprint,
|
|
959
|
+
key.status.value,
|
|
960
|
+
unix_timestamp_to_datetime(key.created),
|
|
961
|
+
unix_timestamp_to_datetime(key.last_used),
|
|
962
|
+
unix_timestamp_to_datetime(key.expires),
|
|
963
|
+
]
|
|
964
|
+
for key in keys
|
|
965
|
+
]
|
|
966
|
+
print_docker_style_table(headers, rows)
|
|
967
|
+
except Exception as e:
|
|
968
|
+
handle_api_error(e)
|
|
969
|
+
|
|
970
|
+
|
|
873
971
|
@user_ssh_key.command(name="get")
|
|
972
|
+
@click.argument("ssh_key_id", required=False)
|
|
874
973
|
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
875
|
-
def user_ssh_key_get(json_mode):
|
|
974
|
+
def user_ssh_key_get(ssh_key_id, json_mode):
|
|
975
|
+
"""Get a managed key by ID, or the legacy singular key when ID is omitted."""
|
|
876
976
|
client = get_client()
|
|
877
977
|
try:
|
|
978
|
+
if ssh_key_id is not None:
|
|
979
|
+
info = client.user.get_managed_ssh_key(ssh_key_id)
|
|
980
|
+
if json_mode:
|
|
981
|
+
click.echo(format_json(info))
|
|
982
|
+
else:
|
|
983
|
+
headers = [
|
|
984
|
+
"ID",
|
|
985
|
+
"Name",
|
|
986
|
+
"Algorithm",
|
|
987
|
+
"Fingerprint",
|
|
988
|
+
"Status",
|
|
989
|
+
"Created",
|
|
990
|
+
"Updated",
|
|
991
|
+
"Last Used",
|
|
992
|
+
"Expires",
|
|
993
|
+
"Revoked",
|
|
994
|
+
]
|
|
995
|
+
rows = [
|
|
996
|
+
[
|
|
997
|
+
info.id,
|
|
998
|
+
info.name,
|
|
999
|
+
info.algorithm,
|
|
1000
|
+
info.fingerprint,
|
|
1001
|
+
info.status.value,
|
|
1002
|
+
unix_timestamp_to_datetime(info.created),
|
|
1003
|
+
unix_timestamp_to_datetime(info.updated),
|
|
1004
|
+
unix_timestamp_to_datetime(info.last_used),
|
|
1005
|
+
unix_timestamp_to_datetime(info.expires),
|
|
1006
|
+
unix_timestamp_to_datetime(info.revoked),
|
|
1007
|
+
]
|
|
1008
|
+
]
|
|
1009
|
+
print_docker_style_table(headers, rows)
|
|
1010
|
+
return
|
|
1011
|
+
|
|
878
1012
|
info = client.user.get_ssh_key()
|
|
879
1013
|
if json_mode:
|
|
880
1014
|
click.echo(format_json(info))
|
|
@@ -886,6 +1020,120 @@ def user_ssh_key_get(json_mode):
|
|
|
886
1020
|
handle_api_error(e)
|
|
887
1021
|
|
|
888
1022
|
|
|
1023
|
+
@user_ssh_key.command(name="add", aliases=["create"])
|
|
1024
|
+
@click.option("--name", required=True, help="A unique, recognizable device name.")
|
|
1025
|
+
@click.option("--public-key", default=None, help="One OpenSSH public-key value.")
|
|
1026
|
+
@click.option(
|
|
1027
|
+
"--public-key-file",
|
|
1028
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
1029
|
+
default=None,
|
|
1030
|
+
help="Read the public key from a .pub file.",
|
|
1031
|
+
)
|
|
1032
|
+
@click.option(
|
|
1033
|
+
"--expires",
|
|
1034
|
+
callback=_parse_ssh_expiry,
|
|
1035
|
+
default=None,
|
|
1036
|
+
help="Optional expiry as Unix seconds or ISO 8601 (naive values are UTC).",
|
|
1037
|
+
)
|
|
1038
|
+
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
1039
|
+
def user_ssh_key_add(name, public_key, public_key_file, expires, json_mode):
|
|
1040
|
+
"""Add a managed SSH public key.
|
|
1041
|
+
|
|
1042
|
+
A user-owned key authorizes direct SSH through all current organization
|
|
1043
|
+
memberships. Morph never asks for or stores the corresponding private key.
|
|
1044
|
+
"""
|
|
1045
|
+
key_value = _read_ssh_public_key(public_key, public_key_file)
|
|
1046
|
+
client = get_client()
|
|
1047
|
+
try:
|
|
1048
|
+
with Spinner(
|
|
1049
|
+
text="Adding SSH public key...",
|
|
1050
|
+
success_text="SSH public key added",
|
|
1051
|
+
success_emoji="🔐",
|
|
1052
|
+
):
|
|
1053
|
+
info = client.user.add_ssh_key(
|
|
1054
|
+
name=name, public_key=key_value, expires=expires
|
|
1055
|
+
)
|
|
1056
|
+
if json_mode:
|
|
1057
|
+
click.echo(format_json(info))
|
|
1058
|
+
else:
|
|
1059
|
+
click.secho("SSH public key added.", fg="green")
|
|
1060
|
+
click.echo(f"ID: {info.id}")
|
|
1061
|
+
click.echo(f"Name: {info.name}")
|
|
1062
|
+
click.echo(f"Fingerprint: {info.fingerprint}")
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
handle_api_error(e)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@user_ssh_key.command(name="edit")
|
|
1068
|
+
@click.argument("ssh_key_id")
|
|
1069
|
+
@click.option("--name", default=None, help="New recognizable device name.")
|
|
1070
|
+
@click.option(
|
|
1071
|
+
"--expires",
|
|
1072
|
+
callback=_parse_ssh_expiry,
|
|
1073
|
+
default=None,
|
|
1074
|
+
help="New expiry as Unix seconds or ISO 8601.",
|
|
1075
|
+
)
|
|
1076
|
+
@click.option(
|
|
1077
|
+
"--clear-expiry",
|
|
1078
|
+
is_flag=True,
|
|
1079
|
+
default=False,
|
|
1080
|
+
help="Remove the key's existing expiry.",
|
|
1081
|
+
)
|
|
1082
|
+
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
1083
|
+
def user_ssh_key_edit(ssh_key_id, name, expires, clear_expiry, json_mode):
|
|
1084
|
+
"""Edit a managed key's name or expiry; public material is immutable."""
|
|
1085
|
+
if name is None and expires is None and not clear_expiry:
|
|
1086
|
+
raise click.ClickException(
|
|
1087
|
+
"Provide at least one of --name, --expires, or --clear-expiry."
|
|
1088
|
+
)
|
|
1089
|
+
if expires is not None and clear_expiry:
|
|
1090
|
+
raise click.ClickException("Use either --expires or --clear-expiry, not both.")
|
|
1091
|
+
|
|
1092
|
+
client = get_client()
|
|
1093
|
+
try:
|
|
1094
|
+
with Spinner(
|
|
1095
|
+
text="Editing SSH public key...",
|
|
1096
|
+
success_text="SSH public key edited",
|
|
1097
|
+
success_emoji="🔐",
|
|
1098
|
+
):
|
|
1099
|
+
info = client.user.edit_ssh_key(
|
|
1100
|
+
ssh_key_id, name=name, expires=expires, clear_expiry=clear_expiry
|
|
1101
|
+
)
|
|
1102
|
+
if json_mode:
|
|
1103
|
+
click.echo(format_json(info))
|
|
1104
|
+
else:
|
|
1105
|
+
click.secho("SSH public key edited.", fg="green")
|
|
1106
|
+
click.echo(f"ID: {info.id}")
|
|
1107
|
+
click.echo(f"Name: {info.name}")
|
|
1108
|
+
click.echo(f"Expires: {unix_timestamp_to_datetime(info.expires)}")
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
handle_api_error(e)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
@user_ssh_key.command(name="revoke", aliases=["rm"])
|
|
1114
|
+
@click.argument("ssh_key_id")
|
|
1115
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
1116
|
+
def user_ssh_key_revoke(ssh_key_id, yes):
|
|
1117
|
+
"""Revoke one managed key across all current organization memberships."""
|
|
1118
|
+
if not yes:
|
|
1119
|
+
click.confirm(
|
|
1120
|
+
"Revoke this key for direct SSH across all current organization "
|
|
1121
|
+
"memberships? Browser terminal and instance credentials are separate.",
|
|
1122
|
+
abort=True,
|
|
1123
|
+
)
|
|
1124
|
+
client = get_client()
|
|
1125
|
+
try:
|
|
1126
|
+
with Spinner(
|
|
1127
|
+
text="Revoking SSH public key...",
|
|
1128
|
+
success_text="SSH public key revoked",
|
|
1129
|
+
success_emoji="🗑",
|
|
1130
|
+
):
|
|
1131
|
+
client.user.revoke_ssh_key(ssh_key_id)
|
|
1132
|
+
click.secho("SSH public key revoked.", fg="green")
|
|
1133
|
+
except Exception as e:
|
|
1134
|
+
handle_api_error(e)
|
|
1135
|
+
|
|
1136
|
+
|
|
889
1137
|
@user_ssh_key.command(name="set", aliases=["update"])
|
|
890
1138
|
@click.option(
|
|
891
1139
|
"--public-key",
|
|
@@ -894,6 +1142,10 @@ def user_ssh_key_get(json_mode):
|
|
|
894
1142
|
)
|
|
895
1143
|
@click.option("--json", "json_mode", is_flag=True, default=False)
|
|
896
1144
|
def user_ssh_key_set(public_key, json_mode):
|
|
1145
|
+
"""Set the legacy singular compatibility key.
|
|
1146
|
+
|
|
1147
|
+
Prefer ``add`` for safe overlap rotation with managed keys.
|
|
1148
|
+
"""
|
|
897
1149
|
client = get_client()
|
|
898
1150
|
try:
|
|
899
1151
|
with Spinner(
|
|
@@ -901,7 +1153,7 @@ def user_ssh_key_set(public_key, json_mode):
|
|
|
901
1153
|
success_text="SSH key updated",
|
|
902
1154
|
success_emoji="🔐",
|
|
903
1155
|
):
|
|
904
|
-
info = client.user.
|
|
1156
|
+
info = client.user.set_ssh_key(public_key)
|
|
905
1157
|
if json_mode:
|
|
906
1158
|
click.echo(format_json(info))
|
|
907
1159
|
else:
|