remo-cli 1.0.2__tar.gz → 2.0.0rc2__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 (207) hide show
  1. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/PKG-INFO +1 -1
  2. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/pyproject.toml +1 -1
  3. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/providers/incus.py +14 -2
  4. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/providers/proxmox.py +14 -2
  5. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/shell.py +40 -13
  6. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/ssh.py +33 -11
  7. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/providers/incus.py +15 -3
  8. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/providers/proxmox.py +17 -6
  9. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/cli/test_shell.py +58 -5
  10. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_ssh.py +44 -18
  11. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/uv.lock +1 -1
  12. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.analyze.md +0 -0
  13. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.checklist.md +0 -0
  14. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.clarify.md +0 -0
  15. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.constitution.md +0 -0
  16. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.implement.md +0 -0
  17. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.plan.md +0 -0
  18. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.specify.md +0 -0
  19. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.tasks.md +0 -0
  20. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.claude/commands/speckit.taskstoissues.md +0 -0
  21. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.devcontainer/devcontainer.json +0 -0
  22. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.dockerignore +0 -0
  23. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/dependabot.yml +0 -0
  24. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/ci.yml +0 -0
  25. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/provision.yml +0 -0
  26. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/release.yml +0 -0
  27. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/smoke-test-cleanup.yml +0 -0
  28. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/smoke-test.yml +0 -0
  29. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.github/workflows/teardown.yml +0 -0
  30. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.gitignore +0 -0
  31. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.maverick/checkpoints/feature/implementation_complete.json +0 -0
  32. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.maverick/checkpoints/feature/phase_${{ index }}_complete.json +0 -0
  33. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.maverick/checkpoints/feature/validation_complete.json +0 -0
  34. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/memory/constitution.md +0 -0
  35. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/scripts/bash/check-prerequisites.sh +0 -0
  36. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/scripts/bash/common.sh +0 -0
  37. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/scripts/bash/create-new-feature.sh +0 -0
  38. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/scripts/bash/setup-plan.sh +0 -0
  39. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/scripts/bash/update-agent-context.sh +0 -0
  40. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/templates/agent-file-template.md +0 -0
  41. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/templates/checklist-template.md +0 -0
  42. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/templates/plan-template.md +0 -0
  43. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/templates/spec-template.md +0 -0
  44. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/.specify/templates/tasks-template.md +0 -0
  45. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/CLAUDE.md +0 -0
  46. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/CONTRIBUTING.md +0 -0
  47. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/Dockerfile +0 -0
  48. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/LICENSE +0 -0
  49. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/README.md +0 -0
  50. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/.ansible-lint +0 -0
  51. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/README.md +0 -0
  52. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/ansible.cfg +0 -0
  53. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/aws_configure.yml +0 -0
  54. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/aws_resize.yml +0 -0
  55. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/aws_site.yml +0 -0
  56. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/aws_teardown.yml +0 -0
  57. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/group_vars/all.yml +0 -0
  58. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/group_vars/incus_containers.yml +0 -0
  59. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/group_vars/proxmox_containers.yml +0 -0
  60. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/hetzner_configure.yml +0 -0
  61. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/hetzner_provision.yml +0 -0
  62. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/hetzner_resize.yml +0 -0
  63. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/hetzner_site.yml +0 -0
  64. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/hetzner_teardown.yml +0 -0
  65. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_bootstrap.yml +0 -0
  66. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_configure.yml +0 -0
  67. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_provision.yml +0 -0
  68. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_resize.yml +0 -0
  69. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_site.yml +0 -0
  70. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/incus_teardown.yml +0 -0
  71. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/inventory/hosts.yml +0 -0
  72. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/inventory/incus_containers.yml +0 -0
  73. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_bootstrap.yml +0 -0
  74. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_configure.yml +0 -0
  75. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_provision.yml +0 -0
  76. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_resize.yml +0 -0
  77. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_site.yml +0 -0
  78. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/proxmox_teardown.yml +0 -0
  79. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/requirements.yml +0 -0
  80. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/defaults/main.yml +0 -0
  81. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/ebs.yml +0 -0
  82. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/ec2.yml +0 -0
  83. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/main.yml +0 -0
  84. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/patch_manager.yml +0 -0
  85. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/preflight.yml +0 -0
  86. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/aws_server/tasks/security_group.yml +0 -0
  87. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/devcontainers/tasks/main.yml +0 -0
  88. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/docker/handlers/main.yml +0 -0
  89. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/docker/tasks/main.yml +0 -0
  90. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/fzf/tasks/main.yml +0 -0
  91. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/github_cli/tasks/main.yml +0 -0
  92. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/hetzner_server/defaults/main.yml +0 -0
  93. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/hetzner_server/tasks/main.yml +0 -0
  94. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_bootstrap/defaults/main.yml +0 -0
  95. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_bootstrap/handlers/main.yml +0 -0
  96. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_bootstrap/tasks/main.yml +0 -0
  97. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container/defaults/main.yml +0 -0
  98. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container/handlers/main.yml +0 -0
  99. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container/meta/main.yml +0 -0
  100. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container/tasks/main.yml +0 -0
  101. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container/tasks/preflight.yml +0 -0
  102. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container_teardown/defaults/main.yml +0 -0
  103. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container_teardown/meta/main.yml +0 -0
  104. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/incus_container_teardown/tasks/main.yml +0 -0
  105. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/nodejs/defaults/main.yml +0 -0
  106. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/nodejs/tasks/main.yml +0 -0
  107. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_bootstrap/defaults/main.yml +0 -0
  108. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_bootstrap/meta/main.yml +0 -0
  109. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_bootstrap/tasks/main.yml +0 -0
  110. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container/defaults/main.yml +0 -0
  111. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container/handlers/main.yml +0 -0
  112. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container/meta/main.yml +0 -0
  113. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container/tasks/main.yml +0 -0
  114. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container/tasks/preflight.yml +0 -0
  115. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container_teardown/defaults/main.yml +0 -0
  116. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container_teardown/meta/main.yml +0 -0
  117. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/proxmox_container_teardown/tasks/main.yml +0 -0
  118. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/user_setup/defaults/main.yml +0 -0
  119. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/user_setup/tasks/main.yml +0 -0
  120. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/user_setup/templates/devshell.sh.j2 +0 -0
  121. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/user_setup/templates/project-menu.sh.j2 +0 -0
  122. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/zellij/defaults/main.yml +0 -0
  123. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/zellij/tasks/main.yml +0 -0
  124. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/zellij/templates/config.kdl.j2 +0 -0
  125. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/roles/zellij/templates/layouts/minimal.kdl.j2 +0 -0
  126. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/ansible/tasks/configure_dev_tools.yml +0 -0
  127. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/docs/aws.md +0 -0
  128. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/docs/hetzner.md +0 -0
  129. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/docs/incus.md +0 -0
  130. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/docs/install.sh +0 -0
  131. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/docs/proxmox.md +0 -0
  132. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/install.sh +0 -0
  133. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/maverick.yaml +0 -0
  134. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/project-menu.sh +0 -0
  135. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/remo +0 -0
  136. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/run.sh +0 -0
  137. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/scripts/setup-aws-oidc.sh +0 -0
  138. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/checklists/requirements.md +0 -0
  139. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/checklists/validation.md +0 -0
  140. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/contracts/playbook-interface.yaml +0 -0
  141. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/contracts/role-interface.yaml +0 -0
  142. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/data-model.md +0 -0
  143. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/plan.md +0 -0
  144. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/quickstart.md +0 -0
  145. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/research.md +0 -0
  146. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/spec.md +0 -0
  147. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/001-bootstrap-incus-host/tasks.md +0 -0
  148. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/checklists/requirements.md +0 -0
  149. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/contracts/playbook-interface.yaml +0 -0
  150. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/contracts/role-interface.yaml +0 -0
  151. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/data-model.md +0 -0
  152. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/plan.md +0 -0
  153. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/quickstart.md +0 -0
  154. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/research.md +0 -0
  155. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/spec.md +0 -0
  156. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/002-incus-container-support/tasks.md +0 -0
  157. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/checklists/requirements.md +0 -0
  158. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/data-model.md +0 -0
  159. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/plan.md +0 -0
  160. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/quickstart.md +0 -0
  161. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/research.md +0 -0
  162. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/spec.md +0 -0
  163. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/003-python-cli-rewrite/tasks.md +0 -0
  164. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/specs/004-proxmox-container-support/spec.md +0 -0
  165. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/__init__.py +0 -0
  166. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/__main__.py +0 -0
  167. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/__init__.py +0 -0
  168. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/cp.py +0 -0
  169. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/main.py +0 -0
  170. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/providers/__init__.py +0 -0
  171. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/providers/aws.py +0 -0
  172. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/cli/providers/hetzner.py +0 -0
  173. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/__init__.py +0 -0
  174. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/ansible_runner.py +0 -0
  175. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/completion.py +0 -0
  176. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/config.py +0 -0
  177. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/known_hosts.py +0 -0
  178. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/output.py +0 -0
  179. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/picker.py +0 -0
  180. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/rsync.py +0 -0
  181. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/validation.py +0 -0
  182. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/core/version.py +0 -0
  183. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/models/__init__.py +0 -0
  184. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/models/host.py +0 -0
  185. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/providers/__init__.py +0 -0
  186. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/providers/aws.py +0 -0
  187. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/src/remo_cli/providers/hetzner.py +0 -0
  188. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/__init__.py +0 -0
  189. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/conftest.py +0 -0
  190. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/integration/__init__.py +0 -0
  191. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/integration/orbstack-cloud-init.yml +0 -0
  192. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/integration/orbstack.sh +0 -0
  193. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/__init__.py +0 -0
  194. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/cli/__init__.py +0 -0
  195. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/cli/test_cp.py +0 -0
  196. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/cli/test_main.py +0 -0
  197. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/__init__.py +0 -0
  198. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_ansible_runner.py +0 -0
  199. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_config.py +0 -0
  200. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_known_hosts.py +0 -0
  201. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_output.py +0 -0
  202. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_picker.py +0 -0
  203. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_rsync.py +0 -0
  204. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_validation.py +0 -0
  205. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/core/test_version.py +0 -0
  206. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/providers/__init__.py +0 -0
  207. {remo_cli-1.0.2 → remo_cli-2.0.0rc2}/tests/unit/test_host_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remo-cli
3
- Version: 1.0.2
3
+ Version: 2.0.0rc2
4
4
  Summary: Remote development environment CLI
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "remo-cli"
7
- version = "1.0.2"
7
+ version = "2.0.0rc2"
8
8
  description = "Remote development environment CLI"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,6 +40,11 @@ def incus() -> None:
40
40
  )
41
41
  @click.option("--only", multiple=True, help="Only install these tools.")
42
42
  @click.option("--skip", multiple=True, help="Skip these tools.")
43
+ @click.option(
44
+ "--use-ip",
45
+ is_flag=True,
46
+ help="Store the container's IP address in known_hosts instead of its name (for setups without DNS/MagicDNS).",
47
+ )
43
48
  @click.option("--yes", "-y", is_flag=True, help="Auto-confirm prompts.")
44
49
  @click.option("-v", "--verbose", is_flag=True, help="Verbose output.")
45
50
  def create(
@@ -53,6 +58,7 @@ def create(
53
58
  memory: int,
54
59
  only: tuple[str, ...],
55
60
  skip: tuple[str, ...],
61
+ use_ip: bool,
56
62
  yes: bool,
57
63
  verbose: bool,
58
64
  ) -> None:
@@ -68,6 +74,7 @@ def create(
68
74
  memory=memory,
69
75
  tools_only=only,
70
76
  tools_skip=skip,
77
+ use_ip=use_ip,
71
78
  verbose=verbose,
72
79
  )
73
80
  sys.exit(rc)
@@ -173,9 +180,14 @@ def info(name: str, host: str, user: str) -> None:
173
180
  @incus.command()
174
181
  @click.option("--host", default="localhost", help="Incus host (default: localhost).")
175
182
  @click.option("--user", default="", help="SSH user for remote Incus host.")
176
- def sync(host: str, user: str) -> None:
183
+ @click.option(
184
+ "--use-ip",
185
+ is_flag=True,
186
+ help="Store each container's IP address in known_hosts instead of its name (for setups without DNS/MagicDNS).",
187
+ )
188
+ def sync(host: str, user: str, use_ip: bool) -> None:
177
189
  """Discover containers from an Incus host."""
178
- providers_incus.sync(host=host, user=user)
190
+ providers_incus.sync(host=host, user=user, use_ip=use_ip)
179
191
 
180
192
 
181
193
  @incus.command()
@@ -38,6 +38,11 @@ def proxmox() -> None:
38
38
  @click.option("--domain", default="", help="Domain name for the container.")
39
39
  @click.option("--only", multiple=True, help="Only install these tools.")
40
40
  @click.option("--skip", multiple=True, help="Skip these tools.")
41
+ @click.option(
42
+ "--use-ip",
43
+ is_flag=True,
44
+ help="Store the container's IP address in known_hosts instead of its name (for setups without DNS/MagicDNS).",
45
+ )
41
46
  @click.option("--yes", "-y", is_flag=True, help="Auto-confirm prompts.")
42
47
  @click.option("-v", "--verbose", is_flag=True, help="Verbose output.")
43
48
  def create(
@@ -55,6 +60,7 @@ def create(
55
60
  domain: str,
56
61
  only: tuple[str, ...],
57
62
  skip: tuple[str, ...],
63
+ use_ip: bool,
58
64
  yes: bool,
59
65
  verbose: bool,
60
66
  ) -> None:
@@ -74,6 +80,7 @@ def create(
74
80
  domain=domain,
75
81
  tools_only=only,
76
82
  tools_skip=skip,
83
+ use_ip=use_ip,
77
84
  verbose=verbose,
78
85
  )
79
86
  sys.exit(rc)
@@ -179,9 +186,14 @@ def info(name: str, host: str, user: str) -> None:
179
186
  @proxmox.command()
180
187
  @click.option("--host", required=True, help="Proxmox host to scan.")
181
188
  @click.option("--user", default="", help="SSH user for the Proxmox host.")
182
- def sync(host: str, user: str) -> None:
189
+ @click.option(
190
+ "--use-ip",
191
+ is_flag=True,
192
+ help="Store each container's IP address in known_hosts instead of its name (for setups without DNS/MagicDNS).",
193
+ )
194
+ def sync(host: str, user: str, use_ip: bool) -> None:
183
195
  """Discover containers from a Proxmox host."""
184
- providers_proxmox.sync(host=host, user=user)
196
+ providers_proxmox.sync(host=host, user=user, use_ip=use_ip)
185
197
 
186
198
 
187
199
  @proxmox.command()
@@ -33,7 +33,7 @@ def shell(
33
33
  ) -> None:
34
34
  """Connect to a remo environment (auto-detects or picker)."""
35
35
  from remo_cli.core.ssh import check_remote_version, resolve_remo_host, shell_connect # noqa: PLC0415
36
- from remo_cli.core.output import confirm, print_warning # noqa: PLC0415
36
+ from remo_cli.core.output import confirm, print_error, print_warning # noqa: PLC0415
37
37
  from remo_cli.core.version import get_current_version, version_is_newer # noqa: PLC0415
38
38
  from remo_cli.providers.aws import auto_start_aws_if_stopped # noqa: PLC0415
39
39
 
@@ -46,10 +46,20 @@ def shell(
46
46
  if not no_update_check:
47
47
  local_version = get_current_version()
48
48
  if local_version != "unknown":
49
- remote_version = check_remote_version(host)
49
+ remote_version, remote_err = check_remote_version(host)
50
50
 
51
51
  should_update = False
52
- if remote_version is None:
52
+ if remote_err is not None:
53
+ # SSH itself failed — we can't tell whether the marker exists
54
+ # or what version is on the box. Don't prompt to update from
55
+ # an unknown baseline; surface the error so the user can fix
56
+ # it (DNS, host key, auth, ...) and re-run.
57
+ print_warning(
58
+ f"Could not check tools version on '{host.name}':\n"
59
+ f" {remote_err}\n"
60
+ f" Skipping update check; proceeding with connection."
61
+ )
62
+ elif remote_version is None:
53
63
  # No marker file on remote
54
64
  should_update = confirm(
55
65
  f"Instance '{host.name}' has no version info. Update tools?",
@@ -71,29 +81,45 @@ def shell(
71
81
  )
72
82
 
73
83
  if should_update:
74
- _run_provider_update(host)
84
+ rc = _run_provider_update(host)
85
+ if rc != 0:
86
+ # The playbook log has already been dumped, but the SSH
87
+ # connection (and any remote project picker) would scroll
88
+ # it offscreen immediately. Pause so the user can read it.
89
+ print_error(
90
+ f"Tools update for '{host.name}' failed "
91
+ f"(ansible-playbook exit code {rc})."
92
+ )
93
+ if not confirm(
94
+ "Connect anyway?",
95
+ default=False,
96
+ ):
97
+ raise SystemExit(rc)
75
98
 
76
99
  shell_connect(host, list(tunnels), no_open)
77
100
 
78
101
 
79
- def _run_provider_update(host) -> None: # noqa: ANN001
80
- """Run the appropriate provider update for the given host."""
102
+ def _run_provider_update(host) -> int: # noqa: ANN001
103
+ """Run the appropriate provider update for the given host.
104
+
105
+ Returns the provider update's exit code (0 on success).
106
+ """
81
107
  from remo_cli.core.output import print_info # noqa: PLC0415
82
108
 
83
109
  print_info(f"Updating instance '{host.name}'...")
84
110
 
85
111
  if host.type == "aws":
86
112
  from remo_cli.providers.aws import update as aws_update # noqa: PLC0415
87
- aws_update(name=host.name)
88
- elif host.type == "hetzner":
113
+ return aws_update(name=host.name)
114
+ if host.type == "hetzner":
89
115
  from remo_cli.providers.hetzner import update as hetzner_update # noqa: PLC0415
90
- hetzner_update(name=host.name)
91
- elif host.type == "incus":
116
+ return hetzner_update(name=host.name)
117
+ if host.type == "incus":
92
118
  from remo_cli.providers.incus import update as incus_update # noqa: PLC0415
93
119
  # Incus name in known_hosts is "host/container" — extract just the container name
94
120
  container_name = host.name.split("/", maxsplit=1)[-1] if "/" in host.name else host.name
95
- incus_update(name=container_name)
96
- elif host.type == "proxmox":
121
+ return incus_update(name=container_name)
122
+ if host.type == "proxmox":
97
123
  from remo_cli.providers.proxmox import update as proxmox_update # noqa: PLC0415
98
124
  # Proxmox name in known_hosts is "node/container".
99
125
  # The proxmox SSH user is stored in the region slot (see providers.proxmox.create).
@@ -101,8 +127,9 @@ def _run_provider_update(host) -> None: # noqa: ANN001
101
127
  if not container_name:
102
128
  container_name = host.name
103
129
  proxmox_host = ""
104
- proxmox_update(
130
+ return proxmox_update(
105
131
  name=container_name,
106
132
  host=proxmox_host,
107
133
  user=host.region or "",
108
134
  )
135
+ return 0
@@ -257,24 +257,46 @@ def detect_timezone() -> str:
257
257
  return ""
258
258
 
259
259
 
260
- def check_remote_version(host: KnownHost) -> str | None:
260
+ def check_remote_version(host: KnownHost) -> tuple[str | None, str | None]:
261
261
  """Read the remo version marker from the remote instance.
262
262
 
263
- Runs ``cat ~/.remo-version`` over SSH and returns the version string,
264
- or ``None`` if the file doesn't exist or SSH fails.
263
+ Runs ``cat ~/.remo-version`` over SSH. Returns a ``(version, error)``
264
+ tuple where exactly one element is non-None:
265
+
266
+ * ``(version, None)`` — marker present and readable.
267
+ * ``(None, None)`` — SSH succeeded but the marker is missing/empty.
268
+ * ``(None, error)`` — SSH itself failed (DNS, host key, network,
269
+ timeout, ...); *error* is the underlying message so the caller can
270
+ surface it to the user.
265
271
  """
266
272
  ssh_opts, ssh_target = build_ssh_opts(host)
267
- cmd = ["ssh"] + ssh_opts + ["-o", "ConnectTimeout=10", ssh_target, "cat ~/.remo-version 2>/dev/null"]
273
+ cmd = ["ssh"] + ssh_opts + [
274
+ "-o", "ConnectTimeout=10",
275
+ "-o", "BatchMode=yes",
276
+ ssh_target,
277
+ "cat ~/.remo-version 2>/dev/null",
278
+ ]
268
279
 
269
280
  try:
270
281
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
271
- version = result.stdout.strip()
272
- if result.returncode == 0 and version:
273
- return version
274
- except (subprocess.TimeoutExpired, OSError):
275
- pass
276
-
277
- return None
282
+ except subprocess.TimeoutExpired:
283
+ return None, "SSH timed out after 15s"
284
+ except OSError as e:
285
+ return None, f"SSH failed: {e}"
286
+
287
+ # ssh exits with 255 for its own failures (auth, host key, DNS,
288
+ # connection refused, ...). Any other non-zero exit code comes from
289
+ # the remote command — here, `cat` failing because the marker is
290
+ # absent — which we treat as "no marker" rather than an SSH error.
291
+ if result.returncode == 255:
292
+ stderr = result.stderr.strip()
293
+ return None, stderr or "SSH connection failed (exit code 255)"
294
+
295
+ version = result.stdout.strip()
296
+ if result.returncode == 0 and version:
297
+ return version, None
298
+
299
+ return None, None
278
300
 
279
301
 
280
302
  def shell_connect(host: KnownHost, tunnels: list[str], no_open: bool) -> None:
@@ -165,6 +165,7 @@ def create(
165
165
  memory: int = 0,
166
166
  tools_only: tuple[str, ...] = (),
167
167
  tools_skip: tuple[str, ...] = (),
168
+ use_ip: bool = False,
168
169
  verbose: bool = False,
169
170
  ) -> int:
170
171
  """Create a new Incus container and configure it with dev tools.
@@ -206,7 +207,10 @@ def create(
206
207
  rc = run_playbook("incus_site.yml", extra_vars, verbose=verbose)
207
208
 
208
209
  if rc == 0:
209
- container_host = _resolve_container_ip(name, host, user) or name
210
+ if use_ip:
211
+ container_host = _resolve_container_ip(name, host, user) or name
212
+ else:
213
+ container_host = name
210
214
  save_known_host(
211
215
  KnownHost(
212
216
  type="incus",
@@ -483,13 +487,17 @@ def info(name: str, host: str = "", user: str = "") -> int:
483
487
  return 0
484
488
 
485
489
 
486
- def sync(host: str = "localhost", user: str = "") -> None:
490
+ def sync(host: str = "localhost", user: str = "", use_ip: bool = False) -> None:
487
491
  """Discover Incus containers on *host* and register them in known-hosts.
488
492
 
489
493
  For localhost, runs ``incus list -f csv -c n`` directly. For remote hosts,
490
494
  the same command is executed over SSH. All previously registered entries
491
495
  for the given host prefix are cleared before the newly discovered
492
496
  containers are saved.
497
+
498
+ When *use_ip* is true, each container's eth0 IP is resolved and stored as
499
+ the ``host`` field; otherwise the container name itself is stored (and
500
+ relies on DNS/MagicDNS for resolution at connect time).
493
501
  """
494
502
  if host == "localhost":
495
503
  result = subprocess.run(
@@ -523,11 +531,15 @@ def sync(host: str = "localhost", user: str = "") -> None:
523
531
  clear_known_hosts_by_prefix("incus", f"{host}/")
524
532
 
525
533
  for name in containers:
534
+ if use_ip:
535
+ container_host = _resolve_container_ip(name, host, user) or name
536
+ else:
537
+ container_host = name
526
538
  save_known_host(
527
539
  KnownHost(
528
540
  type="incus",
529
541
  name=f"{host}/{name}",
530
- host=name,
542
+ host=container_host,
531
543
  user="remo",
532
544
  instance_id=user,
533
545
  access_mode="direct",
@@ -180,6 +180,7 @@ def create(
180
180
  domain: str = "",
181
181
  tools_only: tuple[str, ...] = (),
182
182
  tools_skip: tuple[str, ...] = (),
183
+ use_ip: bool = False,
183
184
  verbose: bool = False,
184
185
  ) -> int:
185
186
  """Create a new Proxmox LXC container and configure dev tools.
@@ -241,12 +242,15 @@ def create(
241
242
 
242
243
  if rc == 0:
243
244
  vmid = _resolve_vmid(name, host, user)
244
- ip = _resolve_container_ip(name, host, user, vmid=vmid) or name
245
+ if use_ip:
246
+ container_host = _resolve_container_ip(name, host, user, vmid=vmid) or name
247
+ else:
248
+ container_host = name
245
249
  save_known_host(
246
250
  KnownHost(
247
251
  type="proxmox",
248
252
  name=f"{host}/{name}",
249
- host=ip,
253
+ host=container_host,
250
254
  user="remo",
251
255
  instance_id=vmid,
252
256
  access_mode="direct",
@@ -541,11 +545,15 @@ def _parse_pct_config_field(config_text: str, field: str) -> str:
541
545
  return match.group(1).strip() if match else ""
542
546
 
543
547
 
544
- def sync(host: str, user: str = "") -> None:
548
+ def sync(host: str, user: str = "", use_ip: bool = False) -> None:
545
549
  """Discover Proxmox LXC containers on *host* and register them.
546
550
 
547
551
  Runs ``pct list`` over SSH (or locally if host == "localhost"),
548
- parses the output, then queries each container for its VMID and IP.
552
+ parses the output, then queries each container for its VMID. When
553
+ *use_ip* is true, each container's eth0 IP is also resolved and stored
554
+ as the ``host`` field; otherwise the container name itself is stored
555
+ (and relies on DNS/MagicDNS for resolution at connect time).
556
+
549
557
  Existing entries with the host prefix are cleared first.
550
558
  """
551
559
  if not host:
@@ -581,12 +589,15 @@ def sync(host: str, user: str = "") -> None:
581
589
  clear_known_hosts_by_prefix("proxmox", f"{host}/")
582
590
 
583
591
  for vmid, hostname in containers:
584
- ip = _resolve_container_ip(hostname, host, user, vmid=vmid) or hostname
592
+ if use_ip:
593
+ container_host = _resolve_container_ip(hostname, host, user, vmid=vmid) or hostname
594
+ else:
595
+ container_host = hostname
585
596
  save_known_host(
586
597
  KnownHost(
587
598
  type="proxmox",
588
599
  name=f"{host}/{hostname}",
589
- host=ip,
600
+ host=container_host,
590
601
  user="remo",
591
602
  instance_id=vmid,
592
603
  access_mode="direct",
@@ -52,7 +52,7 @@ class TestShellVersionCheck:
52
52
  def test_equal_versions_proceeds_silently(self, runner, mocker):
53
53
  """When remote and local versions match, no prompt is shown."""
54
54
  mocker.patch("remo_cli.core.version.get_current_version", return_value="0.8.0")
55
- mocker.patch("remo_cli.core.ssh.check_remote_version", return_value="0.8.0")
55
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.8.0", None))
56
56
  mock_confirm = mocker.patch("remo_cli.core.output.confirm")
57
57
 
58
58
  result = runner.invoke(shell, [])
@@ -64,7 +64,7 @@ class TestShellVersionCheck:
64
64
  def test_remote_behind_prompts_update(self, runner, mocker):
65
65
  """When remote is behind local, user is prompted to update."""
66
66
  mocker.patch("remo_cli.core.version.get_current_version", return_value="0.9.0")
67
- mocker.patch("remo_cli.core.ssh.check_remote_version", return_value="0.8.0")
67
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.8.0", None))
68
68
  mock_confirm = mocker.patch("remo_cli.core.output.confirm", return_value=False)
69
69
 
70
70
  result = runner.invoke(shell, [])
@@ -78,7 +78,7 @@ class TestShellVersionCheck:
78
78
  def test_remote_behind_update_accepted(self, runner, mocker):
79
79
  """When user accepts update, provider update is called."""
80
80
  mocker.patch("remo_cli.core.version.get_current_version", return_value="0.9.0")
81
- mocker.patch("remo_cli.core.ssh.check_remote_version", return_value="0.8.0")
81
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.8.0", None))
82
82
  mocker.patch("remo_cli.core.output.confirm", return_value=True)
83
83
  mock_update = mocker.patch("remo_cli.providers.hetzner.update", return_value=0)
84
84
 
@@ -87,11 +87,45 @@ class TestShellVersionCheck:
87
87
  assert result.exit_code == 0
88
88
  mock_update.assert_called_once_with(name="webserver")
89
89
 
90
+ @pytest.mark.usefixtures("_patch_shell_deps")
91
+ def test_update_failure_prompts_before_connect(self, runner, mocker):
92
+ """When tools update fails, user is prompted to confirm connect."""
93
+ mocker.patch("remo_cli.core.version.get_current_version", return_value="0.9.0")
94
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.8.0", None))
95
+ # First confirm() = "Update?" → True; second = "Connect anyway?" → True
96
+ mock_confirm = mocker.patch(
97
+ "remo_cli.core.output.confirm", side_effect=[True, True]
98
+ )
99
+ mocker.patch("remo_cli.providers.hetzner.update", return_value=2)
100
+ mock_shell_connect = mocker.patch("remo_cli.core.ssh.shell_connect")
101
+
102
+ result = runner.invoke(shell, [])
103
+
104
+ assert result.exit_code == 0
105
+ assert mock_confirm.call_count == 2
106
+ assert "Connect anyway?" in mock_confirm.call_args_list[1][0][0]
107
+ mock_shell_connect.assert_called_once()
108
+
109
+ @pytest.mark.usefixtures("_patch_shell_deps")
110
+ def test_update_failure_decline_aborts(self, runner, mocker):
111
+ """When user declines after failed update, shell_connect is not called."""
112
+ mocker.patch("remo_cli.core.version.get_current_version", return_value="0.9.0")
113
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.8.0", None))
114
+ # First confirm() = "Update?" → True; second = "Connect anyway?" → False
115
+ mocker.patch("remo_cli.core.output.confirm", side_effect=[True, False])
116
+ mocker.patch("remo_cli.providers.hetzner.update", return_value=2)
117
+ mock_shell_connect = mocker.patch("remo_cli.core.ssh.shell_connect")
118
+
119
+ result = runner.invoke(shell, [])
120
+
121
+ assert result.exit_code == 2
122
+ mock_shell_connect.assert_not_called()
123
+
90
124
  @pytest.mark.usefixtures("_patch_shell_deps")
91
125
  def test_remote_ahead_shows_warning(self, runner, mocker):
92
126
  """When remote is ahead of local, a warning is shown."""
93
127
  mocker.patch("remo_cli.core.version.get_current_version", return_value="0.8.0")
94
- mocker.patch("remo_cli.core.ssh.check_remote_version", return_value="0.9.0")
128
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=("0.9.0", None))
95
129
  mock_confirm = mocker.patch("remo_cli.core.output.confirm")
96
130
 
97
131
  result = runner.invoke(shell, [])
@@ -105,7 +139,7 @@ class TestShellVersionCheck:
105
139
  def test_no_marker_prompts_update(self, runner, mocker):
106
140
  """When remote has no version marker, user is prompted to update."""
107
141
  mocker.patch("remo_cli.core.version.get_current_version", return_value="0.8.0")
108
- mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=None)
142
+ mocker.patch("remo_cli.core.ssh.check_remote_version", return_value=(None, None))
109
143
  mock_confirm = mocker.patch("remo_cli.core.output.confirm", return_value=False)
110
144
 
111
145
  result = runner.invoke(shell, [])
@@ -114,6 +148,25 @@ class TestShellVersionCheck:
114
148
  mock_confirm.assert_called_once()
115
149
  assert "no version info" in mock_confirm.call_args[0][0]
116
150
 
151
+ @pytest.mark.usefixtures("_patch_shell_deps")
152
+ def test_ssh_error_skips_update_prompt(self, runner, mocker):
153
+ """When SSH itself fails, the user is warned and not prompted to update."""
154
+ mocker.patch("remo_cli.core.version.get_current_version", return_value="0.8.0")
155
+ mocker.patch(
156
+ "remo_cli.core.ssh.check_remote_version",
157
+ return_value=(None, "Host key verification failed."),
158
+ )
159
+ mock_confirm = mocker.patch("remo_cli.core.output.confirm")
160
+ mock_shell_connect = mocker.patch("remo_cli.core.ssh.shell_connect")
161
+
162
+ result = runner.invoke(shell, [])
163
+
164
+ assert result.exit_code == 0
165
+ mock_confirm.assert_not_called()
166
+ assert "Could not check tools version" in result.output
167
+ assert "Host key verification failed." in result.output
168
+ mock_shell_connect.assert_called_once()
169
+
117
170
  @pytest.mark.usefixtures("_patch_shell_deps")
118
171
  def test_unknown_local_version_skips_check(self, runner, mocker):
119
172
  """When local version is unknown, skip the version check."""
@@ -395,51 +395,71 @@ class TestCheckRemoteVersion:
395
395
 
396
396
  @pytest.mark.usefixtures("_suppress_tz")
397
397
  def test_returns_version_on_success(self, hetzner_host, mocker):
398
- """Returns the version string when SSH succeeds."""
398
+ """Returns (version, None) when SSH succeeds and marker is present."""
399
399
  mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
400
400
  mock_run = mocker.patch(
401
401
  "subprocess.run",
402
- return_value=MagicMock(returncode=0, stdout="0.8.0rc1\n"),
402
+ return_value=MagicMock(returncode=0, stdout="0.8.0rc1\n", stderr=""),
403
403
  )
404
404
 
405
405
  from remo_cli.core.ssh import check_remote_version
406
406
 
407
- result = check_remote_version(hetzner_host)
408
- assert result == "0.8.0rc1"
407
+ assert check_remote_version(hetzner_host) == ("0.8.0rc1", None)
409
408
 
410
409
  # Verify SSH command includes cat ~/.remo-version
411
410
  call_args = mock_run.call_args[0][0]
412
411
  assert "cat ~/.remo-version 2>/dev/null" in call_args
412
+ # And uses BatchMode so it can't hang on host-key/auth prompts
413
+ assert "BatchMode=yes" in call_args
413
414
 
414
415
  @pytest.mark.usefixtures("_suppress_tz")
415
- def test_returns_none_on_missing_file(self, hetzner_host, mocker):
416
- """Returns None when the remote file doesn't exist (rc != 0)."""
416
+ def test_returns_no_marker_on_missing_file(self, hetzner_host, mocker):
417
+ """Returns (None, None) when the remote command failed (cat marker missing)."""
417
418
  mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
418
419
  mocker.patch(
419
420
  "subprocess.run",
420
- return_value=MagicMock(returncode=1, stdout=""),
421
+ return_value=MagicMock(returncode=1, stdout="", stderr=""),
421
422
  )
422
423
 
423
424
  from remo_cli.core.ssh import check_remote_version
424
425
 
425
- assert check_remote_version(hetzner_host) is None
426
+ assert check_remote_version(hetzner_host) == (None, None)
426
427
 
427
428
  @pytest.mark.usefixtures("_suppress_tz")
428
- def test_returns_none_on_empty_output(self, hetzner_host, mocker):
429
- """Returns None when SSH succeeds but output is empty."""
429
+ def test_returns_no_marker_on_empty_output(self, hetzner_host, mocker):
430
+ """Returns (None, None) when SSH succeeds but the marker is empty."""
430
431
  mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
431
432
  mocker.patch(
432
433
  "subprocess.run",
433
- return_value=MagicMock(returncode=0, stdout=""),
434
+ return_value=MagicMock(returncode=0, stdout="", stderr=""),
434
435
  )
435
436
 
436
437
  from remo_cli.core.ssh import check_remote_version
437
438
 
438
- assert check_remote_version(hetzner_host) is None
439
+ assert check_remote_version(hetzner_host) == (None, None)
439
440
 
440
441
  @pytest.mark.usefixtures("_suppress_tz")
441
- def test_returns_none_on_timeout(self, hetzner_host, mocker):
442
- """Returns None when SSH times out."""
442
+ def test_returns_error_on_ssh_failure(self, hetzner_host, mocker):
443
+ """Returns (None, stderr) when SSH itself fails (rc 255)."""
444
+ mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
445
+ mocker.patch(
446
+ "subprocess.run",
447
+ return_value=MagicMock(
448
+ returncode=255,
449
+ stdout="",
450
+ stderr="Host key verification failed.\n",
451
+ ),
452
+ )
453
+
454
+ from remo_cli.core.ssh import check_remote_version
455
+
456
+ version, err = check_remote_version(hetzner_host)
457
+ assert version is None
458
+ assert err == "Host key verification failed."
459
+
460
+ @pytest.mark.usefixtures("_suppress_tz")
461
+ def test_returns_error_on_timeout(self, hetzner_host, mocker):
462
+ """Returns (None, error) when SSH times out."""
443
463
  mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
444
464
  mocker.patch(
445
465
  "subprocess.run",
@@ -448,14 +468,20 @@ class TestCheckRemoteVersion:
448
468
 
449
469
  from remo_cli.core.ssh import check_remote_version
450
470
 
451
- assert check_remote_version(hetzner_host) is None
471
+ version, err = check_remote_version(hetzner_host)
472
+ assert version is None
473
+ assert err is not None
474
+ assert "timed out" in err.lower()
452
475
 
453
476
  @pytest.mark.usefixtures("_suppress_tz")
454
- def test_returns_none_on_os_error(self, hetzner_host, mocker):
455
- """Returns None when SSH binary is not found."""
477
+ def test_returns_error_on_os_error(self, hetzner_host, mocker):
478
+ """Returns (None, error) when the ssh binary can't be invoked."""
456
479
  mocker.patch("remo_cli.core.ssh.get_aws_region", return_value="us-west-2")
457
480
  mocker.patch("subprocess.run", side_effect=OSError("No ssh"))
458
481
 
459
482
  from remo_cli.core.ssh import check_remote_version
460
483
 
461
- assert check_remote_version(hetzner_host) is None
484
+ version, err = check_remote_version(hetzner_host)
485
+ assert version is None
486
+ assert err is not None
487
+ assert "No ssh" in err
@@ -771,7 +771,7 @@ wheels = [
771
771
 
772
772
  [[package]]
773
773
  name = "remo-cli"
774
- version = "1.0.2"
774
+ version = "2.0.0rc2"
775
775
  source = { editable = "." }
776
776
  dependencies = [
777
777
  { name = "ansible-core" },
File without changes