msdev 0.13.0__tar.gz → 0.14.0__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 (106) hide show
  1. {msdev-0.13.0 → msdev-0.14.0}/PKG-INFO +5 -3
  2. {msdev-0.13.0 → msdev-0.14.0}/README.md +4 -2
  3. {msdev-0.13.0 → msdev-0.14.0}/pyproject.toml +1 -1
  4. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/__init__.py +1 -1
  5. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/cli.py +18 -0
  6. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/node.py +51 -14
  7. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/sftp_transfer.py +45 -9
  8. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdevd-linux-amd64 +0 -0
  9. msdev-0.14.0/src/msdev/helpers/msdevd-linux-amd64.sha256 +1 -0
  10. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdevd-linux-arm64 +0 -0
  11. msdev-0.14.0/src/msdev/helpers/msdevd-linux-arm64.sha256 +1 -0
  12. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/skills/msdev-cli/SKILL.md +6 -2
  13. {msdev-0.13.0 → msdev-0.14.0}/src/msdev.egg-info/PKG-INFO +5 -3
  14. {msdev-0.13.0 → msdev-0.14.0}/src/msdev.egg-info/SOURCES.txt +1 -0
  15. {msdev-0.13.0 → msdev-0.14.0}/tests/test_cli.py +21 -0
  16. {msdev-0.13.0 → msdev-0.14.0}/tests/test_cli_only_architecture.py +7 -2
  17. {msdev-0.13.0 → msdev-0.14.0}/tests/test_config_bundle.py +3 -3
  18. {msdev-0.13.0 → msdev-0.14.0}/tests/test_daemon.py +1 -1
  19. {msdev-0.13.0 → msdev-0.14.0}/tests/test_go_msdevd_scenario.py +1 -1
  20. {msdev-0.13.0 → msdev-0.14.0}/tests/test_msdev_cli_skill.py +2 -1
  21. msdev-0.14.0/tests/test_node_bootstrap_version_scenario.py +173 -0
  22. msdev-0.14.0/tests/test_sftp_transfer_scenario.py +239 -0
  23. {msdev-0.13.0 → msdev-0.14.0}/tests/test_target_exec_services.py +68 -9
  24. msdev-0.13.0/src/msdev/helpers/msdevd-linux-amd64.sha256 +0 -1
  25. msdev-0.13.0/src/msdev/helpers/msdevd-linux-arm64.sha256 +0 -1
  26. msdev-0.13.0/tests/test_sftp_transfer_scenario.py +0 -109
  27. {msdev-0.13.0 → msdev-0.14.0}/LICENSE +0 -0
  28. {msdev-0.13.0 → msdev-0.14.0}/setup.cfg +0 -0
  29. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/__init__.py +0 -0
  30. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/compat.py +0 -0
  31. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/config.py +0 -0
  32. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/config_bundle.py +0 -0
  33. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/container_helper.py +0 -0
  34. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/daemon_deploy.py +0 -0
  35. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/env_transfer.py +0 -0
  36. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/exec_argv.py +0 -0
  37. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/guides.py +0 -0
  38. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/helper_deploy.py +0 -0
  39. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/host_files.py +0 -0
  40. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/host_mutations.py +0 -0
  41. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/init_setup.py +0 -0
  42. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/inventory.py +0 -0
  43. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/limits.py +0 -0
  44. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/local_rpc.py +0 -0
  45. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/multiplex.py +0 -0
  46. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/request_client.py +0 -0
  47. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/resources.py +0 -0
  48. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/__init__.py +0 -0
  49. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/capabilities.py +0 -0
  50. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/context.py +0 -0
  51. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/environment.py +0 -0
  52. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/execution.py +0 -0
  53. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/model.py +0 -0
  54. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/npu.py +0 -0
  55. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/services/results.py +0 -0
  56. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/skill_install.py +0 -0
  57. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/ssh_auth.py +0 -0
  58. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/ssh_config.py +0 -0
  59. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/ssh_tunnel.py +0 -0
  60. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/stream_limits.py +0 -0
  61. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/stream_server.py +0 -0
  62. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/stream_transport.py +0 -0
  63. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/streamproto.py +0 -0
  64. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/core/transport.py +0 -0
  65. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/daemon.py +0 -0
  66. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdev-file-helper-linux-amd64 +0 -0
  67. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdev-file-helper-linux-amd64.sha256 +0 -0
  68. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdev-file-helper-linux-arm64 +0 -0
  69. {msdev-0.13.0 → msdev-0.14.0}/src/msdev/helpers/msdev-file-helper-linux-arm64.sha256 +0 -0
  70. {msdev-0.13.0 → msdev-0.14.0}/src/msdev.egg-info/dependency_links.txt +0 -0
  71. {msdev-0.13.0 → msdev-0.14.0}/src/msdev.egg-info/entry_points.txt +0 -0
  72. {msdev-0.13.0 → msdev-0.14.0}/src/msdev.egg-info/top_level.txt +0 -0
  73. {msdev-0.13.0 → msdev-0.14.0}/tests/test_compat.py +0 -0
  74. {msdev-0.13.0 → msdev-0.14.0}/tests/test_config.py +0 -0
  75. {msdev-0.13.0 → msdev-0.14.0}/tests/test_connect_env_scenario.py +0 -0
  76. {msdev-0.13.0 → msdev-0.14.0}/tests/test_container_helper.py +0 -0
  77. {msdev-0.13.0 → msdev-0.14.0}/tests/test_daemon_container_stream.py +0 -0
  78. {msdev-0.13.0 → msdev-0.14.0}/tests/test_daemon_deploy.py +0 -0
  79. {msdev-0.13.0 → msdev-0.14.0}/tests/test_docker_stream_dispatch.py +0 -0
  80. {msdev-0.13.0 → msdev-0.14.0}/tests/test_env_put_get_scenario.py +0 -0
  81. {msdev-0.13.0 → msdev-0.14.0}/tests/test_env_read_write_scenario.py +0 -0
  82. {msdev-0.13.0 → msdev-0.14.0}/tests/test_env_transfer.py +0 -0
  83. {msdev-0.13.0 → msdev-0.14.0}/tests/test_exec_argv.py +0 -0
  84. {msdev-0.13.0 → msdev-0.14.0}/tests/test_exec_command_scenario.py +0 -0
  85. {msdev-0.13.0 → msdev-0.14.0}/tests/test_exec_default_timeout_scenario.py +0 -0
  86. {msdev-0.13.0 → msdev-0.14.0}/tests/test_helper_deploy.py +0 -0
  87. {msdev-0.13.0 → msdev-0.14.0}/tests/test_init_skill.py +0 -0
  88. {msdev-0.13.0 → msdev-0.14.0}/tests/test_inventory.py +0 -0
  89. {msdev-0.13.0 → msdev-0.14.0}/tests/test_model_cli.py +0 -0
  90. {msdev-0.13.0 → msdev-0.14.0}/tests/test_model_services.py +0 -0
  91. {msdev-0.13.0 → msdev-0.14.0}/tests/test_multiplex.py +0 -0
  92. {msdev-0.13.0 → msdev-0.14.0}/tests/test_node_connect_provision_scenario.py +0 -0
  93. {msdev-0.13.0 → msdev-0.14.0}/tests/test_node_connect_save_password_scenario.py +0 -0
  94. {msdev-0.13.0 → msdev-0.14.0}/tests/test_node_env_config.py +0 -0
  95. {msdev-0.13.0 → msdev-0.14.0}/tests/test_node_env_services.py +0 -0
  96. {msdev-0.13.0 → msdev-0.14.0}/tests/test_node_sync_ssh_scenario.py +0 -0
  97. {msdev-0.13.0 → msdev-0.14.0}/tests/test_request_client.py +0 -0
  98. {msdev-0.13.0 → msdev-0.14.0}/tests/test_ssh_auth.py +0 -0
  99. {msdev-0.13.0 → msdev-0.14.0}/tests/test_ssh_config.py +0 -0
  100. {msdev-0.13.0 → msdev-0.14.0}/tests/test_ssh_tunnel.py +0 -0
  101. {msdev-0.13.0 → msdev-0.14.0}/tests/test_stream_server.py +0 -0
  102. {msdev-0.13.0 → msdev-0.14.0}/tests/test_stream_transport.py +0 -0
  103. {msdev-0.13.0 → msdev-0.14.0}/tests/test_streamproto.py +0 -0
  104. {msdev-0.13.0 → msdev-0.14.0}/tests/test_target_guides.py +0 -0
  105. {msdev-0.13.0 → msdev-0.14.0}/tests/test_transport.py +0 -0
  106. {msdev-0.13.0 → msdev-0.14.0}/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.0
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.0"
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.0"
@@ -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=(
@@ -6,6 +6,7 @@ from dataclasses import asdict, dataclass
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ from ... import __version__
9
10
  from ..resources import Node
10
11
  from ..ssh_config import (
11
12
  SshHostSpec,
@@ -18,6 +19,19 @@ from ..transport import DaemonUnavailableError, RpcError
18
19
  from .context import ServiceContext
19
20
 
20
21
 
22
+ def remote_daemon_version_matches(
23
+ ping: Any,
24
+ *,
25
+ expected: str | None = None,
26
+ ) -> bool:
27
+ """Return True when ping reports the same msdevd version as the local CLI."""
28
+ target = expected if expected is not None else __version__
29
+ if not isinstance(ping, dict):
30
+ return False
31
+ remote = ping.get("version")
32
+ return isinstance(remote, str) and remote == target
33
+
34
+
21
35
  @dataclass(frozen=True)
22
36
  class NodeAddRequest:
23
37
  name: str
@@ -220,29 +234,52 @@ class NodeService:
220
234
  name: str,
221
235
  package_path: Path | None = None,
222
236
  ) -> dict[str, Any]:
237
+ """Force-redeploy msdevd on the node (ignores current ping/version)."""
223
238
  node = self.context.node_store.get(self._required(name, "node name"))
224
- return self._ensure_agent_ready(node, package_path=package_path)
239
+ return self._ensure_agent_ready(
240
+ node,
241
+ package_path=package_path,
242
+ force=True,
243
+ )
225
244
 
226
245
  def _ensure_agent_ready(
227
246
  self,
228
247
  node: Node,
229
248
  *,
230
249
  package_path: Path | None = None,
250
+ force: bool = False,
231
251
  ) -> dict[str, Any]:
232
252
  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}
253
+ reason = "forced" if force else None
254
+ if not force:
255
+ try:
256
+ ping = transport.call("ping")
257
+ if remote_daemon_version_matches(ping):
258
+ return {
259
+ "ready": True,
260
+ "bootstrapped": False,
261
+ "reason": "version_match",
262
+ "ping": ping if isinstance(ping, dict) else None,
263
+ }
264
+ if isinstance(ping, dict):
265
+ reason = "version_mismatch"
266
+ else:
267
+ reason = "unavailable"
268
+ except DaemonUnavailableError:
269
+ reason = "unavailable"
270
+ if not node.auto_bootstrap:
271
+ raise RpcError(
272
+ f"msdevd is not ready on node {node.name!r}; "
273
+ "enable auto-bootstrap on the node, or run "
274
+ f"`msdev node bootstrap {node.name}`"
275
+ ) from None
276
+ result = self.context.bootstrap(node, package_path)
277
+ return {
278
+ "ready": True,
279
+ "bootstrapped": True,
280
+ "reason": reason or "unavailable",
281
+ **result,
282
+ }
246
283
 
247
284
  def _connection_nodes(
248
285
  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,
@@ -0,0 +1 @@
1
+ 7b3d2dcd31cc752b44289c5b0936569be7df1aef162d07c1b3c00e886cfea884 msdevd-linux-amd64
@@ -0,0 +1 @@
1
+ aeb5721e49746058e286ed14621208823ee092372c0a7d772cfdd9924d5b71e1 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,6 @@ 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 or its version mismatches the local CLI, run
185
+ `msdev node bootstrap NODE` (or `msdev node connect NODE`), then retry.
182
186
  - 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.0
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
 
@@ -81,6 +81,7 @@ tests/test_model_cli.py
81
81
  tests/test_model_services.py
82
82
  tests/test_msdev_cli_skill.py
83
83
  tests/test_multiplex.py
84
+ tests/test_node_bootstrap_version_scenario.py
84
85
  tests/test_node_connect_provision_scenario.py
85
86
  tests/test_node_connect_save_password_scenario.py
86
87
  tests/test_node_env_config.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.0"},
318
+ },
319
+ (
320
+ "{\n"
321
+ ' "bootstrapped": true,\n'
322
+ ' "daemon": {\n'
323
+ ' "ok": true,\n'
324
+ ' "version": "0.14.0"\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.0",
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.0")
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.0")
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.0")
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",
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """Scenario: CLI node bootstrap force-redeploys; connect matches ping version."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from io import StringIO
10
+ from pathlib import Path
11
+ from unittest.mock import Mock, patch
12
+
13
+ from msdev import __version__
14
+ from msdev.cli import build_parser, command_node
15
+ from msdev.core.resources import EnvironmentStore, Node, NodeStore
16
+ from msdev.core.services.context import ServiceContext
17
+ from msdev.core.services.node import NodeService, remote_daemon_version_matches
18
+
19
+
20
+ class NodeBootstrapVersionScenarioTests(unittest.TestCase):
21
+ def test_remote_daemon_version_matches_helper(self) -> None:
22
+ self.assertTrue(
23
+ remote_daemon_version_matches(
24
+ {"ok": True, "version": __version__},
25
+ )
26
+ )
27
+ self.assertFalse(
28
+ remote_daemon_version_matches(
29
+ {"ok": True, "version": "0.0.0"},
30
+ )
31
+ )
32
+ self.assertFalse(remote_daemon_version_matches({"ok": True}))
33
+ self.assertFalse(remote_daemon_version_matches("not-a-dict"))
34
+
35
+ def test_cli_bootstrap_force_redeploys_even_when_ping_succeeds(self) -> None:
36
+ with tempfile.TemporaryDirectory() as temp_dir:
37
+ root = Path(temp_dir)
38
+ resources = root / "resources.json"
39
+ node_store = NodeStore(resources)
40
+ node_store.add(
41
+ Node(name="worker", ssh_host="worker", auto_bootstrap=True)
42
+ )
43
+ environment_store = EnvironmentStore(resources)
44
+
45
+ bootstrap_result = {
46
+ "ready": True,
47
+ "bootstrapped": True,
48
+ "reason": "forced",
49
+ "daemon": {
50
+ "ok": True,
51
+ "version": __version__,
52
+ "node_id": "n1",
53
+ },
54
+ "layout": "static",
55
+ }
56
+ args = build_parser().parse_args(
57
+ ["--config", str(resources), "node", "bootstrap", "worker"]
58
+ )
59
+ stdout = StringIO()
60
+ with (
61
+ patch("msdev.cli.NodeService") as service_cls,
62
+ patch("sys.stdout", stdout),
63
+ ):
64
+ service = service_cls.return_value
65
+ service.bootstrap.return_value = bootstrap_result
66
+ code = command_node(args, node_store, environment_store)
67
+
68
+ self.assertEqual(code, 0)
69
+ service.bootstrap.assert_called_once_with("worker", package_path=None)
70
+ payload = json.loads(stdout.getvalue())
71
+ self.assertTrue(payload["bootstrapped"])
72
+ self.assertEqual(payload["reason"], "forced")
73
+ self.assertEqual(payload["daemon"]["version"], __version__)
74
+
75
+ def test_connect_redeploys_when_remote_version_mismatches(self) -> None:
76
+ with tempfile.TemporaryDirectory() as temp_dir:
77
+ root = Path(temp_dir)
78
+ resources = root / "resources.json"
79
+ node = Node(name="worker", ssh_host="worker", auto_bootstrap=True)
80
+ node_store = NodeStore(resources)
81
+ node_store.add(node)
82
+ environment_store = EnvironmentStore(resources)
83
+
84
+ transport = Mock()
85
+ transport.connect.return_value = {
86
+ "connected": True,
87
+ "already_connected": False,
88
+ "persist": "yes",
89
+ }
90
+ transport.call.return_value = {
91
+ "ok": True,
92
+ "version": "0.12.0",
93
+ "node_id": "old",
94
+ }
95
+ bootstrap = Mock(
96
+ return_value={
97
+ "daemon": {
98
+ "ok": True,
99
+ "version": __version__,
100
+ "node_id": "new",
101
+ },
102
+ "layout": "static",
103
+ }
104
+ )
105
+ context = ServiceContext(
106
+ node_store=node_store,
107
+ environment_store=environment_store,
108
+ create_transport=Mock(return_value=transport),
109
+ bootstrap=bootstrap,
110
+ )
111
+
112
+ args = build_parser().parse_args(
113
+ ["--config", str(resources), "node", "connect", "worker"]
114
+ )
115
+ stdout = StringIO()
116
+ with (
117
+ patch("msdev.cli._service_context", return_value=context),
118
+ patch("msdev.cli.NodeService", NodeService),
119
+ patch("sys.stdout", stdout),
120
+ ):
121
+ code = command_node(args, node_store, environment_store)
122
+
123
+ self.assertEqual(code, 0, stdout.getvalue())
124
+ bootstrap.assert_called_once_with(node, None)
125
+ self.assertIn("agent=ready", stdout.getvalue())
126
+ transport.call.assert_called_with("ping")
127
+
128
+ def test_connect_skips_redeploy_when_version_matches(self) -> None:
129
+ with tempfile.TemporaryDirectory() as temp_dir:
130
+ root = Path(temp_dir)
131
+ resources = root / "resources.json"
132
+ node = Node(name="worker", ssh_host="worker", auto_bootstrap=True)
133
+ node_store = NodeStore(resources)
134
+ node_store.add(node)
135
+ environment_store = EnvironmentStore(resources)
136
+
137
+ transport = Mock()
138
+ transport.connect.return_value = {
139
+ "connected": True,
140
+ "already_connected": False,
141
+ "persist": "yes",
142
+ }
143
+ transport.call.return_value = {
144
+ "ok": True,
145
+ "version": __version__,
146
+ "node_id": "n1",
147
+ }
148
+ bootstrap = Mock()
149
+ context = ServiceContext(
150
+ node_store=node_store,
151
+ environment_store=environment_store,
152
+ create_transport=Mock(return_value=transport),
153
+ bootstrap=bootstrap,
154
+ )
155
+
156
+ args = build_parser().parse_args(
157
+ ["--config", str(resources), "node", "connect", "worker"]
158
+ )
159
+ stdout = StringIO()
160
+ with (
161
+ patch("msdev.cli._service_context", return_value=context),
162
+ patch("msdev.cli.NodeService", NodeService),
163
+ patch("sys.stdout", stdout),
164
+ ):
165
+ code = command_node(args, node_store, environment_store)
166
+
167
+ self.assertEqual(code, 0, stdout.getvalue())
168
+ bootstrap.assert_not_called()
169
+ self.assertIn("agent=ready", stdout.getvalue())
170
+
171
+
172
+ if __name__ == "__main__":
173
+ unittest.main()