msdev 0.13.0__tar.gz → 0.14.1__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 (108) hide show
  1. {msdev-0.13.0 → msdev-0.14.1}/PKG-INFO +5 -3
  2. {msdev-0.13.0 → msdev-0.14.1}/README.md +4 -2
  3. {msdev-0.13.0 → msdev-0.14.1}/pyproject.toml +1 -1
  4. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/__init__.py +1 -1
  5. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/cli.py +18 -0
  6. msdev-0.14.1/src/msdev/core/daemon_version.py +26 -0
  7. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/node.py +38 -14
  8. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/sftp_transfer.py +45 -9
  9. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/transport.py +73 -34
  10. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdevd-linux-amd64 +0 -0
  11. msdev-0.14.1/src/msdev/helpers/msdevd-linux-amd64.sha256 +1 -0
  12. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdevd-linux-arm64 +0 -0
  13. msdev-0.14.1/src/msdev/helpers/msdevd-linux-arm64.sha256 +1 -0
  14. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/skills/msdev-cli/SKILL.md +8 -2
  15. {msdev-0.13.0 → msdev-0.14.1}/src/msdev.egg-info/PKG-INFO +5 -3
  16. {msdev-0.13.0 → msdev-0.14.1}/src/msdev.egg-info/SOURCES.txt +3 -0
  17. {msdev-0.13.0 → msdev-0.14.1}/tests/test_cli.py +21 -0
  18. {msdev-0.13.0 → msdev-0.14.1}/tests/test_cli_only_architecture.py +7 -2
  19. {msdev-0.13.0 → msdev-0.14.1}/tests/test_config_bundle.py +3 -3
  20. {msdev-0.13.0 → msdev-0.14.1}/tests/test_daemon.py +1 -1
  21. {msdev-0.13.0 → msdev-0.14.1}/tests/test_go_msdevd_scenario.py +1 -1
  22. {msdev-0.13.0 → msdev-0.14.1}/tests/test_msdev_cli_skill.py +2 -1
  23. msdev-0.14.1/tests/test_node_bootstrap_version_scenario.py +174 -0
  24. msdev-0.14.1/tests/test_sftp_transfer_scenario.py +239 -0
  25. msdev-0.14.1/tests/test_stale_daemon_autobootstrap_scenario.py +100 -0
  26. {msdev-0.13.0 → msdev-0.14.1}/tests/test_target_exec_services.py +68 -9
  27. {msdev-0.13.0 → msdev-0.14.1}/tests/test_transport.py +4 -0
  28. msdev-0.13.0/src/msdev/helpers/msdevd-linux-amd64.sha256 +0 -1
  29. msdev-0.13.0/src/msdev/helpers/msdevd-linux-arm64.sha256 +0 -1
  30. msdev-0.13.0/tests/test_sftp_transfer_scenario.py +0 -109
  31. {msdev-0.13.0 → msdev-0.14.1}/LICENSE +0 -0
  32. {msdev-0.13.0 → msdev-0.14.1}/setup.cfg +0 -0
  33. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/__init__.py +0 -0
  34. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/compat.py +0 -0
  35. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/config.py +0 -0
  36. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/config_bundle.py +0 -0
  37. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/container_helper.py +0 -0
  38. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/daemon_deploy.py +0 -0
  39. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/env_transfer.py +0 -0
  40. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/exec_argv.py +0 -0
  41. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/guides.py +0 -0
  42. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/helper_deploy.py +0 -0
  43. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/host_files.py +0 -0
  44. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/host_mutations.py +0 -0
  45. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/init_setup.py +0 -0
  46. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/inventory.py +0 -0
  47. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/limits.py +0 -0
  48. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/local_rpc.py +0 -0
  49. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/multiplex.py +0 -0
  50. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/request_client.py +0 -0
  51. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/resources.py +0 -0
  52. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/__init__.py +0 -0
  53. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/capabilities.py +0 -0
  54. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/context.py +0 -0
  55. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/environment.py +0 -0
  56. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/execution.py +0 -0
  57. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/model.py +0 -0
  58. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/npu.py +0 -0
  59. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/services/results.py +0 -0
  60. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/skill_install.py +0 -0
  61. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/ssh_auth.py +0 -0
  62. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/ssh_config.py +0 -0
  63. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/ssh_tunnel.py +0 -0
  64. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/stream_limits.py +0 -0
  65. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/stream_server.py +0 -0
  66. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/stream_transport.py +0 -0
  67. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/core/streamproto.py +0 -0
  68. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/daemon.py +0 -0
  69. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdev-file-helper-linux-amd64 +0 -0
  70. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdev-file-helper-linux-amd64.sha256 +0 -0
  71. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdev-file-helper-linux-arm64 +0 -0
  72. {msdev-0.13.0 → msdev-0.14.1}/src/msdev/helpers/msdev-file-helper-linux-arm64.sha256 +0 -0
  73. {msdev-0.13.0 → msdev-0.14.1}/src/msdev.egg-info/dependency_links.txt +0 -0
  74. {msdev-0.13.0 → msdev-0.14.1}/src/msdev.egg-info/entry_points.txt +0 -0
  75. {msdev-0.13.0 → msdev-0.14.1}/src/msdev.egg-info/top_level.txt +0 -0
  76. {msdev-0.13.0 → msdev-0.14.1}/tests/test_compat.py +0 -0
  77. {msdev-0.13.0 → msdev-0.14.1}/tests/test_config.py +0 -0
  78. {msdev-0.13.0 → msdev-0.14.1}/tests/test_connect_env_scenario.py +0 -0
  79. {msdev-0.13.0 → msdev-0.14.1}/tests/test_container_helper.py +0 -0
  80. {msdev-0.13.0 → msdev-0.14.1}/tests/test_daemon_container_stream.py +0 -0
  81. {msdev-0.13.0 → msdev-0.14.1}/tests/test_daemon_deploy.py +0 -0
  82. {msdev-0.13.0 → msdev-0.14.1}/tests/test_docker_stream_dispatch.py +0 -0
  83. {msdev-0.13.0 → msdev-0.14.1}/tests/test_env_put_get_scenario.py +0 -0
  84. {msdev-0.13.0 → msdev-0.14.1}/tests/test_env_read_write_scenario.py +0 -0
  85. {msdev-0.13.0 → msdev-0.14.1}/tests/test_env_transfer.py +0 -0
  86. {msdev-0.13.0 → msdev-0.14.1}/tests/test_exec_argv.py +0 -0
  87. {msdev-0.13.0 → msdev-0.14.1}/tests/test_exec_command_scenario.py +0 -0
  88. {msdev-0.13.0 → msdev-0.14.1}/tests/test_exec_default_timeout_scenario.py +0 -0
  89. {msdev-0.13.0 → msdev-0.14.1}/tests/test_helper_deploy.py +0 -0
  90. {msdev-0.13.0 → msdev-0.14.1}/tests/test_init_skill.py +0 -0
  91. {msdev-0.13.0 → msdev-0.14.1}/tests/test_inventory.py +0 -0
  92. {msdev-0.13.0 → msdev-0.14.1}/tests/test_model_cli.py +0 -0
  93. {msdev-0.13.0 → msdev-0.14.1}/tests/test_model_services.py +0 -0
  94. {msdev-0.13.0 → msdev-0.14.1}/tests/test_multiplex.py +0 -0
  95. {msdev-0.13.0 → msdev-0.14.1}/tests/test_node_connect_provision_scenario.py +0 -0
  96. {msdev-0.13.0 → msdev-0.14.1}/tests/test_node_connect_save_password_scenario.py +0 -0
  97. {msdev-0.13.0 → msdev-0.14.1}/tests/test_node_env_config.py +0 -0
  98. {msdev-0.13.0 → msdev-0.14.1}/tests/test_node_env_services.py +0 -0
  99. {msdev-0.13.0 → msdev-0.14.1}/tests/test_node_sync_ssh_scenario.py +0 -0
  100. {msdev-0.13.0 → msdev-0.14.1}/tests/test_request_client.py +0 -0
  101. {msdev-0.13.0 → msdev-0.14.1}/tests/test_ssh_auth.py +0 -0
  102. {msdev-0.13.0 → msdev-0.14.1}/tests/test_ssh_config.py +0 -0
  103. {msdev-0.13.0 → msdev-0.14.1}/tests/test_ssh_tunnel.py +0 -0
  104. {msdev-0.13.0 → msdev-0.14.1}/tests/test_stream_server.py +0 -0
  105. {msdev-0.13.0 → msdev-0.14.1}/tests/test_stream_transport.py +0 -0
  106. {msdev-0.13.0 → msdev-0.14.1}/tests/test_streamproto.py +0 -0
  107. {msdev-0.13.0 → msdev-0.14.1}/tests/test_target_guides.py +0 -0
  108. {msdev-0.13.0 → msdev-0.14.1}/tests/test_windows_local_scenario.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msdev
3
- Version: 0.13.0
3
+ Version: 0.14.1
4
4
  Summary: Portable nodes and execution environments for msModelSlim development
5
5
  Author: msModelSlim Agent Contributors
6
6
  License-Expression: MulanPSL-2.0
@@ -104,6 +104,7 @@ key, and deploys `msdevd`:
104
104
  ```bash
105
105
  msdev node connect dev-122 --hostname 80.48.33.133 --user root --password PASSWORD
106
106
  msdev node connect dev-122
107
+ msdev node bootstrap dev-122
107
108
  msdev node sync-ssh
108
109
  msdev node list --ssh
109
110
  msdev node list
@@ -116,8 +117,9 @@ config. `node sync-ssh` registers Host aliases that were added manually.
116
117
 
117
118
  Connect atomically creates a same-named host Env on first use and uploads a
118
119
  **static Go msdevd** binary matching the node architecture
119
- (`linux-amd64` / `linux-arm64`) when the agent is missing or stale. No Python
120
- is required on the node.
120
+ (`linux-amd64` / `linux-arm64`) when the agent is missing or when the remote
121
+ `ping` version does not match the local CLI. Use **`msdev node bootstrap NODE`**
122
+ to force a redeploy after upgrading msdev. No Python is required on the node.
121
123
 
122
124
  Build the static daemons before release or local testing:
123
125
 
@@ -79,6 +79,7 @@ key, and deploys `msdevd`:
79
79
  ```bash
80
80
  msdev node connect dev-122 --hostname 80.48.33.133 --user root --password PASSWORD
81
81
  msdev node connect dev-122
82
+ msdev node bootstrap dev-122
82
83
  msdev node sync-ssh
83
84
  msdev node list --ssh
84
85
  msdev node list
@@ -91,8 +92,9 @@ config. `node sync-ssh` registers Host aliases that were added manually.
91
92
 
92
93
  Connect atomically creates a same-named host Env on first use and uploads a
93
94
  **static Go msdevd** binary matching the node architecture
94
- (`linux-amd64` / `linux-arm64`) when the agent is missing or stale. No Python
95
- is required on the node.
95
+ (`linux-amd64` / `linux-arm64`) when the agent is missing or when the remote
96
+ `ping` version does not match the local CLI. Use **`msdev node bootstrap NODE`**
97
+ to force a redeploy after upgrading msdev. No Python is required on the node.
96
98
 
97
99
  Build the static daemons before release or local testing:
98
100
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "msdev"
7
- version = "0.13.0"
7
+ version = "0.14.1"
8
8
  description = "Portable nodes and execution environments for msModelSlim development"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Portable node and execution-environment tooling for msModelSlim."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.14.1"
@@ -240,6 +240,10 @@ def command_node(
240
240
  print(f"node: {args.name}")
241
241
  _print_json(service.status(args.name))
242
242
  return 0
243
+ if action == "bootstrap":
244
+ result = service.bootstrap(args.name, package_path=args.package)
245
+ _print_json(result)
246
+ return 0
243
247
  if action in {"connect", "disconnect"}:
244
248
  provision = None
245
249
  if action == "connect" and args.name and not getattr(args, "all", False):
@@ -613,6 +617,20 @@ def build_parser() -> argparse.ArgumentParser:
613
617
  )
614
618
  node_status = node_sub.add_parser("status")
615
619
  node_status.add_argument("name")
620
+ node_bootstrap = node_sub.add_parser(
621
+ "bootstrap",
622
+ help=(
623
+ "force-redeploy msdevd on the node "
624
+ "(use after local upgrade when remote version mismatches)"
625
+ ),
626
+ )
627
+ node_bootstrap.add_argument("name")
628
+ node_bootstrap.add_argument(
629
+ "--package",
630
+ type=Path,
631
+ default=None,
632
+ help="optional source checkout for source-layout bootstrap",
633
+ )
616
634
  node_connect = node_sub.add_parser(
617
635
  "connect",
618
636
  help=(
@@ -0,0 +1,26 @@
1
+ """Helpers for comparing local CLI and remote msdevd versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .. import __version__
8
+
9
+
10
+ def remote_daemon_version_matches(
11
+ ping: Any,
12
+ *,
13
+ expected: str | None = None,
14
+ ) -> bool:
15
+ """Return True when ping reports the same msdevd version as the local CLI."""
16
+ target = expected if expected is not None else __version__
17
+ if not isinstance(ping, dict):
18
+ return False
19
+ remote = ping.get("version")
20
+ return isinstance(remote, str) and remote == target
21
+
22
+
23
+ def is_incompatible_daemon_rpc_error(exc: BaseException) -> bool:
24
+ """True when the remote daemon rejected a method the current CLI requires."""
25
+ text = str(exc).lower()
26
+ return "unknown rpc method" in text
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import Any
8
8
 
9
9
  from ..resources import Node
10
+ from ..daemon_version import remote_daemon_version_matches
10
11
  from ..ssh_config import (
11
12
  SshHostSpec,
12
13
  list_ssh_config_hosts,
@@ -220,29 +221,52 @@ class NodeService:
220
221
  name: str,
221
222
  package_path: Path | None = None,
222
223
  ) -> dict[str, Any]:
224
+ """Force-redeploy msdevd on the node (ignores current ping/version)."""
223
225
  node = self.context.node_store.get(self._required(name, "node name"))
224
- return self._ensure_agent_ready(node, package_path=package_path)
226
+ return self._ensure_agent_ready(
227
+ node,
228
+ package_path=package_path,
229
+ force=True,
230
+ )
225
231
 
226
232
  def _ensure_agent_ready(
227
233
  self,
228
234
  node: Node,
229
235
  *,
230
236
  package_path: Path | None = None,
237
+ force: bool = False,
231
238
  ) -> dict[str, Any]:
232
239
  transport = self.context.create_transport(node)
233
- try:
234
- ping = transport.call("ping")
235
- if isinstance(ping, dict):
236
- return {"ready": True, "bootstrapped": False, "ping": ping}
237
- return {"ready": True, "bootstrapped": False}
238
- except DaemonUnavailableError:
239
- if not node.auto_bootstrap:
240
- raise RpcError(
241
- f"msdevd is not ready on node {node.name!r}; "
242
- "enable auto-bootstrap on the node or fix the host agent"
243
- ) from None
244
- result = self.context.bootstrap(node, package_path)
245
- return {"ready": True, "bootstrapped": True, **result}
240
+ reason = "forced" if force else None
241
+ if not force:
242
+ try:
243
+ ping = transport.call("ping")
244
+ if remote_daemon_version_matches(ping):
245
+ return {
246
+ "ready": True,
247
+ "bootstrapped": False,
248
+ "reason": "version_match",
249
+ "ping": ping if isinstance(ping, dict) else None,
250
+ }
251
+ if isinstance(ping, dict):
252
+ reason = "version_mismatch"
253
+ else:
254
+ reason = "unavailable"
255
+ except DaemonUnavailableError:
256
+ reason = "unavailable"
257
+ if not node.auto_bootstrap:
258
+ raise RpcError(
259
+ f"msdevd is not ready on node {node.name!r}; "
260
+ "enable auto-bootstrap on the node, or run "
261
+ f"`msdev node bootstrap {node.name}`"
262
+ ) from None
263
+ result = self.context.bootstrap(node, package_path)
264
+ return {
265
+ "ready": True,
266
+ "bootstrapped": True,
267
+ "reason": reason or "unavailable",
268
+ **result,
269
+ }
246
270
 
247
271
  def _connection_nodes(
248
272
  self,
@@ -3,10 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import subprocess
6
- from pathlib import Path
6
+ from pathlib import Path, PureWindowsPath
7
7
  from typing import Any
8
8
 
9
- from .compat import popen_kwargs
9
+ from .compat import IS_WINDOWS, popen_kwargs
10
+
11
+ # Characters that need quoting in OpenSSH sftp batch arguments.
12
+ _SFTP_SPECIAL = frozenset(' \t"\'*?[]{}\\')
10
13
 
11
14
 
12
15
  def _run_kw(env: dict[str, str] | None = None) -> dict[str, Any]:
@@ -16,6 +19,35 @@ def _run_kw(env: dict[str, str] | None = None) -> dict[str, Any]:
16
19
  return kwargs
17
20
 
18
21
 
22
+ def format_sftp_local_path(path: Path) -> str:
23
+ """Format a local path for an OpenSSH ``sftp -b`` argument.
24
+
25
+ Windows backslashes are treated as escapes by sftp batch mode, so convert
26
+ drive paths to forward slashes (``C:/Users/...``). Quote when spaces or
27
+ other special characters remain.
28
+ """
29
+ resolved = path.expanduser().resolve()
30
+ text = str(resolved)
31
+ if IS_WINDOWS or "\\" in text:
32
+ # PureWindowsPath.as_posix keeps the drive letter and uses '/'.
33
+ text = PureWindowsPath(text).as_posix()
34
+ return _quote_sftp_arg(text)
35
+
36
+
37
+ def format_sftp_remote_path(path: str) -> str:
38
+ """Format a remote POSIX path for an OpenSSH ``sftp -b`` argument."""
39
+ return _quote_sftp_arg(path)
40
+
41
+
42
+ def _quote_sftp_arg(text: str) -> str:
43
+ if not text:
44
+ return '""'
45
+ if any(ch in _SFTP_SPECIAL for ch in text):
46
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
47
+ return f'"{escaped}"'
48
+ return text
49
+
50
+
19
51
  def run_sftp_batch(
20
52
  sftp_argv: list[str],
21
53
  ssh_host: str,
@@ -60,8 +92,9 @@ def sftp_put_file(
60
92
  timeout: float | None,
61
93
  env: dict[str, str] | None = None,
62
94
  ) -> None:
63
- local = str(local_path.resolve())
64
- batch = f"put {local} {remote_path}\n"
95
+ local = format_sftp_local_path(local_path)
96
+ remote = format_sftp_remote_path(remote_path)
97
+ batch = f"put {local} {remote}\n"
65
98
  run_sftp_batch(
66
99
  sftp_argv,
67
100
  ssh_host,
@@ -82,8 +115,9 @@ def sftp_get_file(
82
115
  timeout: float | None,
83
116
  env: dict[str, str] | None = None,
84
117
  ) -> None:
85
- local = str(local_path.expanduser().resolve())
86
- batch = f"get {remote_path} {local}\n"
118
+ local = format_sftp_local_path(local_path)
119
+ remote = format_sftp_remote_path(remote_path)
120
+ batch = f"get {remote} {local}\n"
87
121
  run_sftp_batch(
88
122
  sftp_argv,
89
123
  ssh_host,
@@ -105,10 +139,12 @@ def sftp_put_rename(
105
139
  timeout: float | None,
106
140
  env: dict[str, str] | None = None,
107
141
  ) -> None:
108
- local = str(local_path.resolve())
142
+ local = format_sftp_local_path(local_path)
143
+ staging = format_sftp_remote_path(staging_path)
144
+ destination = format_sftp_remote_path(destination_path)
109
145
  batch = (
110
- f"put {local} {staging_path}\n"
111
- f"rename {staging_path} {destination_path}\n"
146
+ f"put {local} {staging}\n"
147
+ f"rename {staging} {destination}\n"
112
148
  )
113
149
  run_sftp_batch(
114
150
  sftp_argv,
@@ -19,6 +19,7 @@ import uuid
19
19
  from pathlib import Path
20
20
  from typing import Any, Callable, Protocol
21
21
 
22
+ from .. import __version__
22
23
  from .compat import (
23
24
  kill_popen_tree,
24
25
  popen_kwargs,
@@ -50,6 +51,10 @@ from .env_transfer import (
50
51
  transfer_timeout_seconds,
51
52
  )
52
53
  from .daemon_deploy import select_daemon_binary
54
+ from .daemon_version import (
55
+ is_incompatible_daemon_rpc_error,
56
+ remote_daemon_version_matches,
57
+ )
53
58
  from .local_rpc import connect_local_rpc
54
59
  from .resources import Environment, Node
55
60
  from .sftp_transfer import sftp_put_file
@@ -649,6 +654,53 @@ class SshRpcTransport:
649
654
  self.control_persist = os.environ.get("MSDEV_SSH_CONTROL_PERSIST", "yes")
650
655
  self._control_master = ssh_control_master_enabled()
651
656
  self._tunnel = LocalForwardTunnel(self)
657
+ self._compatible_daemon = False
658
+
659
+ def _bootstrap_remote_daemon(self, reason: str) -> None:
660
+ print(
661
+ f"[msdev] node {self.node.name!r} msdevd needs update "
662
+ f"({reason}); bootstrapping user daemon...",
663
+ file=sys.stderr,
664
+ )
665
+ try:
666
+ bootstrap_node(self.node)
667
+ except Exception as bootstrap_exc:
668
+ raise RpcError(
669
+ f"automatic bootstrap failed for node {self.node.name!r}: "
670
+ f"{bootstrap_exc}. Retry with `msdev node bootstrap "
671
+ f"{self.node.name}` for detailed diagnostics."
672
+ ) from bootstrap_exc
673
+ self._compatible_daemon = True
674
+ print(
675
+ f"[msdev] msdevd is ready on node {self.node.name!r}; "
676
+ "retrying request.",
677
+ file=sys.stderr,
678
+ )
679
+
680
+ def _ensure_compatible_daemon(self) -> None:
681
+ """Redeploy when remote ping version does not match this CLI."""
682
+ if self._compatible_daemon:
683
+ return
684
+ try:
685
+ ping = self._call_once("ping")
686
+ except DaemonUnavailableError:
687
+ if not self.auto_bootstrap:
688
+ raise
689
+ self._bootstrap_remote_daemon("unavailable")
690
+ ping = self._call_once("ping")
691
+ if remote_daemon_version_matches(ping):
692
+ self._compatible_daemon = True
693
+ return
694
+ remote = ping.get("version") if isinstance(ping, dict) else None
695
+ if not self.auto_bootstrap:
696
+ raise RpcError(
697
+ f"msdevd version mismatch on node {self.node.name!r}: "
698
+ f"remote={remote!r} local={__version__!r}; "
699
+ f"run `msdev node bootstrap {self.node.name}`"
700
+ )
701
+ self._bootstrap_remote_daemon(
702
+ f"version mismatch remote={remote!r} local={__version__!r}"
703
+ )
652
704
 
653
705
  def _rpc_target_label(self) -> str:
654
706
  if self.environment is not None:
@@ -1143,56 +1195,43 @@ esac
1143
1195
  return values
1144
1196
 
1145
1197
  def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
1198
+ if method != "ping":
1199
+ self._ensure_compatible_daemon()
1146
1200
  try:
1147
1201
  return self._call_once(method, params)
1148
- except DaemonUnavailableError as exc:
1202
+ except DaemonUnavailableError:
1149
1203
  if not self.auto_bootstrap:
1150
1204
  raise
1151
- print(
1152
- f"[msdev] node {self.node.name!r} is reachable but msdevd "
1153
- "is unavailable; bootstrapping user daemon...",
1154
- file=sys.stderr,
1155
- )
1156
- try:
1157
- bootstrap_node(self.node)
1158
- except Exception as bootstrap_exc:
1159
- raise RpcError(
1160
- f"automatic bootstrap failed for node {self.node.name!r}: "
1161
- f"{bootstrap_exc}. Retry with `msdev node bootstrap "
1162
- f"{self.node.name}` for detailed diagnostics."
1163
- ) from bootstrap_exc
1164
- print(
1165
- f"[msdev] msdevd is ready on node {self.node.name!r}; "
1166
- "retrying request.",
1167
- file=sys.stderr,
1168
- )
1205
+ self._bootstrap_remote_daemon("unavailable")
1169
1206
  return self._call_once(method, params)
1207
+ except RpcError as exc:
1208
+ if (
1209
+ method != "ping"
1210
+ and self.auto_bootstrap
1211
+ and is_incompatible_daemon_rpc_error(exc)
1212
+ ):
1213
+ self._bootstrap_remote_daemon(str(exc))
1214
+ return self._call_once(method, params)
1215
+ raise
1170
1216
 
1171
1217
  def stream_exec(
1172
1218
  self,
1173
1219
  params: dict[str, Any],
1174
1220
  on_output: Callable[[str, bytes], None],
1175
1221
  ) -> dict[str, Any]:
1222
+ self._ensure_compatible_daemon()
1176
1223
  try:
1177
1224
  return self._stream_exec_once(params, on_output)
1178
- except DaemonUnavailableError as exc:
1225
+ except DaemonUnavailableError:
1179
1226
  if not self.auto_bootstrap:
1180
1227
  raise
1181
- print(
1182
- f"[msdev] node {self.node.name!r} is reachable but msdevd "
1183
- "is unavailable; bootstrapping user daemon...",
1184
- file=sys.stderr,
1185
- )
1186
- try:
1187
- bootstrap_node(self.node)
1188
- except Exception as bootstrap_exc:
1189
- raise RpcError(
1190
- f"automatic bootstrap failed for node "
1191
- f"{self.node.name!r}: {bootstrap_exc}. Retry with "
1192
- f"`msdev node connect {self.node.name}` for "
1193
- "detailed diagnostics."
1194
- ) from bootstrap_exc
1228
+ self._bootstrap_remote_daemon("unavailable")
1195
1229
  return self._stream_exec_once(params, on_output)
1230
+ except RpcError as exc:
1231
+ if self.auto_bootstrap and is_incompatible_daemon_rpc_error(exc):
1232
+ self._bootstrap_remote_daemon(str(exc))
1233
+ return self._stream_exec_once(params, on_output)
1234
+ raise
1196
1235
 
1197
1236
  def _stream_exec_once(
1198
1237
  self,
@@ -0,0 +1 @@
1
+ e397d2c3621ad047df7bc181b2fa7637694fb533fa559a39e3495130d3fbf5fa msdevd-linux-amd64
@@ -0,0 +1 @@
1
+ 86aa45ed2e836e359cc65fe341c39aa84c61bde4f522f086fdecac7026695627 msdevd-linux-arm64
@@ -56,6 +56,7 @@ msdev node sync-ssh
56
56
  msdev node list --ssh
57
57
  msdev node connect NODE
58
58
  msdev node connect NODE --identity FILE
59
+ msdev node bootstrap NODE
59
60
  msdev node status NODE
60
61
  msdev node disconnect NODE
61
62
  msdev node remove NODE
@@ -96,7 +97,9 @@ registers existing Host blocks.
96
97
 
97
98
  `msdev node connect` opens SSH persistence, saves `--password` into SSH config,
98
99
  installs the local public key when a password is available, and deploys/starts
99
- `msdevd` when needed. Users do not run a separate add or bootstrap step.
100
+ `msdevd` when missing or when the remote `ping` version does not match the
101
+ local CLI. Use `msdev node bootstrap NODE` to force a redeploy after upgrading
102
+ msdev (version mismatch is not always visible as a hard failure).
100
103
 
101
104
  `msdev env open` runs the env `setup` script once and establishes the shared
102
105
  session. `msdev exec` opens the env automatically when needed.
@@ -178,5 +181,8 @@ captures one result. Local cancellation terminates the remote process group.
178
181
 
179
182
  - Ask before destructive or externally visible changes unless authorized.
180
183
  - Track temporary resources and remove only those created by the task.
181
- - If `msdevd` is missing or stale, run `msdev node connect NODE`, then retry.
184
+ - If `msdevd` is missing, version-mismatched, or returns
185
+ `unknown RPC method` (for example `env.open`), run
186
+ `msdev node bootstrap NODE` (or retry the command — exec/connect now
187
+ auto-redeploy when `auto_bootstrap` is on), then retry.
182
188
  - If connect fails, report the error; do not bypass msdev with another transport.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msdev
3
- Version: 0.13.0
3
+ Version: 0.14.1
4
4
  Summary: Portable nodes and execution environments for msModelSlim development
5
5
  Author: msModelSlim Agent Contributors
6
6
  License-Expression: MulanPSL-2.0
@@ -104,6 +104,7 @@ key, and deploys `msdevd`:
104
104
  ```bash
105
105
  msdev node connect dev-122 --hostname 80.48.33.133 --user root --password PASSWORD
106
106
  msdev node connect dev-122
107
+ msdev node bootstrap dev-122
107
108
  msdev node sync-ssh
108
109
  msdev node list --ssh
109
110
  msdev node list
@@ -116,8 +117,9 @@ config. `node sync-ssh` registers Host aliases that were added manually.
116
117
 
117
118
  Connect atomically creates a same-named host Env on first use and uploads a
118
119
  **static Go msdevd** binary matching the node architecture
119
- (`linux-amd64` / `linux-arm64`) when the agent is missing or stale. No Python
120
- is required on the node.
120
+ (`linux-amd64` / `linux-arm64`) when the agent is missing or when the remote
121
+ `ping` version does not match the local CLI. Use **`msdev node bootstrap NODE`**
122
+ to force a redeploy after upgrading msdev. No Python is required on the node.
121
123
 
122
124
  Build the static daemons before release or local testing:
123
125
 
@@ -15,6 +15,7 @@ src/msdev/core/config.py
15
15
  src/msdev/core/config_bundle.py
16
16
  src/msdev/core/container_helper.py
17
17
  src/msdev/core/daemon_deploy.py
18
+ src/msdev/core/daemon_version.py
18
19
  src/msdev/core/env_transfer.py
19
20
  src/msdev/core/exec_argv.py
20
21
  src/msdev/core/guides.py
@@ -81,6 +82,7 @@ tests/test_model_cli.py
81
82
  tests/test_model_services.py
82
83
  tests/test_msdev_cli_skill.py
83
84
  tests/test_multiplex.py
85
+ tests/test_node_bootstrap_version_scenario.py
84
86
  tests/test_node_connect_provision_scenario.py
85
87
  tests/test_node_connect_save_password_scenario.py
86
88
  tests/test_node_env_config.py
@@ -91,6 +93,7 @@ tests/test_sftp_transfer_scenario.py
91
93
  tests/test_ssh_auth.py
92
94
  tests/test_ssh_config.py
93
95
  tests/test_ssh_tunnel.py
96
+ tests/test_stale_daemon_autobootstrap_scenario.py
94
97
  tests/test_stream_server.py
95
98
  tests/test_stream_transport.py
96
99
  tests/test_streamproto.py
@@ -307,6 +307,27 @@ class CliParserTest(unittest.TestCase):
307
307
  {"node_id": "node-1"},
308
308
  'node: worker\n{\n "node_id": "node-1"\n}\n',
309
309
  ),
310
+ (
311
+ ["node", "bootstrap", "worker"],
312
+ "bootstrap",
313
+ {
314
+ "ready": True,
315
+ "bootstrapped": True,
316
+ "reason": "forced",
317
+ "daemon": {"ok": True, "version": "0.14.1"},
318
+ },
319
+ (
320
+ "{\n"
321
+ ' "bootstrapped": true,\n'
322
+ ' "daemon": {\n'
323
+ ' "ok": true,\n'
324
+ ' "version": "0.14.1"\n'
325
+ " },\n"
326
+ ' "ready": true,\n'
327
+ ' "reason": "forced"\n'
328
+ "}\n"
329
+ ),
330
+ ),
310
331
  (
311
332
  ["node", "connect", "worker"],
312
333
  "connect",
@@ -30,8 +30,13 @@ class CliOnlyArchitectureTests(unittest.TestCase):
30
30
  parser.parse_args(["npu", "list", "--node", "worker"])
31
31
  with self.assertRaises(SystemExit):
32
32
  parser.parse_args(["model", "list", "--node", "worker"])
33
- with self.assertRaises(SystemExit):
34
- parser.parse_args(["node", "bootstrap", "worker"])
33
+
34
+ def test_node_bootstrap_is_present(self):
35
+ args = build_parser().parse_args(["node", "bootstrap", "worker"])
36
+ self.assertEqual(args.command, "node")
37
+ self.assertEqual(args.node_command, "bootstrap")
38
+ self.assertEqual(args.name, "worker")
39
+ self.assertIsNone(args.package)
35
40
 
36
41
  def test_skill_command_is_absent(self):
37
42
  parser = build_parser()
@@ -56,7 +56,7 @@ class ConfigBundleTests(unittest.TestCase):
56
56
  summary = export_bundle(
57
57
  export_dir,
58
58
  config_path=resources,
59
- msdev_version="0.13.0",
59
+ msdev_version="0.14.1",
60
60
  )
61
61
  self.assertFalse(summary["archive"])
62
62
  self.assertTrue((export_dir / BUNDLE_MANIFEST).is_file())
@@ -100,7 +100,7 @@ class ConfigBundleTests(unittest.TestCase):
100
100
  )
101
101
 
102
102
  archive = root / "msdev-config.tar.gz"
103
- export_bundle(archive, config_path=resources, msdev_version="0.13.0")
103
+ export_bundle(archive, config_path=resources, msdev_version="0.14.1")
104
104
  self.assertTrue(archive.is_file())
105
105
 
106
106
  target_resources = root / "imported" / "resources.json"
@@ -126,7 +126,7 @@ class ConfigBundleTests(unittest.TestCase):
126
126
  )
127
127
 
128
128
  bundle_dir = root / "bundle"
129
- export_bundle(bundle_dir, config_path=source_resources, msdev_version="0.13.0")
129
+ export_bundle(bundle_dir, config_path=source_resources, msdev_version="0.14.1")
130
130
  result = import_bundle(bundle_dir, config_path=target_resources)
131
131
  self.assertEqual(result["imported_nodes"], 0)
132
132
  self.assertIn("node:worker", result["skipped"])
@@ -683,7 +683,7 @@ class RpcDispatcherTest(unittest.TestCase):
683
683
  started = time.monotonic()
684
684
  first = json.loads(proxy.stdout.readline())
685
685
  self.assertEqual(first["event"], "stdout")
686
- self.assertLess(time.monotonic() - started, 0.3)
686
+ self.assertLess(time.monotonic() - started, 1.0)
687
687
  events = [first]
688
688
  while events[-1]["event"] != "result":
689
689
  events.append(json.loads(proxy.stdout.readline()))
@@ -104,7 +104,7 @@ class GoMsdevdScenarioTests(unittest.TestCase):
104
104
  self.assertNotIn("error", response)
105
105
  result = response["result"]
106
106
  self.assertTrue(result["ok"])
107
- self.assertEqual(result["version"], "0.13.0")
107
+ self.assertEqual(result["version"], "0.14.1")
108
108
  self.assertIn("node_id", result)
109
109
 
110
110
  def test_exec(self):
@@ -45,9 +45,9 @@ class MsdevCliSkillTests(unittest.TestCase):
45
45
  self.assertIn("msdev env open", content)
46
46
  self.assertIn("msdev exec ENV", content)
47
47
  self.assertIn("msdev node connect", content)
48
+ self.assertIn("msdev node bootstrap", content)
48
49
  self.assertIn("msdev node guide NODE", content)
49
50
  self.assertIn("msdev env shell ENV", content)
50
- self.assertNotIn("msdev node bootstrap", content)
51
51
  self.assertNotIn("msdev model list", content)
52
52
  self.assertNotIn("msdev npu list", content)
53
53
  self.assertIn("Do not use MCP", content)
@@ -61,6 +61,7 @@ class MsdevCliSkillTests(unittest.TestCase):
61
61
  required_commands = (
62
62
  "msdev node connect",
63
63
  "msdev node connect NODE --hostname",
64
+ "msdev node bootstrap NODE",
64
65
  "msdev node remove",
65
66
  "msdev env add",
66
67
  "msdev env open",