tan-cli 0.7.0__py3-none-any.whl

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 (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/bootstrap.py ADDED
@@ -0,0 +1,2572 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure decision logic for `tan bootstrap` -- no IO, no subprocesses.
3
+
4
+ Mirrors `crates/tan-core/src/bootstrap/` (its `manifest`/`prerequisites`/
5
+ `runtime`/`blocks`/`workspace_guard` split, collapsed into one module because
6
+ Python needs no visibility ceremony to keep them apart). The spawning half lives
7
+ in `tan.commands.bootstrap_cmd`.
8
+
9
+ The FACTS every step acts on -- tool lists, argv, pip specs, pins, env map,
10
+ hints -- come from `<sdkRoot>/metadata/bootstrap.json`, never from literals
11
+ here. That file is a live consumer contract (invariant **I-64**: *"tan (Rust,
12
+ cross-platform) has read the same facts since tan-cli PR #55 ... not merely an
13
+ INTENDED future consumer"*), and its own drift gate
14
+ (`scripts/check_bootstrap_manifest.py`) inspects only `bootstrap.sh` and
15
+ `bootstrap.ps1` -- so a hand-ported constant here desyncs silently. The
16
+ `fallback_facts` constants below are therefore stale-by-default and exist only
17
+ for an SDK predating the manifest.
18
+
19
+ **tan does not shell the SDK's bootstrap scripts.** Invariant **I-32** and
20
+ anti-pattern **22** of `docs/superpowers/specs/2026-07-29-tan-port-invariants.md`
21
+ record that giving a command an alp-sdk-script dependency it deliberately does
22
+ not have is a regression the parity gates cannot see; the Rust oracle's own
23
+ module doc says the same ("No `bash` anywhere -- native Windows is a first-class
24
+ host (#49), so the two scripts are the parity oracle for CONTROL FLOW and
25
+ message strings, not a runtime dependency"). The scripts are read as an oracle
26
+ for wording and step ORDER, and re-implemented.
27
+
28
+ Message strings and step order come from those two oracles. Their whitespace is
29
+ load-bearing twice over: a human reads the lines, and the envelope's issue
30
+ message is `" ".join(lines)`.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from tan.core.os_class import infer_runtime_for_core_id
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import shlex
40
+ from collections.abc import Callable
41
+ from dataclasses import dataclass, replace
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from tan.core import artifact_provenance
46
+ from tan.core.artifact_provenance import ArtifactProvenance
47
+ from tan.core.timestamp import generated_at_iso
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Hosts
51
+ # ---------------------------------------------------------------------------
52
+
53
+ #: The four hosts the flow distinguishes. Plain strings, not an enum: these ARE
54
+ #: the manifest's own `prerequisites.install` keys for three of the four, so a
55
+ #: separate enum would only need translating back.
56
+ LINUX = "linux"
57
+ MACOS = "macos"
58
+ #: `install.linux`'s package-manager keys (alp-sdk#1464 / tan-cli#760) -- see
59
+ #: `detect_linux_pm` for the detection order and why `pacman` has no constant
60
+ #: here at all.
61
+ LINUX_PM_APT = "apt"
62
+ LINUX_PM_DNF = "dnf"
63
+ WINDOWS = "windows"
64
+ OTHER = "other"
65
+
66
+
67
+ def detect_host_os(platform: str) -> str:
68
+ """Classify a `sys.platform` value. A PARAMETER, not read from `sys` here,
69
+ so both branches stay testable from either host (`HostOs::detect`)."""
70
+ if platform.startswith("linux"):
71
+ return LINUX
72
+ if platform == "darwin":
73
+ return MACOS
74
+ if platform in ("win32", "cygwin"):
75
+ return WINDOWS
76
+ return OTHER
77
+
78
+
79
+ def os_label(host: str) -> str:
80
+ """The POSIX script's `OS_LABEL`. `windows-bash` (git-bash/MSYS) has no
81
+ counterpart: on Windows `tan bootstrap` runs the native flow, which prints
82
+ the Python version instead of an OS label."""
83
+ return "unknown" if host == OTHER else host
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Constants (the documented fallbacks -- stale by default; see the module doc)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ #: FALLBACK Zephyr pin, used only when the SDK has no `metadata/bootstrap.json`.
91
+ ZEPHYR_VERSION = "v4.4.1"
92
+
93
+ #: FALLBACK west requirement -- a FLOOR, not a pin. Mirrors `west.pipSpec`.
94
+ WEST_REQUIREMENT = "west>=0.14.0"
95
+
96
+ #: Manifest path relative to the SDK checkout root.
97
+ BOOTSTRAP_MANIFEST_REL_PATH = "metadata/bootstrap.json"
98
+
99
+ #: The only `schemaVersion` this consumer understands
100
+ #: (`metadata/schemas/bootstrap-v1.schema.json` pins it `const: 1`).
101
+ BOOTSTRAP_MANIFEST_SCHEMA_VERSION = 1
102
+
103
+ #: `${SDK_ROOT}` / `${WORKSPACE_DIR}` substitution tokens.
104
+ TOKEN_SDK_ROOT = "${SDK_ROOT}"
105
+ TOKEN_WORKSPACE_DIR = "${WORKSPACE_DIR}"
106
+
107
+ #: The dedicated subdirectory the workspace-parent guard offers to relocate the
108
+ #: checkout into. NOT a detection heuristic -- the guard never keys off a
109
+ #: directory NAME (see `parent_needs_workspace_guard`); this is only the name tan
110
+ #: chooses for the new home it builds.
111
+ DEFAULT_WORKSPACE_DIR_NAME = "alp-workspace"
112
+
113
+ #: `tan doctor`'s wording, reused verbatim so the two agree
114
+ #: (`tan_core::build_readiness::YOCTO_HOST_DETAIL`).
115
+ YOCTO_HOST_DETAIL = "Yocto builds are Linux-only; use WSL2 or a Linux host/container."
116
+
117
+ #: The per-core `os:` value that takes a core OUT of play entirely.
118
+ OS_OFF = "off"
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Venv layout
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class VenvLayout:
128
+ """Where a venv keeps its executables and what they are called. The
129
+ DIRECTORY names are manifest facts (`venv.posixBinDir`/`windowsBinDir`); the
130
+ executable names are not in the manifest and live here."""
131
+
132
+ bin_dir: str
133
+ python: str
134
+ west: str
135
+
136
+
137
+ def venv_layout(is_windows: bool) -> VenvLayout:
138
+ if is_windows:
139
+ return VenvLayout("Scripts", "python.exe", "west.exe")
140
+ return VenvLayout("bin", "python", "west")
141
+
142
+
143
+ def venv_exe_names(bin_dir: str, facts: BootstrapFacts) -> VenvLayout:
144
+ """The venv executable names for whichever bin dir actually WON. Both
145
+ scripts pick the bin dir by which one exists, so a `Scripts/` venv created
146
+ under git-bash keeps working on a POSIX host -- the names follow that
147
+ choice, not the host."""
148
+ return venv_layout(bin_dir == facts.venv_windows_bin_dir)
149
+
150
+
151
+ def python_candidates(is_windows: bool) -> list[list[str]]:
152
+ """Host-interpreter candidates to probe, best first.
153
+
154
+ Windows leads with the `py` launcher because a machine can have a perfectly
155
+ good 3.12 with NO bare `python` on PATH, and the bare `python.exe` there is
156
+ very often the Microsoft Store alias -- on PATH, prints nothing.
157
+ """
158
+ if is_windows:
159
+ return [["py", "-3"], ["python"], ["python3"]]
160
+ return [["python3"], ["python"]]
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Version parsing (tan_core::preflight)
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def parse_version_tag(revision: str) -> str | None:
169
+ """`"v4.4.1"` / `"4.4"` / `"v4.4.0-rc1"` -> `"4.4.1"` / `"4.4.0"` /
170
+ `"4.4.0"`. `None` for a branch/SHA with no leading `MAJOR.MINOR`.
171
+
172
+ Normalises the two shapes that would defeat the comparison: a missing PATCH
173
+ reads as `0`, and a pre-release suffix is dropped from the patch component
174
+ rather than failing the whole parse.
175
+ """
176
+ stripped = revision.strip()
177
+ if stripped.startswith("v"):
178
+ stripped = stripped[1:]
179
+ parts = stripped.split(".")
180
+ if len(parts) < 2:
181
+ return None
182
+ try:
183
+ major = int(parts[0])
184
+ minor = int(parts[1])
185
+ except ValueError:
186
+ return None
187
+ patch = 0
188
+ if len(parts) > 2:
189
+ digits = re.match(r"\d+", parts[2])
190
+ if digits is not None:
191
+ patch = int(digits.group(0))
192
+ return f"{major}.{minor}.{patch}"
193
+
194
+
195
+ def parse_zephyr_version_file(body: str) -> str | None:
196
+ """`<ZEPHYR_BASE>/VERSION` -> `MAJOR.MINOR.PATCH`. `None` when MAJOR or
197
+ MINOR is missing; PATCHLEVEL defaults to `0`."""
198
+ major: int | None = None
199
+ minor: int | None = None
200
+ patch = 0
201
+ for line in body.splitlines():
202
+ key, sep, value = line.partition("=")
203
+ if not sep:
204
+ continue
205
+ key = key.strip()
206
+ raw = value.strip()
207
+ if key == "VERSION_MAJOR":
208
+ major = _int_or_none(raw)
209
+ elif key == "VERSION_MINOR":
210
+ minor = _int_or_none(raw)
211
+ elif key == "PATCHLEVEL":
212
+ patch = _int_or_none(raw) or 0
213
+ if major is None or minor is None:
214
+ return None
215
+ return f"{major}.{minor}.{patch}"
216
+
217
+
218
+ def _int_or_none(raw: str) -> int | None:
219
+ try:
220
+ return int(raw)
221
+ except ValueError:
222
+ return None
223
+
224
+
225
+ def parse_west_zephyr_pin(body: str) -> str | None:
226
+ """The Zephyr pin as `MAJOR.MINOR.PATCH` from a `west.yml` body: the
227
+ `manifest.projects[]` entry named `zephyr`, whose `revision` is a tag.
228
+
229
+ PyYAML when importable, else a two-key scan. tan ships no YAML dependency
230
+ and the frozen binary is built without one, so the fallback is THE path on
231
+ the shipped artifact -- the same bargain `presets_cmd._load_som_yaml` and
232
+ `generate_cmd._board_sku` strike.
233
+ """
234
+ revision = _west_zephyr_revision(body)
235
+ return parse_version_tag(revision) if revision else None
236
+
237
+
238
+ def _west_zephyr_revision(body: str) -> str | None:
239
+ try:
240
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
241
+ except ImportError:
242
+ return _scan_west_zephyr_revision(body)
243
+ try:
244
+ doc = yaml.safe_load(body)
245
+ except Exception: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
246
+ return None
247
+ if not isinstance(doc, dict):
248
+ return None
249
+ manifest = doc.get("manifest")
250
+ if not isinstance(manifest, dict):
251
+ return None
252
+ projects = manifest.get("projects")
253
+ if not isinstance(projects, list):
254
+ return None
255
+ for project in projects:
256
+ if isinstance(project, dict) and project.get("name") == "zephyr":
257
+ revision = project.get("revision")
258
+ return revision if isinstance(revision, str) else None
259
+ return None
260
+
261
+
262
+ def _scan_west_zephyr_revision(body: str) -> str | None:
263
+ """The no-PyYAML reader: `revision:` inside the `- name: zephyr` list item.
264
+
265
+ Answers one question, in either key order (`revision:` may precede
266
+ `name:`), and stops at the next `- ` item so a later project's revision is
267
+ never attributed to zephyr.
268
+ """
269
+ in_item = False
270
+ is_zephyr = False
271
+ revision: str | None = None
272
+ for raw in body.splitlines():
273
+ stripped = raw.strip()
274
+ if not stripped or stripped.startswith("#"):
275
+ continue
276
+ if stripped.startswith("- "):
277
+ if is_zephyr and revision is not None:
278
+ return revision
279
+ in_item = True
280
+ is_zephyr = False
281
+ revision = None
282
+ stripped = stripped[2:].strip()
283
+ if not in_item:
284
+ continue
285
+ key, sep, value = stripped.partition(":")
286
+ if not sep:
287
+ continue
288
+ cleaned = value.strip().strip("'\"")
289
+ if key.strip() == "name" and cleaned == "zephyr":
290
+ is_zephyr = True
291
+ elif key.strip() == "revision":
292
+ revision = cleaned
293
+ return revision if is_zephyr else None
294
+
295
+
296
+ def resolve_zephyr_pin(west_yml: str | None, facts_version: str) -> str:
297
+ """The ONE Zephyr pin the workspace-reuse test compares against.
298
+
299
+ `west.yml` leads because `build`'s preflight `zephyrVersion` check reads
300
+ exactly that file, and the operator re-runs `tan bootstrap` ON its warning
301
+ (`tan build` does not do it for them -- tan-cli#427 settled that this port
302
+ has no implicit bootstrap and is not getting one). With
303
+ two pin sources an SDK bump made bootstrap ADOPT a workspace preflight
304
+ simultaneously called stale -- a loop that never converges. Full
305
+ `MAJOR.MINOR.PATCH`, never a `MAJOR.MINOR` truncation: that truncation is
306
+ what let a `v4.4.0` tree satisfy a `v4.4.1` pin, silently.
307
+ """
308
+ if west_yml is not None:
309
+ pinned = parse_west_zephyr_pin(west_yml)
310
+ if pinned is not None:
311
+ return pinned
312
+ return parse_version_tag(facts_version) or ""
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # `metadata/bootstrap.json`
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ class BootstrapManifestError(Exception):
321
+ """A manifest that is present and unusable. NEVER a silent fallback: the
322
+ absent-file case is the legacy path and falls back, but degrading here would
323
+ re-introduce hand-ported behaviour against an SDK that explicitly declared
324
+ something else."""
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class NativeLibHint:
329
+ """A per-OS optional-native-libs hint. `note` is an ARRAY of lines, not one
330
+ paragraph (the schema's `minItems: 1`): both scripts print one line per
331
+ element, so an aligned `package -> API` mapping survives instead of
332
+ collapsing into a ~380-char unwrapped line."""
333
+
334
+ note: tuple[str, ...]
335
+ command: str | None
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class Tokens:
340
+ """`${SDK_ROOT}` / `${WORKSPACE_DIR}` substitution values.
341
+
342
+ Applied at RENDER time, not baked in at load time, because workspace
343
+ selection can repoint `workspace_dir` afterwards (adopting a compatible
344
+ `$ZEPHYR_BASE` tree). `bootstrap.sh` re-substitutes on every
345
+ `print_env_lines` call for exactly this reason; `bootstrap.ps1` binds once
346
+ BEFORE selection and prints the pre-reuse path -- we follow bash.
347
+ """
348
+
349
+ sdk_root: str
350
+ workspace_dir: str
351
+
352
+ def apply(self, value: str) -> str:
353
+ """One blind substitution pass (`tok()` / `Resolve-BootstrapToken`)."""
354
+ return value.replace(TOKEN_SDK_ROOT, self.sdk_root).replace(
355
+ TOKEN_WORKSPACE_DIR, self.workspace_dir
356
+ )
357
+
358
+
359
+ @dataclass(frozen=True)
360
+ class BootstrapFacts:
361
+ """The workspace-assembly facts, however obtained: parsed from the manifest,
362
+ or reconstructed from the fallback constants for an SDK that predates it.
363
+
364
+ ONE shape for both sources so no step branches on provenance -- only
365
+ `from_manifest` records which it was, for the envelope's
366
+ `factsFromManifest`.
367
+ """
368
+
369
+ zephyr_version: str
370
+ zephyr_requirements_path: str
371
+ venv_dir_name: str
372
+ venv_posix_bin_dir: str
373
+ venv_windows_bin_dir: str
374
+ prerequisites_posix: tuple[str, ...]
375
+ #: `prerequisites.macos`, or EMPTY when the manifest declares none -- which
376
+ #: means "read `posix`", the behaviour of every SDK before v0.14.0. See
377
+ #: `prerequisites` for why that fallback is load-bearing rather than tidy.
378
+ prerequisites_macos: tuple[str, ...]
379
+ prerequisites_windows: tuple[str, ...]
380
+ python_min_version: tuple[int, int]
381
+ #: `zephyr.pythonMinVersion` -- tan-cli#606: alp-sdk#1078 (Option A, the
382
+ #: chosen path) added this Zephyr-SCOPED floor alongside the pre-existing
383
+ #: host-universal `prerequisites.pythonMinVersion` above; the two are
384
+ #: deliberately different numbers ("3.12" vs "3.10" as of this writing),
385
+ #: not a duplicate. `None` when the manifest predates the key (every SDK
386
+ #: before alp-sdk's zephyr-scoped-floor change) -- callers fall back to
387
+ #: `doctor_cmd.ZEPHYR_PYTHON_FLOOR` in that case, same as before this field
388
+ #: existed. Not `_require`d: unlike `prerequisites.pythonMinVersion`, an
389
+ #: absent key here is a normal, expected shape, not a malformed manifest.
390
+ zephyr_python_min_version: tuple[int, int] | None
391
+ #: `prerequisites.install`, keyed `linux`/`macos`/`windows`. `macos`/
392
+ #: `windows` map tool -> command directly. `linux` is one level deeper
393
+ #: (alp-sdk#1464 / tan-cli#760): PACKAGE MANAGER -> tool -> command --
394
+ #: `{"apt": {...}, "dnf": {...}}`, `dnf` a SUBSET of `apt`'s tools (see
395
+ #: `normalize_linux_install`/`_fallback_install_commands`). Use
396
+ #: `install_for_host`, never this field directly.
397
+ install: dict[str, dict[str, str] | dict[str, dict[str, str]]]
398
+ #: `artifactProvenance`, tool -> tier/licence/upstream page/size, already
399
+ #: normalised (tan-cli#1066, alp-sdk#1574). TOP-LEVEL in the manifest, not
400
+ #: under `prerequisites`, even though its keys are the prerequisite tool
401
+ #: vocabulary. EMPTY for an SDK predating alp-sdk v0.16.0, and empty for a
402
+ #: malformed block -- never an error: this is advisory metadata for a
403
+ #: consumer's consent screen, and refusing to bootstrap over it would trade
404
+ #: a working host for a display fact. See `tan.core.artifact_provenance`.
405
+ artifact_provenance: dict[str, ArtifactProvenance]
406
+ west_pip_spec: str
407
+ west_init_args: tuple[str, ...]
408
+ west_update_args: tuple[str, ...]
409
+ west_export_args: tuple[str, ...]
410
+ west_extension_guard: str
411
+ pip_bootstrap_upgrade: tuple[str, ...]
412
+ pip_sdk_extras: tuple[str, ...]
413
+ #: `pip install -e` target: alp-sdk's own `alp_cli`/`alp_mcp` tooling (the
414
+ #: `alp-mcp` console script). Not a planner install -- tan plans in-process
415
+ #: (tan-cli#270).
416
+ pip_editable_install: str
417
+ #: `env`, ordered, still tokened. A list of pairs because ORDER is what
418
+ #: makes the rendered `export`/`$env:` lines come out in the manifest's
419
+ #: declared order (serde's `preserve_order`; `json.loads` gives it free).
420
+ env: tuple[tuple[str, str], ...]
421
+ hint_linux: NativeLibHint
422
+ hint_macos: NativeLibHint
423
+ hint_windows: NativeLibHint
424
+ manual_install_windows: tuple[str, ...]
425
+ #: `manualInstallHints.posix.note` -- EMPTY when the manifest declares no
426
+ #: `posix` key at all (tan-cli#495 defect 6). Optional on the wire, unlike
427
+ #: `windows`: every SDK before alp-sdk v0.14.0 declares `windows` alone,
428
+ #: and requiring it here would turn each of those into a hard
429
+ #: `BootstrapManifestError` for everyone who runs `tan bootstrap` against
430
+ #: an older SDK -- the same trap `prerequisites.install` and
431
+ #: `prerequisites.windows` above already document. Empty renders nothing,
432
+ #: which is exactly what those SDKs did before the field existed.
433
+ manual_install_posix: tuple[str, ...]
434
+ from_manifest: bool
435
+
436
+ def venv_bin_dir(self, is_windows: bool) -> str:
437
+ return self.venv_windows_bin_dir if is_windows else self.venv_posix_bin_dir
438
+
439
+ def prerequisites(self, host: str) -> tuple[str, ...]:
440
+ """The tool list for this host. The lists genuinely differ (`python` vs
441
+ `python3`) and the manifest records that faithfully rather than
442
+ unifying them -- so does this.
443
+
444
+ Takes the HOST, not `is_windows`, since alp-sdk v0.14.0: that release
445
+ added `xz` and `wget` to `prerequisites.posix` AND a separate
446
+ `prerequisites.macos` that omits them. Keying off a bool hands macOS the
447
+ POSIX list and refuses a stock macOS host -- which ships neither `wget`
448
+ nor a standalone `xz` -- over tools the SDK does not ask macOS for.
449
+
450
+ An EMPTY `prerequisites_macos` means the manifest declared none (every
451
+ SDK before v0.14.0), and macOS then reads `posix` exactly as it always
452
+ did. The fallback is the old behaviour, not a guess.
453
+ """
454
+ if host == WINDOWS:
455
+ return self.prerequisites_windows
456
+ if host == MACOS and self.prerequisites_macos:
457
+ return self.prerequisites_macos
458
+ return self.prerequisites_posix
459
+
460
+ def install_for_host(self, host: str, linux_pm: str | None = None) -> dict[str, str]:
461
+ """THE one place the manifest's `linux`/`macos`/`windows` install keying
462
+ is reconciled with `prerequisites`' `posix`/`windows` tool-list keying.
463
+ Callers resolve once, by host, and hand the resolved map down -- so no
464
+ caller can look a tool up in the wrong OS's table (a POSIX refusal on
465
+ macOS getting Linux's `apt-get` lines).
466
+
467
+ `OTHER` (a POSIX host that is neither Linux nor macOS) has no manifest
468
+ entry and is not going to grow one: every tool there reports
469
+ `command: null`. The alternatives are both worse than the `null` -- a
470
+ throw, or handing a BSD user a `brew install` line.
471
+
472
+ `linux_pm` (alp-sdk#1464 / tan-cli#760) is the extra hop `host ==
473
+ LINUX` needs: `self.install[LINUX]` is package-manager -> tool ->
474
+ command, so a caller must say WHICH sub-map it wants. `None` (the
475
+ default) is an EMPTY map, never a guess -- a caller with no confirmed
476
+ package manager (`detect_linux_pm`) gets `command: null` for every
477
+ tool, not one PM's data served for another's.
478
+ """
479
+ if host == LINUX:
480
+ if linux_pm is None:
481
+ return {}
482
+ sub = self.install.get(LINUX, {})
483
+ node = sub.get(linux_pm) if isinstance(sub, dict) else None
484
+ return dict(node) if isinstance(node, dict) else {}
485
+ return self.install.get(host, {})
486
+
487
+ def native_lib_hint(self, host: str) -> NativeLibHint | None:
488
+ """`None` for `OTHER` -- `bootstrap.sh`'s `*)` arm prints no hint, just
489
+ the not-detected line."""
490
+ return {
491
+ LINUX: self.hint_linux,
492
+ MACOS: self.hint_macos,
493
+ WINDOWS: self.hint_windows,
494
+ }.get(host)
495
+
496
+
497
+ def _str_list(value: Any, what: str) -> tuple[str, ...]:
498
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
499
+ raise BootstrapManifestError(
500
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: `{what}` is not a list of strings"
501
+ )
502
+ return tuple(value)
503
+
504
+
505
+ def _require(doc: Any, key: str, kind: type, what: str) -> Any:
506
+ if not isinstance(doc, dict):
507
+ raise BootstrapManifestError(
508
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: `{what}` is not an object"
509
+ )
510
+ value = doc.get(key)
511
+ if not isinstance(value, kind):
512
+ raise BootstrapManifestError(
513
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
514
+ f"`{what}.{key}`"
515
+ )
516
+ return value
517
+
518
+
519
+ def _hint(doc: Any, key: str) -> NativeLibHint:
520
+ node = doc.get(key) if isinstance(doc, dict) else None
521
+ if not isinstance(node, dict):
522
+ raise BootstrapManifestError(
523
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
524
+ f"`nativeLibHints.{key}`"
525
+ )
526
+ command = node.get("command")
527
+ return NativeLibHint(
528
+ note=_str_list(node.get("note"), f"nativeLibHints.{key}.note"),
529
+ command=command if isinstance(command, str) else None,
530
+ )
531
+
532
+
533
+ def parse_min_version(raw: str) -> tuple[int, int] | None:
534
+ """`"3.10"` -> `(3, 10)`."""
535
+ major, sep, minor = raw.strip().partition(".")
536
+ if not sep:
537
+ return None
538
+ try:
539
+ return int(major.strip()), int(minor.strip())
540
+ except ValueError:
541
+ return None
542
+
543
+
544
+ def is_plain_relative(raw: str) -> bool:
545
+ """A relative path with no `..`, no root and no drive letter -- the shape a
546
+ manifest-supplied directory name must have before it is joined onto the
547
+ workspace (`tan_core::path_guard::is_plain_relative`)."""
548
+ if not raw or raw != raw.strip():
549
+ return False
550
+ if os.path.isabs(raw) or ntpath_isabs(raw):
551
+ return False
552
+ parts = re.split(r"[\\/]", raw)
553
+ return all(part not in ("", ".", "..") for part in parts)
554
+
555
+
556
+ def ntpath_isabs(raw: str) -> bool:
557
+ """Windows-shaped absoluteness (`C:\\x`, `\\\\server\\share`, `\\x`),
558
+ checked on EVERY host: the manifest is authored once and consumed on all
559
+ three, so a POSIX `os.path.isabs` alone would wave `C:\\Windows` through.
560
+
561
+ NOT version-stable, and deliberately left that way (tan-cli#1139): its
562
+ answer for a rooted-but-driveless path flips at CPython 3.13, and both
563
+ sides are supported. Its only two callers absorb that -- `is_plain_relative`
564
+ above is indifferent (its component scan rejects every divergent input
565
+ anyway), and `resolve_workspace_target` decides the shape up front in
566
+ `_is_rooted_no_drive`, which owns the measurement and the reasoning. A
567
+ THIRD caller would have to do the same: do not read a version-stable
568
+ answer out of this."""
569
+ import ntpath # noqa: PLC0415 -- one call site
570
+
571
+ return ntpath.isabs(raw) or bool(re.match(r"^[A-Za-z]:", raw))
572
+
573
+
574
+ def parse_bootstrap_manifest(text: str) -> BootstrapFacts:
575
+ """Parse `metadata/bootstrap.json`. Pure -- the caller reads the file and
576
+ decides what an absent file means (see `fallback_facts`).
577
+
578
+ `schemaVersion` is read on its own FIRST: a future manifest may legitimately
579
+ reshape fields this consumer would otherwise fail on, and the user deserves
580
+ "unsupported version N", not "missing field `foo`".
581
+ """
582
+ try:
583
+ doc = json.loads(text)
584
+ except ValueError as err:
585
+ raise BootstrapManifestError(
586
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: {err}"
587
+ ) from err
588
+ if not isinstance(doc, dict):
589
+ raise BootstrapManifestError(
590
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: not a JSON object"
591
+ )
592
+ version = doc.get("schemaVersion")
593
+ # `bool` excluded explicitly: `True == 1` in Python, so `schemaVersion: true`
594
+ # would pass an `== 1` test that serde's `as_u64()` rejects.
595
+ if not isinstance(version, int) or isinstance(version, bool):
596
+ raise BootstrapManifestError(
597
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing `schemaVersion`"
598
+ )
599
+ if version != BOOTSTRAP_MANIFEST_SCHEMA_VERSION:
600
+ raise BootstrapManifestError(
601
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} declares schemaVersion {version}, but this "
602
+ f"`tan` supports only {BOOTSTRAP_MANIFEST_SCHEMA_VERSION}. Update `tan`, or "
603
+ f"pin an SDK whose bootstrap manifest this version understands."
604
+ )
605
+
606
+ zephyr = doc.get("zephyr")
607
+ venv = doc.get("venv")
608
+ prerequisites = doc.get("prerequisites")
609
+ west = doc.get("west")
610
+ pip = doc.get("pip")
611
+ env = doc.get("env")
612
+ hints = doc.get("nativeLibHints")
613
+ manual = doc.get("manualInstallHints")
614
+
615
+ min_raw = _require(prerequisites, "pythonMinVersion", str, "prerequisites")
616
+ python_min_version = parse_min_version(min_raw)
617
+ if python_min_version is None:
618
+ raise BootstrapManifestError(
619
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
620
+ f"prerequisites.pythonMinVersion `{min_raw}` is not MAJOR.MINOR"
621
+ )
622
+
623
+ # OPTIONAL, unlike `prerequisites.pythonMinVersion` above: absent is the
624
+ # normal shape for any manifest predating alp-sdk#1078's zephyr-scoped
625
+ # floor, not a malformed one -- so a present-but-unparseable value still
626
+ # hard-fails (it was deliberately authored), but a missing key does not.
627
+ zephyr_min_raw = zephyr.get("pythonMinVersion") if isinstance(zephyr, dict) else None
628
+ zephyr_python_min_version: tuple[int, int] | None = None
629
+ if zephyr_min_raw is not None:
630
+ if not isinstance(zephyr_min_raw, str):
631
+ raise BootstrapManifestError(
632
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
633
+ f"zephyr.pythonMinVersion is not a string"
634
+ )
635
+ zephyr_python_min_version = parse_min_version(zephyr_min_raw)
636
+ if zephyr_python_min_version is None:
637
+ raise BootstrapManifestError(
638
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
639
+ f"zephyr.pythonMinVersion `{zephyr_min_raw}` is not MAJOR.MINOR"
640
+ )
641
+
642
+ dir_name = _require(venv, "dirName", str, "venv")
643
+ # `venv.dirName` joins straight onto `workspace_dir` and the join's result is
644
+ # later handed to `rmtree` when a stale venv is recreated -- an unvalidated
645
+ # `..`-bearing or absolute value would let the manifest name an arbitrary
646
+ # removal target outside the workspace. Rejected at this one seam, which
647
+ # every consumer of the name reads through.
648
+ if not is_plain_relative(dir_name):
649
+ raise BootstrapManifestError(
650
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: venv.dirName "
651
+ f"`{dir_name}` is not a plain relative path"
652
+ )
653
+
654
+ if not isinstance(env, dict):
655
+ raise BootstrapManifestError(
656
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped `env`"
657
+ )
658
+ manual_node = manual.get("windows") if isinstance(manual, dict) else None
659
+ posix_node = manual.get("posix") if isinstance(manual, dict) else None
660
+ if not isinstance(manual_node, dict):
661
+ raise BootstrapManifestError(
662
+ f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
663
+ f"`manualInstallHints.windows`"
664
+ )
665
+
666
+ return BootstrapFacts(
667
+ zephyr_version=_require(zephyr, "version", str, "zephyr"),
668
+ zephyr_requirements_path=_require(zephyr, "requirementsPath", str, "zephyr"),
669
+ venv_dir_name=dir_name,
670
+ venv_posix_bin_dir=_require(venv, "posixBinDir", str, "venv"),
671
+ venv_windows_bin_dir=_require(venv, "windowsBinDir", str, "venv"),
672
+ prerequisites_posix=_str_list(prerequisites.get("posix"), "prerequisites.posix"),
673
+ # OPTIONAL on the wire: absent means "use `posix`", which is every SDK
674
+ # before v0.14.0. Required here, it would turn each of those into a hard
675
+ # ValidationFailure for every `tan bootstrap` run against such an SDK.
676
+ prerequisites_macos=_str_list(prerequisites.get("macos", []), "prerequisites.macos"),
677
+ prerequisites_windows=_str_list(
678
+ prerequisites.get("windows"), "prerequisites.windows"
679
+ ),
680
+ python_min_version=python_min_version,
681
+ zephyr_python_min_version=zephyr_python_min_version,
682
+ install=_resolve_install_commands(prerequisites.get("install")),
683
+ # Read off `doc`, not `prerequisites`: alp-sdk#1574 put the block at
684
+ # the manifest's TOP level. Never `_require`d -- see the field comment.
685
+ artifact_provenance=artifact_provenance.parse_table(
686
+ doc.get(artifact_provenance.BLOCK_KEY)
687
+ ),
688
+ west_pip_spec=_require(west, "pipSpec", str, "west"),
689
+ west_init_args=_str_list(_require(west, "initArgs", list, "west"), "west.initArgs"),
690
+ west_update_args=_str_list(
691
+ _require(west, "updateArgs", list, "west"), "west.updateArgs"
692
+ ),
693
+ west_export_args=_str_list(
694
+ _require(west, "exportArgs", list, "west"), "west.exportArgs"
695
+ ),
696
+ west_extension_guard=_require(west, "extensionGuardCommand", str, "west"),
697
+ pip_bootstrap_upgrade=_str_list(
698
+ _require(pip, "bootstrapUpgrade", list, "pip"), "pip.bootstrapUpgrade"
699
+ ),
700
+ pip_sdk_extras=_str_list(_require(pip, "sdkExtras", list, "pip"), "pip.sdkExtras"),
701
+ pip_editable_install=_require(pip, "editableInstall", str, "pip"),
702
+ # A non-string value degrades to `""` rather than failing the manifest,
703
+ # matching serde's `v.as_str().unwrap_or_default()`.
704
+ env=tuple((k, v if isinstance(v, str) else "") for k, v in env.items()),
705
+ hint_linux=_hint(hints, LINUX),
706
+ hint_macos=_hint(hints, MACOS),
707
+ hint_windows=_hint(hints, WINDOWS),
708
+ manual_install_windows=_str_list(
709
+ manual_node.get("note"), "manualInstallHints.windows.note"
710
+ ),
711
+ # OPTIONAL on the wire, unlike `windows` just above: a missing or
712
+ # non-dict `posix` is an empty list, never an error (see the field's
713
+ # own comment on `BootstrapFacts`).
714
+ manual_install_posix=(
715
+ _str_list(posix_node.get("note"), "manualInstallHints.posix.note")
716
+ if isinstance(posix_node, dict)
717
+ else ()
718
+ ),
719
+ from_manifest=True,
720
+ )
721
+
722
+
723
+ def _fallback_install_commands() -> dict[str, dict[str, str] | dict[str, dict[str, str]]]:
724
+ """The install one-liners as `metadata/bootstrap.json` carries them.
725
+
726
+ Two callers: the whole-manifest fallback, and `_resolve_install_commands`'s
727
+ gap-fill for a manifest predating alp-sdk#959 (which carried no `install`
728
+ key at all). Note `ninja`'s PACKAGE name differs from the binary name --
729
+ which is the whole argument for carrying these as data rather than guessing.
730
+
731
+ `LINUX` is re-pinned to alp-sdk `dev` @ `7a419865` (tan-cli#760's second
732
+ half / alp-sdk#1464+#1471): package-manager-keyed now, matching
733
+ `BootstrapFacts.install`'s own nested shape, not the flat tool -> command
734
+ map this table carried before. `dnf` has no `ninja` entry and there is no
735
+ `pacman` key at all -- both deliberate DATA (see `detect_linux_pm`), not
736
+ gaps to fill in later.
737
+ """
738
+ return {
739
+ LINUX: {
740
+ LINUX_PM_APT: {
741
+ "git": "sudo apt-get install -y git",
742
+ "cmake": "sudo apt-get install -y cmake",
743
+ "python3": "sudo apt-get install -y python3",
744
+ "ninja": "sudo apt-get install -y ninja-build",
745
+ # `xz`/`wget` joined `prerequisites.posix` at alp-sdk v0.14.0.
746
+ # Same package-name-differs-from-binary-name point as `ninja`:
747
+ # the binary is `xz`, the package is `xz-utils`.
748
+ "xz": "sudo apt-get install -y xz-utils",
749
+ "wget": "sudo apt-get install -y wget",
750
+ },
751
+ LINUX_PM_DNF: {
752
+ "git": "sudo dnf install -y git",
753
+ "cmake": "sudo dnf install -y cmake",
754
+ "python3": "sudo dnf install -y python3",
755
+ # No `ninja` -- see this function's own docstring.
756
+ "xz": "sudo dnf install -y xz",
757
+ "wget": "sudo dnf install -y wget",
758
+ },
759
+ },
760
+ MACOS: {
761
+ "git": "brew install git",
762
+ "cmake": "brew install cmake",
763
+ "python3": "brew install python3",
764
+ "ninja": "brew install ninja",
765
+ # Present even though `prerequisites.macos` does NOT list `xz`/`wget`
766
+ # -- the manifest declares these commands for macOS regardless, and
767
+ # this table is byte-pinned to it. A user who needs them (an SDK
768
+ # predating `prerequisites.macos`, so macOS reads the POSIX list)
769
+ # gets the `brew` line rather than Linux's `apt-get`.
770
+ "xz": "brew install xz",
771
+ "wget": "brew install wget",
772
+ },
773
+ WINDOWS: {
774
+ "git": "winget install -e --id Git.Git",
775
+ "cmake": "winget install -e --id Kitware.CMake",
776
+ "python": "winget install -e --id Python.Python.3.12",
777
+ "ninja": "winget install -e --id Ninja-build.Ninja",
778
+ # NOT in `prerequisites.windows` -- the manifest declares an install
779
+ # command for a tool it does not require. `7z` is what unpacks the
780
+ # Zephyr SDK's native-Windows hosttools archive, so a Windows user
781
+ # who takes that optional step needs the line; bootstrap.ps1 itself
782
+ # does not. Carried because this table is byte-pinned to the
783
+ # manifest's `prerequisites.install.windows`, not to its tool LIST.
784
+ "7zip": "winget install -e --id 7zip.7zip",
785
+ },
786
+ }
787
+
788
+
789
+ def normalize_linux_install(raw: Any) -> dict[str, dict[str, str]]:
790
+ """`prerequisites.install.linux`, canonicalised to PACKAGE MANAGER -> tool
791
+ -> command, whichever of the two shapes alp-sdk declared it in.
792
+
793
+ **New shape** (alp-sdk#1464, `dev` @ `7a419865` on): `{"apt": {tool:
794
+ command}, "dnf": {tool: command}}` -- detected structurally, by every
795
+ top-level value being a dict, not by key name, so a package manager this
796
+ parser has never heard of is still readable rather than silently dropped.
797
+
798
+ **Legacy shape** (every manifest before alp-sdk#1471): a FLAT tool ->
799
+ command map, unconditionally Debian's -- that key had no other content
800
+ until #1464 gave it a package-manager dimension at all. Read here AS
801
+ `apt`'s sub-map (design decision (4)): a NEW tan against an OLD
802
+ `--sdk-root` still works on a real apt host exactly as it always did, and
803
+ `select_linux_install` never hands the same data out to a dnf/pacman/other
804
+ caller -- read what was always there, under the key it was implicitly
805
+ for, and let `detect_linux_pm` decide who may see it.
806
+
807
+ Empty/absent/malformed input returns `{}` -- callers fall back to
808
+ `_fallback_install_commands()[LINUX]`, matching every other per-OS gap in
809
+ `_resolve_install_commands`.
810
+ """
811
+ if not isinstance(raw, dict) or not raw:
812
+ return {}
813
+ if all(isinstance(v, dict) for v in raw.values()):
814
+ out: dict[str, dict[str, str]] = {}
815
+ for pm, tools in raw.items():
816
+ if not isinstance(pm, str) or not isinstance(tools, dict):
817
+ continue
818
+ clean = {k: v for k, v in tools.items() if isinstance(k, str) and isinstance(v, str)}
819
+ if clean:
820
+ out[pm] = clean
821
+ return out
822
+ # Legacy flat shape -- read as `apt`'s sub-map (see the docstring above).
823
+ clean = {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)}
824
+ return {LINUX_PM_APT: clean} if clean else {}
825
+
826
+
827
+ def select_linux_install(normalized: dict[str, dict[str, str]], pm: str | None) -> dict[str, str]:
828
+ """The tool -> command map for ONE package manager, out of
829
+ `normalize_linux_install`'s output. `pm=None` -- no package manager
830
+ confirmed on this host (`detect_linux_pm` found neither `apt-get` nor
831
+ `dnf`, or found a third one this manifest ships no sub-map for, e.g.
832
+ Arch's `pacman`) -- is `{}`, never a different PM's data: the whole point
833
+ of keying `install.linux` by package manager is that one PM's shape must
834
+ never stand in for another's."""
835
+ if pm is None:
836
+ return {}
837
+ sub = normalized.get(pm)
838
+ return dict(sub) if isinstance(sub, dict) else {}
839
+
840
+
841
+ def detect_linux_pm(available: Callable[[str], bool]) -> str | None:
842
+ """Which package-manager sub-map to read under `install.linux`
843
+ (alp-sdk#1464 / tan-cli#760) -- `apt` checked before `dnf`, `pacman`
844
+ never probed.
845
+
846
+ **Order and fallthrough MUST agree with alp-sdk's own two detectors** --
847
+ `scripts/bootstrap.sh`'s `LINUX_PM` block and `scripts/alp_cli/doctor.py`'s
848
+ `_prereq_linux_pm()` -- both probe `apt-get` before `dnf`, neither probes
849
+ `pacman`, both fall through to a host-neutral degrade when neither
850
+ resolves. A third detector that disagrees with those two is a future
851
+ drift bug: keep this order in lockstep with those two by hand; there is
852
+ no shared gate across the two repos that would.
853
+
854
+ `pacman` is deliberately never probed: `install.linux` ships no `pacman`
855
+ sub-map at all (an unattended `pacman -Sy` risks a partial upgrade) --
856
+ detecting it would only ever resolve to the same `None` below.
857
+
858
+ Pure: `available` is injected (`on_path(binary) is not None`), matching
859
+ every other PATH check in this module -- see `_confirmed`.
860
+ """
861
+ if available("apt-get"):
862
+ return LINUX_PM_APT
863
+ if available("dnf"):
864
+ return LINUX_PM_DNF
865
+ return None
866
+
867
+
868
+ def _resolve_install_commands(
869
+ declared: Any,
870
+ ) -> dict[str, dict[str, str] | dict[str, dict[str, str]]]:
871
+ """`prerequisites.install` as parsed, with each EMPTY per-OS map replaced by
872
+ the fallback's.
873
+
874
+ PER OS, not whole-subtree: `install: {}` -- or one carrying `windows` alone
875
+ -- is indistinguishable from an absent key after parsing, and filling only
876
+ the whole subtree would hand the absent OSes empty maps. On Windows that is
877
+ the real pre-#959 loss: all four `winget` lines vanish. Emptiness is the
878
+ signal because a SERVED OS map is never legitimately empty (the producer's
879
+ schema requires its keys to equal `prerequisites.<os>`).
880
+
881
+ Degrade, do not refuse: every shape handled here is out of contract today,
882
+ and a `ValidationFailure` on a manifest field fails the whole
883
+ `tan bootstrap` run -- which is the step a customer has to get through
884
+ before `tan build` or `tan run` can work at all, since neither of those
885
+ bootstraps implicitly (tan-cli#427).
886
+
887
+ `LINUX` is handled separately (tan-cli#760's second half):
888
+ `normalize_linux_install` reconciles both shapes `install.linux` has ever
889
+ had into one PM-keyed map, so `BootstrapFacts.install[LINUX]` is ALWAYS
890
+ `{pm: {tool: command}}`.
891
+ """
892
+ fallback = _fallback_install_commands()
893
+ if not isinstance(declared, dict):
894
+ return fallback
895
+ out: dict[str, dict[str, str] | dict[str, dict[str, str]]] = {}
896
+ for host in (MACOS, WINDOWS):
897
+ node = declared.get(host)
898
+ clean = (
899
+ {k: v for k, v in node.items() if isinstance(k, str) and isinstance(v, str)}
900
+ if isinstance(node, dict)
901
+ else {}
902
+ )
903
+ out[host] = clean or fallback[host]
904
+ out[LINUX] = normalize_linux_install(declared.get(LINUX)) or fallback[LINUX]
905
+ return out
906
+
907
+
908
+ def fallback_facts(min_python: tuple[int, int]) -> BootstrapFacts:
909
+ """The hand-ported facts, for an SDK with no `metadata/bootstrap.json`.
910
+
911
+ LAST-KNOWN values transcribed from the pre-#917 scripts. The manifest wins
912
+ outright when present, so an SDK-side pin bump reaches tan without a tan
913
+ release; `check_bootstrap_manifest.py` does not scan this file, so treat
914
+ every literal below as stale-by-default.
915
+ """
916
+ return BootstrapFacts(
917
+ zephyr_version=ZEPHYR_VERSION,
918
+ zephyr_requirements_path="zephyr/scripts/requirements.txt",
919
+ venv_dir_name=".venv",
920
+ venv_posix_bin_dir="bin",
921
+ venv_windows_bin_dir="Scripts",
922
+ # `ninja` is POSIX too, not Windows-only: Zephyr picks Ninja as its
923
+ # default CMake generator on every host, so a POSIX box without it fails
924
+ # `west build` with a CMake error naming nothing useful. `xz`/`wget`
925
+ # joined the list at alp-sdk v0.14.0, which also split `macos` out
926
+ # WITHOUT them -- a stock macOS host has neither.
927
+ prerequisites_posix=("git", "cmake", "python3", "ninja", "xz", "wget"),
928
+ prerequisites_macos=("git", "cmake", "python3", "ninja"),
929
+ prerequisites_windows=("git", "cmake", "python", "ninja"),
930
+ python_min_version=min_python,
931
+ # Transcribed from `contract/fixtures/bootstrap/manifest.json`'s
932
+ # `zephyr.pythonMinVersion`, same as `zephyr_version` above -- held to
933
+ # the vendored fixture by
934
+ # `test_the_fallback_constants_match_the_real_manifest_field_for_field`,
935
+ # NOT derived from `min_python` (the caller's `prerequisites.
936
+ # pythonMinVersion`): the two keys are independently-declared numbers
937
+ # in the real manifest ("3.12" vs "3.10" as of this writing) and must
938
+ # not be conflated here either.
939
+ zephyr_python_min_version=(3, 12),
940
+ install=_fallback_install_commands(),
941
+ # No fallback provenance, deliberately: an SDK with no manifest at all
942
+ # publishes no licensing claim, and transcribing one here would be tan
943
+ # ASSERTING a licence nobody handed it -- the one thing the `null`
944
+ # spelling exists to avoid (tan-cli#1066).
945
+ artifact_provenance={},
946
+ west_pip_spec=WEST_REQUIREMENT,
947
+ west_init_args=("init", "-l"),
948
+ west_update_args=("update", "--narrow", "-o=--depth=1"),
949
+ west_export_args=("zephyr-export",),
950
+ west_extension_guard="alp-migrate",
951
+ pip_bootstrap_upgrade=("pip", "wheel"),
952
+ pip_sdk_extras=("jsonschema", "imgtool"),
953
+ pip_editable_install=TOKEN_SDK_ROOT,
954
+ env=(
955
+ ("ZEPHYR_BASE", f"{TOKEN_WORKSPACE_DIR}/zephyr"),
956
+ ("ZEPHYR_TOOLCHAIN_VARIANT", "zephyr"),
957
+ ),
958
+ # The note arrays are transcribed VERBATIM, intra-line padding included:
959
+ # the manifest carries the `->` column alignment, and re-wrapping here
960
+ # would make the fallback print differently from the manifest path.
961
+ hint_linux=NativeLibHint(
962
+ note=(
963
+ "libmosquitto-dev -> alp_mqtt_* (cleartext + TLS)",
964
+ "libasound2-dev -> alp_audio_*",
965
+ "libssl-dev -> alp_hash_* / alp_aead_* / alp_random_bytes",
966
+ ),
967
+ command=(
968
+ "sudo apt-get install -y libmosquitto-dev libasound2-dev libssl-dev "
969
+ "pkg-config"
970
+ ),
971
+ ),
972
+ hint_macos=NativeLibHint(
973
+ note=(
974
+ "Equivalents via Homebrew:",
975
+ "mosquitto -> alp_mqtt_* (cleartext + TLS)",
976
+ "macOS uses CoreAudio rather than ALSA, so the Yocto audio backend "
977
+ "doesn't apply on macOS hosts.",
978
+ "OpenSSL ships with macOS.",
979
+ ),
980
+ command="brew install mosquitto pkg-config",
981
+ ),
982
+ hint_windows=NativeLibHint(
983
+ note=(
984
+ "Under Git Bash / MSYS2 the Yocto-side backends aren't intended to run "
985
+ "-- the canonical use is WSL2 + Ubuntu with the linux command above; "
986
+ "skip this step on native Windows.",
987
+ ),
988
+ command=None,
989
+ ),
990
+ manual_install_windows=(
991
+ "The Zephyr SDK (`west sdk install`) is a separate, manual, one-time "
992
+ "install on native Windows -- not auto-installed by bootstrap.ps1. It is "
993
+ "the one every Zephyr-on-M customer needs: it provides the "
994
+ "`arm-zephyr-eabi` cross toolchain the real-silicon build (`west build` / "
995
+ "`west flash`) actually uses. Run it from your west workspace's top-level "
996
+ "directory -- the alp-sdk checkout's parent directory -- after this script "
997
+ "completes.",
998
+ "7-Zip must already be on PATH before running `west sdk install` on native "
999
+ "Windows: west delegates .7z extraction to patoolib, which shells out to "
1000
+ "an external 7z/7za/7zr/7zz/7zzs/unar binary and has no pure-Python "
1001
+ "fallback.",
1002
+ "The Zephyr SDK's native-Windows hosttools bundle ships neither `dtc` nor "
1003
+ "`gperf` (verified: `hosttools_windows-x86_64.7z`, sdk-ng v1.0.1, "
1004
+ "sha256-checked against upstream's own sha256.sum -- 1486 entries via "
1005
+ "`7z l`, zero dtc/gperf/device-tree matches -- while the equivalent Linux "
1006
+ "hosttools archive does ship `dtc`). Both are separate, manual installs on "
1007
+ "native Windows if you need them (see docs/cross-platform-setup.md); "
1008
+ "WARN-only in `alp doctor` (`_check_dtc` / `_check_gperf`) -- not required "
1009
+ "by bootstrap.ps1.",
1010
+ "The Arm GNU Toolchain (`arm-none-eabi-gcc`) is a SEPARATE manual install, "
1011
+ "needed by three opt-in paths -- rebuilding the GD32 bridge firmware "
1012
+ "(custom-carrier bring-up or bridge recovery), building the CC3501E bridge "
1013
+ "firmware's silicon-free stub target (its production image builds with TI "
1014
+ "ticlang, not this toolchain), or hand-writing bare-metal firmware for a "
1015
+ "real M-class core -- most customers never touch any of them, since the "
1016
+ "GD32G553 ships pre-flashed by Alp Lab (rebuilding it is optional and "
1017
+ "fully open, see docs/gd32-bridge.md). Installer EXE: "
1018
+ "https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads (tick "
1019
+ "'Add path to environment variable' during install).",
1020
+ "native_sim / Yocto need WSL2 (docs/cross-platform-setup.md section 5).",
1021
+ ),
1022
+ # Transcribed from `contract/fixtures/bootstrap/manifest.json`, the
1023
+ # vendored producer output every fallback field is held against by
1024
+ # `test_the_fallback_constants_match_the_real_manifest_field_for_field`
1025
+ # -- this one included, since tan-cli#585 re-vendored the fixture. It
1026
+ # used to carry ONE deliberate departure: the stale fixture ended this
1027
+ # first note on `tan sdk switch`, which is in
1028
+ # `sdk_cmd.NOT_PORTED_SDK_SUBCOMMANDS` and REFUSES in this build, so
1029
+ # transcribing it verbatim failed `test_sdk_onboarding_dead_end.py`.
1030
+ # The refresh removed the conflict at its source; nothing here is
1031
+ # exempt from the field-for-field check any more.
1032
+ manual_install_posix=(
1033
+ "The Zephyr SDK (`west sdk install`) is a separate, manual, one-time install on "
1034
+ "Linux/macOS -- not auto-installed by bootstrap.sh. It is the one every Zephyr-on-M "
1035
+ "customer on Linux or Apple Silicon macOS needs (Intel Mac excepted -- see below): it "
1036
+ "provides the `arm-zephyr-eabi` cross toolchain the real-silicon build (`west build` / "
1037
+ "`west flash`) actually uses. Run it from your west workspace's "
1038
+ "top-level directory -- the alp-sdk checkout's parent directory -- after this script "
1039
+ "completes, e.g. `west sdk install --gnu-toolchains arm-zephyr-eabi --no-hosttools "
1040
+ "--install-dir \"$PWD/zephyr-sdk\"`; see docs/getting-started.md for the full one-liner "
1041
+ "and select the checkout with `tan build --sdk-root <path>` or `.alp/sdk-path`. On an "
1042
+ "Intel Mac this command has no target to install: the pinned SDK ships `macos-aarch64` "
1043
+ "only (no `macos-x86_64`), so real-silicon Zephyr builds need a Linux host instead -- "
1044
+ "`native_sim` and this bootstrap step are unaffected.",
1045
+ "The Arm GNU Toolchain (`arm-none-eabi-gcc`) is a SEPARATE manual install, needed by "
1046
+ "three opt-in paths -- rebuilding the GD32 bridge firmware (custom-carrier bring-up or"
1047
+ " bridge recovery), building the CC3501E bridge firmware's silicon-free stub target "
1048
+ "(its production image builds with TI ticlang, not this toolchain), or hand-writing "
1049
+ "bare-metal firmware for a real M-class core -- most customers never touch any of "
1050
+ "them, since the GD32G553 ships pre-flashed by Alp Lab (rebuilding it is optional and "
1051
+ "fully open, see docs/gd32-bridge.md). See docs/cross-platform-setup.md section 2.3 "
1052
+ "(Linux) / 3.4 (macOS) for the apt/brew/curl install.",
1053
+ "`west sdk install` may print \"could not find a 'file' executable, falling back to "
1054
+ "guess mime type by file extension\" -- patool's extension-based fallback works fine "
1055
+ "without it; this is WARN-only, not a bootstrap.sh prerequisite. Install `file` "
1056
+ "(`apt-get install -y file` / it ships by default on macOS) only to silence the "
1057
+ "message.",
1058
+ ),
1059
+ from_manifest=False,
1060
+ )
1061
+
1062
+
1063
+ # ---------------------------------------------------------------------------
1064
+ # The prerequisite gate's PURE half: what a refusal says
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+
1068
+ @dataclass(frozen=True)
1069
+ class MissingPrerequisite:
1070
+ """One missing host prerequisite, in the form a consumer can act on.
1071
+
1072
+ `command` is `None` -- never prose -- for a tool the manifest lists no
1073
+ command for: a consumer renders this field as something it can RUN, and
1074
+ prose in a runnable-command field is a button that fails. The generic advice
1075
+ belongs in the printed line (`hint_line`) only.
1076
+
1077
+ `provenance` (tan-cli#1066) is the same manifest's `artifactProvenance`
1078
+ entry for this tool: the tier, licence, upstream page and size alp-sdk
1079
+ v0.16.0 publishes (alplabai/alp-sdk#1574). It rides on the ENTRY rather
1080
+ than as a sibling map on `data` because the join is by `tool`, an identity
1081
+ the entry already carries -- a consumer must not have to re-join two
1082
+ arrays, and a consumer that resolved its own SDK checkout must not join
1083
+ tan's commands against a DIFFERENT checkout's provenance (a real risk:
1084
+ `tan bootstrap` relocates the alp-sdk checkout to
1085
+ `<parent>/alp-workspace/<name>`). Defaults to `UNKNOWN` -- all four fields
1086
+ `null` -- for every caller with no table to hand, which is exactly what a
1087
+ tool with no entry reports; see `tan.core.artifact_provenance`.
1088
+ """
1089
+
1090
+ tool: str
1091
+ command: str | None
1092
+ provenance: ArtifactProvenance = artifact_provenance.UNKNOWN
1093
+
1094
+ def as_dict(self) -> dict[str, str | int | None]:
1095
+ """`{tool, command}` plus provenance's four keys, ALWAYS all six: the
1096
+ provenance keys are never omitted and never defaulted, so
1097
+ `contract/doctor-data-keys.json` can declare one item shape for every
1098
+ SDK (an older one simply reports `null`s)."""
1099
+ return {
1100
+ "tool": self.tool,
1101
+ "command": self.command,
1102
+ **self.provenance.as_dict(),
1103
+ }
1104
+
1105
+
1106
+ @dataclass(frozen=True)
1107
+ class PrereqFailure:
1108
+ """A refused prerequisite gate: the `bootstrap.<code>` suffix, the message
1109
+ lines verbatim, and the structured per-tool form of them.
1110
+
1111
+ The structured half exists because the envelope's issue message is
1112
+ `" ".join(lines)` and an install command contains the same spaces the join
1113
+ used -- the split is not recoverable, so a consumer that wants "which tool,
1114
+ which command" must be HANDED it (alp-sdk-vscode#347 proved that parse dead
1115
+ and deleted it).
1116
+
1117
+ The code is per-refusal rather than one blanket `prerequisites-missing`
1118
+ because the Python-floor refusals have no missing TOOL at all -- a
1119
+ `{tool, command}` pair cannot represent "the Python you have is 3.10".
1120
+ """
1121
+
1122
+ code: str
1123
+ lines: tuple[str, ...]
1124
+ missing: tuple[MissingPrerequisite, ...] = ()
1125
+
1126
+
1127
+ #: Program names this guard refuses to accept as "the confirmed installer",
1128
+ #: even when `available` says yes -- tan-cli#760 review, MINOR 1. Every one
1129
+ #: of these is a universally-present WRAPPER that can front an absent real
1130
+ #: installer without this guard ever seeing it: `env FOO=bar apt-get ...` and
1131
+ #: `sh -c '...'` both resolve their OWN leading token to one of these, so
1132
+ #: `available("env")` (true on nearly every host) would confirm a command
1133
+ #: whose real dependency was never checked at all. Harmless against today's
1134
+ #: manifest (none of the six shipped commands use a wrapper); load-bearing
1135
+ #: the moment landing (b) adds a real per-distro command table.
1136
+ _OPAQUE_WRAPPER_BINARIES = frozenset(
1137
+ {"env", "sh", "bash", "dash", "zsh", "ksh", "eval", "exec"}
1138
+ )
1139
+
1140
+ #: Shell composition this guard cannot see past -- `apt-get update && apt-get
1141
+ #: install -y cmake` would confirm only the FIRST command, then hand out the
1142
+ #: whole two-command line as if the second had been checked too. Refuse
1143
+ #: outright on any of these rather than half-confirm (tan-cli#760 review,
1144
+ #: MINOR 1). `"&"` alone (a single backgrounding operator, e.g. `apt-get
1145
+ #: install -y cmake & rm -rf /`) already contains `"&&"` as a substring, so
1146
+ #: listing both would be redundant; `"|"` likewise already covers `"||"`.
1147
+ #: `"\n"` closes the same class of gap a real shell would hit at a bare
1148
+ #: newline (`"apt-get update\napt-get install -y cmake"`) -- tan-cli#760
1149
+ #: review round 3, NIT: both were measured to slip past the pre-round-3 list.
1150
+ #: None of today's six manifest commands use any of these.
1151
+ _SHELL_METACHARACTERS = ("&", ";", "|", "`", "$(", "\n")
1152
+
1153
+
1154
+ def leading_binary(command: str) -> str:
1155
+ """The program name a shell would actually invoke for one of the
1156
+ manifest's install one-liners -- stripping a literal leading `sudo `
1157
+ exactly as `--fix` (`doctor_cmd.run_fix`) does before it ever spawns
1158
+ anything, and parsing the rest with `shlex.split`, the SAME parser
1159
+ `run_fix` itself calls on the (sudo-stripped) command before resolving
1160
+ `argv[0]` -- so whatever this returns is provably the same token
1161
+ `run_fix` would try to resolve, quoting included, not merely a similar
1162
+ one (tan-cli#760 review, MINOR 2: `str.split` disagreed with `run_fix`'s
1163
+ `shlex.split` on a quoted path). `""` for a blank command, or one
1164
+ `shlex` cannot parse at all (an unterminated quote) -- nothing to
1165
+ confirm either way, and callers treat an empty name as "not available"
1166
+ rather than guessing at a half-token."""
1167
+ stripped = command.strip()
1168
+ if stripped.startswith("sudo "):
1169
+ stripped = stripped[len("sudo ") :].lstrip()
1170
+ if not stripped:
1171
+ return ""
1172
+ try:
1173
+ parts = shlex.split(stripped)
1174
+ except ValueError:
1175
+ return ""
1176
+ return parts[0] if parts else ""
1177
+
1178
+
1179
+ def _confirmed(command: str, available: Callable[[str], bool]) -> bool:
1180
+ """Whether `command` -- one manifest install one-liner -- is safe to hand
1181
+ a consumer as a real, runnable command on THIS host. The tan-cli#760
1182
+ guard's one decision point, shared by `confirmed_install_commands`
1183
+ (dict shape) and `confirm_missing` (`MissingPrerequisite` tuple shape)
1184
+ below -- there is exactly one place that decides this, even though there
1185
+ are now two entry points for the two shapes a caller needs it in.
1186
+
1187
+ Refuses outright, never half-confirms, on a shell metacharacter
1188
+ (`_SHELL_METACHARACTERS`) or an opaque wrapper leading binary
1189
+ (`_OPAQUE_WRAPPER_BINARIES`) -- tan-cli#760 review MINOR 1.
1190
+
1191
+ When the command is `sudo`-prefixed, `sudo` itself is confirmed too, not
1192
+ only the program after it: a stock `debian:12` image has `apt-get` but
1193
+ does NOT ship `sudo` by default, so confirming `apt-get` alone would
1194
+ still hand out a command that fails with `sudo: not found`
1195
+ (tan-cli#760 review, MINOR 3).
1196
+
1197
+ This still is not an exhaustive runnability proof -- a PATH-shadowing
1198
+ binary, a permissions error, an environment quirk are all beyond what a
1199
+ PATH lookup can promise. What it DOES guarantee: every program name this
1200
+ command would need to spawn is confirmed present, or the command is
1201
+ refused rather than handed out as if it were checked."""
1202
+ if any(meta in command for meta in _SHELL_METACHARACTERS):
1203
+ return False
1204
+ stripped = command.strip()
1205
+ needs_sudo = stripped.startswith("sudo ")
1206
+ binary = leading_binary(command)
1207
+ if not binary or binary in _OPAQUE_WRAPPER_BINARIES:
1208
+ return False
1209
+ if needs_sudo and not available("sudo"):
1210
+ return False
1211
+ return available(binary)
1212
+
1213
+
1214
+ def confirmed_install_commands(
1215
+ install: dict[str, str], available: Callable[[str], bool]
1216
+ ) -> dict[str, str]:
1217
+ """`install`, keeping only the entries `_confirmed` (above) accepts on
1218
+ THIS host -- the tan-cli#760 guard's dict-shaped entry point.
1219
+
1220
+ Every `MissingPrerequisite.command` this port emits is built by looking a
1221
+ tool up in a dict shaped exactly like `install` (`_structured_missing`,
1222
+ `doctor_cmd.prerequisites_check`) or, for a `PrereqFailure` already built
1223
+ (`posix_venv_unusable()`), via `confirm_missing`'s tuple-shaped twin
1224
+ below -- `_confirmed` is the one decision both apply. Measured on
1225
+ `fedora:42`/`archlinux:latest`/`rockylinux:9`: alp-sdk's Linux table is
1226
+ six `sudo apt-get install -y ...` lines and none of those hosts has
1227
+ `apt-get` on PATH, so every entry is dropped here and the tool downgrades
1228
+ to `command: null` everywhere that dict then feeds the CUSTOMER-FACING
1229
+ surfaces -- `data.missingPrerequisites[].command`, which a human reads
1230
+ and `alp-sdk-vscode`'s Fix button sends verbatim to a terminal. (`tan
1231
+ doctor --fix` itself does its OWN PATH resolution and must NOT read a
1232
+ guarded dict -- see `doctor_cmd.prerequisites_check`'s `fix_missing`.)
1233
+
1234
+ Pure: `available` is injected rather than a real PATH probe, keeping this
1235
+ module's "no IO" contract (see the module docstring). Callers pass the
1236
+ real check -- `on_path(binary) is not None` -- from `bootstrap_cmd`/
1237
+ `doctor_cmd`, both of which already own a hardened PATH walk."""
1238
+ return {
1239
+ tool: command for tool, command in install.items() if _confirmed(command, available)
1240
+ }
1241
+
1242
+
1243
+ def confirm_missing(
1244
+ missing: tuple[MissingPrerequisite, ...], available: Callable[[str], bool]
1245
+ ) -> tuple[MissingPrerequisite, ...]:
1246
+ """`missing` -- an already-built `PrereqFailure.missing`/`{tool, command}`
1247
+ sequence, such as `posix_venv_unusable()` returns -- with any entry
1248
+ `_confirmed` (above) rejects degraded to `command: None`. The tuple-
1249
+ shaped twin of `confirmed_install_commands`, for a caller that already
1250
+ has `MissingPrerequisite` objects rather than the `install` dict they
1251
+ were built from (tan-cli#760 review MAJOR 2 / tan-cli#765:
1252
+ `posix_venv_unusable()`'s hardcoded `sudo apt-get install -y
1253
+ python3-venv` reached the envelope completely unguarded before this
1254
+ existed -- the confirmation guard was never actually "the ONE place"
1255
+ that decided what got handed out; this closes that gap without changing
1256
+ `posix_venv_unusable`'s own signature).
1257
+
1258
+ An entry whose `command` is already `None` passes through unchanged --
1259
+ nothing to confirm, and re-checking it would be a wasted PATH probe."""
1260
+ out: list[MissingPrerequisite] = []
1261
+ for m in missing:
1262
+ if m.command is not None and not _confirmed(m.command, available):
1263
+ # `replace`, not `MissingPrerequisite(m.tool, None)`: this guard
1264
+ # nulls the COMMAND and nothing else. Rebuilding the entry from two
1265
+ # of its fields silently dropped the third the moment `provenance`
1266
+ # existed (tan-cli#1066) -- so an unconfirmable installer would
1267
+ # have cost the customer the licence/tier the consent screen needs
1268
+ # to render, on exactly the hosts (a Fedora box reading alp-sdk's
1269
+ # apt table) where this guard fires.
1270
+ out.append(replace(m, command=None))
1271
+ else:
1272
+ out.append(m)
1273
+ return tuple(out)
1274
+
1275
+
1276
+ def _structured_missing(
1277
+ missing: list[str],
1278
+ install: dict[str, str],
1279
+ provenance: dict[str, ArtifactProvenance] | None = None,
1280
+ ) -> tuple[MissingPrerequisite, ...]:
1281
+ """The `{tool, command, <provenance>}` entries for a refusal.
1282
+
1283
+ `provenance` defaults to `None` -- "no table", indistinguishable
1284
+ downstream from "no entry for this tool" (`artifact_provenance.for_tool`)
1285
+ -- so a caller that has not resolved a manifest (and every pre-tan-cli#1066
1286
+ unit test of the refusal builders) keeps reporting the same `null`s an SDK
1287
+ predating `artifactProvenance` yields."""
1288
+ return tuple(
1289
+ MissingPrerequisite(
1290
+ tool, install.get(tool), artifact_provenance.for_tool(provenance, tool)
1291
+ )
1292
+ for tool in missing
1293
+ )
1294
+
1295
+
1296
+ def hint_line(tool: str, install: dict[str, str]) -> str:
1297
+ """The printed report line for one missing Windows prerequisite. A tool the
1298
+ manifest lists no command for gets generic ADVICE rather than being dropped
1299
+ -- which is why this is separate from `_structured_missing` and not an
1300
+ `or` over the same lookup. The rendering (two-space indent, ` -> ` with
1301
+ two spaces each side) is `bootstrap.ps1`'s and must stay byte-identical."""
1302
+ command = install.get(tool)
1303
+ if command is not None:
1304
+ return f" {tool} -> {command}"
1305
+ return f" {tool} -> install `{tool}` and put it on PATH"
1306
+
1307
+
1308
+ #: tan-cli#355, added as a SECOND line on the refusals below -- the oracle's own
1309
+ #: first line is left byte-identical. See `posix_refusal` for why.
1310
+ #:
1311
+ #: tan-cli#650: both hints below now carry the SAME parenthetical --
1312
+ #: `--fix` mutates the host, so it goes through the identical `can_prompt`
1313
+ #: consent gate (`tan.core.consent`) every other host mutation in this CLI
1314
+ #: does, and that gate refuses outright with no TTY on either `stdin` or
1315
+ #: `stderr` -- piped, redirected, or CI, exactly the shape a Dockerfile `RUN`
1316
+ #: or an onboarding script has. Measured in a clean `ubuntu:24.04` container
1317
+ #: (tan-cli#650): `tan doctor --build --fix` with no TTY attached exits 4 and
1318
+ #: changes nothing. This hint is often the FIRST thing a customer reads about
1319
+ #: `--fix`, before they have tried it -- recommending it with no caveat there
1320
+ #: reads as a working remedy, which for a scripted/CI caller it is not.
1321
+ _DOCTOR_FIX_HINT = (
1322
+ "Or run `tan doctor --build --fix` (needs a real, interactive terminal) "
1323
+ "to install them from the SDK's manifest."
1324
+ )
1325
+
1326
+ #: tan-cli#370. The line above is TRUE only where the manifest's own install
1327
+ #: commands need no elevation OR the caller is already root. alp-sdk's
1328
+ #: `prerequisites.install.linux` is six `sudo apt-get install -y ...` entries;
1329
+ #: `doctor --build --fix` never spawns the `sudo` PROGRAM itself
1330
+ #: (`doctor_cmd.fix_needs_sudo_check`, `doctor.fix-needs-sudo`) -- under
1331
+ #: `--format json` this process's stdio is captured end to end and a password
1332
+ #: prompt would hang forever rather than fail loudly -- but tan-cli#650 made
1333
+ #: it root-aware: for a caller whose effective UID is already 0 (a root
1334
+ #: container, most CI base images, a fresh cloud VM) there is no elevation
1335
+ #: left to acquire, so `--fix` strips the manifest's literal `sudo ` word and
1336
+ #: runs the rest of the line directly. A non-root Linux caller still gets
1337
+ #: NOTHING installed there, only the exact command printed per tool.
1338
+ #:
1339
+ #: Keyed on the COMMANDS, not on the platform: macOS is POSIX and its `brew
1340
+ #: install ...` needs no elevation, so it earns the plain wording, and Windows
1341
+ #: `winget` (user-scope) does too. Reading the commands is also what keeps this
1342
+ #: correct against a manifest nobody here has seen.
1343
+ _DOCTOR_FIX_HINT_NEEDS_ELEVATION = (
1344
+ "Or run `tan doctor --build --fix` (needs a real, interactive terminal): "
1345
+ "it prints the exact command for each tool from the SDK's manifest, runs "
1346
+ "the ones needing no elevation, and -- when already running as root -- "
1347
+ "runs the `sudo`-prefixed ones too, without ever spawning `sudo` itself."
1348
+ )
1349
+
1350
+
1351
+ def _doctor_fix_hint(missing: list[str], install: dict[str, str]) -> str:
1352
+ """Which of the three hints is true for THESE tools on THIS host.
1353
+
1354
+ Only the commands reachable for the MISSING tools are considered: a `sudo`
1355
+ entry belonging to some tool that is already present says nothing about
1356
+ what `--fix` will do in this run. A tool the manifest has no command for
1357
+ cannot need elevation either -- it contributes generic advice, not a spawn.
1358
+
1359
+ tan-cli#760 adds the third shape. `install` here has already been through
1360
+ `confirmed_install_commands`, so by the time this runs "no entry for tool
1361
+ X" means "no command this host can actually run" -- which, when it is
1362
+ true of EVERY missing tool, means `--fix` cannot INSTALL anything for
1363
+ them here (it may still report a diagnostic, e.g. `tan doctor --fix`'s
1364
+ own `doctor.fix-installer-not-found` -- this hint is about the remedy,
1365
+ not that separate command's full behaviour). The two hints above both
1366
+ promise an install ("it prints the exact command for each tool from the
1367
+ SDK's manifest"), which is exactly the unrunnable-remedy defect this
1368
+ guard exists to close, just moved from the structured field into this
1369
+ prose line instead. Name the tools that are missing; a guessed package
1370
+ NAME here would be the identical defect against a different OS/distro
1371
+ (see `zephyr_requirements_hint` for the precedent)."""
1372
+ confirmed = [tool for tool in missing if install.get(tool) is not None]
1373
+ if not confirmed:
1374
+ return (
1375
+ "`tan doctor --build --fix` has no confirmed install command for "
1376
+ f"this host: {', '.join(missing)}. Install them with your OS's "
1377
+ "package manager and put them on PATH, then re-run."
1378
+ )
1379
+ return (
1380
+ _DOCTOR_FIX_HINT_NEEDS_ELEVATION
1381
+ if any(install.get(tool, "").split(maxsplit=1)[:1] == ["sudo"] for tool in missing)
1382
+ else _DOCTOR_FIX_HINT
1383
+ )
1384
+
1385
+
1386
+ def windows_refusal(
1387
+ missing: list[str],
1388
+ install: dict[str, str],
1389
+ provenance: dict[str, ArtifactProvenance] | None = None,
1390
+ ) -> PrereqFailure:
1391
+ """`bootstrap.ps1`'s `$Prereqs` loop: header, one `hint_line` each, the
1392
+ reopen-PowerShell tail.
1393
+
1394
+ `provenance` reaches only the STRUCTURED half (`missingPrerequisites[]`),
1395
+ never the printed lines -- tan-cli#1066 carries a licensing fact for a
1396
+ consumer's consent screen; it does not re-word a refusal the oracle's
1397
+ wording is pinned against."""
1398
+ lines = ["Missing required tools:"]
1399
+ lines.extend(hint_line(tool, install) for tool in missing)
1400
+ lines.append("Install the tools above (then reopen PowerShell) and re-run.")
1401
+ # tan-cli#355: same gap as the POSIX refusal -- name the installer tan ships.
1402
+ # The Windows wording is tan's own (it already carries per-tool hints the
1403
+ # POSIX one may not), so this is an addition, not a divergence.
1404
+ # tan-cli#370: which of the two hints is true depends on the commands, not
1405
+ # on the platform -- Windows `winget` is user-scope today, but the manifest
1406
+ # is alp-sdk's to change and this reads what it actually says.
1407
+ lines.append(_doctor_fix_hint(missing, install))
1408
+ return PrereqFailure(
1409
+ "prerequisites-missing",
1410
+ tuple(lines),
1411
+ _structured_missing(missing, install, provenance),
1412
+ )
1413
+
1414
+
1415
+ def posix_refusal(
1416
+ missing: list[str],
1417
+ install: dict[str, str],
1418
+ provenance: dict[str, ArtifactProvenance] | None = None,
1419
+ ) -> PrereqFailure:
1420
+ """`bootstrap.sh`'s one line: the tool names and nothing else -- TWO spaces
1421
+ before "Install". The oracle prints no per-tool commands and neither may
1422
+ this; alp-sdk#959 changed what the STRUCTURED half carries, not what a POSIX
1423
+ user reads.
1424
+
1425
+ **tan-cli#355 adds a SECOND line, and only a second line.** The oracle's
1426
+ first line is still emitted byte for byte, two spaces and all, and a parity
1427
+ test pins it so the match stays provable. What is added is the sentence
1428
+ naming `tan doctor --build --fix`.
1429
+
1430
+ A DELIBERATE divergence from the oracle, recorded here so nobody restores
1431
+ the silence. "The oracle prints no per-tool commands and neither may this"
1432
+ was right when tan had no installer of its own; tan-cli#91 changed that
1433
+ fact, and `doctor --build --fix` now runs exactly the manifest-owned
1434
+ install commands these missing tools need. Measured in a pristine
1435
+ `ubuntu:24.04`, a first-time customer got
1436
+
1437
+ Missing required tools: cmake ninja xz wget. Install them and re-run.
1438
+
1439
+ and nothing else, while the command that would install them sat one
1440
+ subcommand away, unmentioned. Withholding a remedy tan HAS, to match an
1441
+ oracle that never had one, is parity serving nobody.
1442
+
1443
+ The per-tool commands themselves still stay OUT of the prose -- that half of
1444
+ the original constraint holds, and they remain where alp-sdk#959 put them,
1445
+ in the structured payload's `{tool, command}` pairs -- which is also where
1446
+ tan-cli#1066's `provenance` goes, and only there (see `windows_refusal`)."""
1447
+ return PrereqFailure(
1448
+ "prerequisites-missing",
1449
+ (
1450
+ f"Missing required tools: {' '.join(missing)}. Install them and re-run.",
1451
+ _doctor_fix_hint(missing, install),
1452
+ ),
1453
+ _structured_missing(missing, install, provenance),
1454
+ )
1455
+
1456
+
1457
+ def windows_python_not_runnable(install: dict[str, str]) -> PrereqFailure:
1458
+ """Windows: `python` is on PATH but did not run -- the Microsoft Store alias
1459
+ prints nothing (`bootstrap.ps1`'s `$PyVer` check).
1460
+
1461
+ Its own code, not `prerequisites-missing`: there is no missing tool here and
1462
+ no `{tool, command}` pair that could carry the fix, so the install command
1463
+ reaches the user through the PROSE -- which is exactly why the package ID in
1464
+ it comes from `prerequisites.install.windows` like every other one. A
1465
+ hardcoded `Python.Python.3.12` here would be a second copy of a manifest
1466
+ fact sitting beside a correct read of it.
1467
+ """
1468
+ command = install.get("python")
1469
+ if command is not None:
1470
+ line = (
1471
+ f"python did not run (Windows Store alias?). Install real Python: "
1472
+ f"{command}, reopen PowerShell, re-run."
1473
+ )
1474
+ else:
1475
+ # Only reachable for an out-of-contract manifest: the schema requires
1476
+ # `install.windows`' keys to equal `prerequisites.windows`, which lists
1477
+ # `python`. Degrade the sentence rather than inventing a package ID.
1478
+ line = (
1479
+ "python did not run (Windows Store alias?). Install a real Python 3, "
1480
+ "reopen PowerShell, re-run."
1481
+ )
1482
+ return PrereqFailure("python-not-runnable", (line,))
1483
+
1484
+
1485
+ def posix_python_not_runnable() -> PrereqFailure:
1486
+ """POSIX: `python3` is on PATH but did not run -- the only failure this port
1487
+ adds over `bootstrap.sh`, which would have hit it one step later at
1488
+ `python3 -m venv`."""
1489
+ return PrereqFailure(
1490
+ "python-not-runnable",
1491
+ ("python3 is on PATH but did not run. Install a working Python 3 and re-run.",),
1492
+ )
1493
+
1494
+
1495
+ def python_too_old(
1496
+ found: tuple[int, int],
1497
+ floor: tuple[int, int],
1498
+ install: dict[str, str],
1499
+ *,
1500
+ floor_source: str,
1501
+ manifest_floor: tuple[int, int] | None = None,
1502
+ ) -> PrereqFailure:
1503
+ """A working interpreter below the EFFECTIVE floor.
1504
+
1505
+ **This is the customer-facing fix, not a port.** The oracle refuses here on
1506
+ Windows only and against the MANIFEST's floor
1507
+ (`crates/tan-cli/src/commands/bootstrap/steps.rs`, whose POSIX branch states
1508
+ outright *"this branch cannot fail on version"*). Three facts compose into a
1509
+ silent failure: `metadata/bootstrap.json:16` declares
1510
+ `"pythonMinVersion": "3.10"`; Zephyr's `cmake/modules/python.cmake:14` sets
1511
+ `set(PYTHON_MINIMUM_REQUIRED 3.12)`; Ubuntu 22.04 ships `python3` = 3.10. So
1512
+ today `tan bootstrap` succeeds, and the customer's FIRST build dies inside
1513
+ Zephyr's CMake configure with an error naming Zephyr rather than us. The
1514
+ floor enforced here is therefore the EFFECTIVE one -- the higher of the two
1515
+ -- on BOTH platforms, the same floor `tan doctor` already reports
1516
+ (`tan.commands.doctor_cmd.python_check`, via the same
1517
+ `zephyr_python_floor`).
1518
+
1519
+ Tool-less, so the install command travels in the prose. `floor_source` names
1520
+ WHERE the number came from, and `manifest_floor` (when it is lower) names
1521
+ the skew -- otherwise a customer refused at 3.11 greps the manifest, reads
1522
+ `3.10`, and concludes tan is broken.
1523
+
1524
+ The manifest's install command is SUPPRESSED in the skew case, deliberately.
1525
+ That command is scoped to the manifest's OWN floor, so it cannot be trusted
1526
+ to deliver a higher one: on the host this whole fix exists for -- Ubuntu
1527
+ 22.04 -- `sudo apt-get install -y python3` installs 3.10, which is exactly
1528
+ the version being refused. Printing it would send the customer round a loop.
1529
+ """
1530
+ skewed = manifest_floor is not None and manifest_floor < floor
1531
+ verdict = (
1532
+ f"Python {found[0]}.{found[1]} found; the SDK tooling needs "
1533
+ f">= {floor[0]}.{floor[1]}"
1534
+ )
1535
+ command = None if skewed else (install.get("python") or install.get("python3"))
1536
+ line = f"{verdict} ({command})." if command is not None else f"{verdict}."
1537
+ line = f"{line} That floor comes from {floor_source}."
1538
+ if skewed and manifest_floor is not None:
1539
+ line = (
1540
+ f"{line} alp-sdk's {BOOTSTRAP_MANIFEST_REL_PATH} declares only "
1541
+ f"{manifest_floor[0]}.{manifest_floor[1]}, so its own install command is "
1542
+ f"not enough here -- install a Python "
1543
+ f"{floor[0]}.{floor[1]}+ and put it ahead of "
1544
+ f"{found[0]}.{found[1]} on PATH, then re-run so the workspace venv is "
1545
+ f"built with it."
1546
+ )
1547
+ return PrereqFailure("python-too-old", (line,))
1548
+
1549
+
1550
+ def python_floor_skew_warning(
1551
+ manifest_floor: tuple[int, int],
1552
+ effective_floor: tuple[int, int],
1553
+ source: str,
1554
+ from_manifest: bool = True,
1555
+ ) -> tuple[str, str] | None:
1556
+ """`(code suffix, message)` when the two declared floors disagree, else
1557
+ `None`.
1558
+
1559
+ Reported rather than silently reconciled, and worded to match
1560
+ `tan.commands.doctor_cmd.python_floor_skew_check` -- doctor raises the same
1561
+ verdict as `doctor.pythonFloor`, and two commands describing one manifest
1562
+ defect differently is the drift this port keeps hitting. Fires on a
1563
+ SUCCESSFUL run too: the host is fine and the two declared floors disagree.
1564
+
1565
+ It does NOT follow that the fix belongs in `metadata/bootstrap.json` -- this
1566
+ docstring used to say so, and the remedy below used to act on it. Raising
1567
+ `prerequisites.pythonMinVersion` was tried and REVERTED (alp-sdk#1078): the
1568
+ key is host-universal while this floor is Zephyr's, so raising it refuses a
1569
+ 3.10/3.11 host for a Yocto-only or metadata-only project that builds today.
1570
+ The skew is deliberate; the message says so and points the customer at the
1571
+ only thing that actually helps them (tan-cli#300).
1572
+
1573
+ `from_manifest=False` (pass `facts.from_manifest`) means `manifest_floor`
1574
+ never actually came from a read `metadata/bootstrap.json` -- this SDK
1575
+ predates it (`load_facts`'s `_manifest_absent_floor` branch) -- and is
1576
+ instead tan's own frozen fallback constant standing in. Claiming alp-sdk's
1577
+ manifest "declares" that number, and telling the customer to edit it, would
1578
+ send them to a file bootstrap never read.
1579
+ """
1580
+ if manifest_floor >= effective_floor:
1581
+ return None
1582
+ if from_manifest:
1583
+ claim = (
1584
+ f"alp-sdk's {BOOTSTRAP_MANIFEST_REL_PATH} declares pythonMinVersion "
1585
+ f"{manifest_floor[0]}.{manifest_floor[1]}"
1586
+ )
1587
+ # NOT "raise pythonMinVersion in the manifest". That was tried and
1588
+ # REVERTED (alp-sdk#1078): the key is host-universal, and
1589
+ # `build_readiness.rs:401` checks Python BEFORE any `os_set` branch, so
1590
+ # raising it refuses a 3.10/3.11 host for a Yocto-only or metadata-only
1591
+ # project that builds today -- and 3.12 is unreachable via the remedy
1592
+ # the manifest itself offers (`sudo apt-get install -y python3`) on the
1593
+ # Ubuntu 22.04 hosts the docs recommend. This warning fires while
1594
+ # bootstrap is REFUSING, so it is the last line a blocked user reads and
1595
+ # the likeliest thing they act on; it has to name something that helps
1596
+ # them, not an SDK edit that would make things worse (tan-cli#300).
1597
+ fix = (
1598
+ f" The skew is known and deliberately unresolved (alp-sdk#1078): the "
1599
+ f"manifest key is host-universal while this floor is Zephyr's. Nothing "
1600
+ f"to change in alp-sdk -- put a Python "
1601
+ f"{effective_floor[0]}.{effective_floor[1]} or newer on the build path."
1602
+ )
1603
+ else:
1604
+ claim = (
1605
+ f"this SDK checkout has no {BOOTSTRAP_MANIFEST_REL_PATH} to declare a floor, "
1606
+ f"so tan's own built-in floor {manifest_floor[0]}.{manifest_floor[1]} is "
1607
+ f"standing in"
1608
+ )
1609
+ fix = " Update this SDK checkout to a version that ships that manifest."
1610
+ return (
1611
+ "python-floor-skew",
1612
+ f"{claim}, but the build's effective floor is "
1613
+ f"{effective_floor[0]}.{effective_floor[1]} (from {source}). bootstrap enforces "
1614
+ f"the higher, effective floor, so a host this manifest would have accepted is "
1615
+ f"refused here rather than failing later inside Zephyr's CMake configure."
1616
+ f"{fix}",
1617
+ )
1618
+
1619
+
1620
+ # ---------------------------------------------------------------------------
1621
+ # The Python CEILING (tan-cli#285): a floor alone caught "too old"; it cannot
1622
+ # catch "too new for the ecosystem".
1623
+ # ---------------------------------------------------------------------------
1624
+
1625
+ #: The highest CPython minor tan has actually seen a full venv build clean
1626
+ #: against. STALE BY DEFAULT, exactly like `ZEPHYR_VERSION` above -- there is
1627
+ #: no `pythonMaxVersion` in `metadata/bootstrap.json` yet (it carries only the
1628
+ #: FLOOR, `pythonMinVersion`), so this is tan's own placeholder until that
1629
+ #: manifest can carry a real ceiling. Bump it only against a real run that
1630
+ #: built a complete venv on the newer minor -- not by inference.
1631
+ #:
1632
+ #: A design choice, not a mechanical value, and worth stating explicitly: this
1633
+ #: used to read `(3, 13)`, on the reasoning "3.14 broke, so one minor below it
1634
+ #: is probably fine" -- a COMPUTED guess asserted as "a MEASUREMENT, not a
1635
+ #: computed bound", which it never was (nothing in CI, `getting-started.yml`
1636
+ #: or a first-blink run has ever bootstrapped on 3.13). `(3, 12)` is what is
1637
+ #: actually measured good (every CI Python job pins it) against `(3, 14)`
1638
+ #: measured bad (the `hidapi` failure this whole mechanism exists to warn
1639
+ #: about). Tightening the number to what is true is NOT the same change as
1640
+ #: tightening the gate: this stays a WARN at 3.12 exactly as it was at 3.13 --
1641
+ #: see `python_ceiling_warning`'s own docstring for why a hard refusal here
1642
+ #: would be its own defect, symmetric to the floor bug this port already
1643
+ #: fixed. A working 3.13 host still bootstraps clean either way; it now also
1644
+ #: gets told, correctly, that this port has not verified that combination.
1645
+ PYTHON_CEILING_KNOWN_GOOD = (3, 12)
1646
+
1647
+
1648
+ def python_ceiling_warning(found: tuple[int, int], venv_dir: str) -> tuple[str, str] | None:
1649
+ """`(code suffix, message)` when `found` is newer than any Python tan has
1650
+ verified a complete venv against, else `None`. `venv_dir` is the
1651
+ already-rendered (`_native`) workspace venv path, named in the remedy.
1652
+
1653
+ **Deliberately a WARN, never a refusal.** The floor check above refuses,
1654
+ because a too-OLD interpreter is a GUARANTEED failure -- Zephyr's own CMake
1655
+ configure enforces its floor unconditionally. A too-NEW interpreter is not
1656
+ guaranteed to fail at all: most projects never touch the specific optional
1657
+ dependency (`hidapi`, in the one case measured so far) that lacks a
1658
+ prebuilt wheel for it, and most hosts will bootstrap a perfectly complete
1659
+ venv anyway. Refusing a host that would have built cleanly is the same
1660
+ defect the floor fix above exists to close, mirrored onto the other edge --
1661
+ a hard ceiling that blocks a WORKING host is its own bug, not a safety
1662
+ rail. This warning exists only to give the customer the "why" up front,
1663
+ before they spend time chasing a build failure back to their interpreter
1664
+ choice; the venv-completeness check (tan-cli#285's other half) is what
1665
+ actually catches it when it happens.
1666
+ """
1667
+ if found <= PYTHON_CEILING_KNOWN_GOOD:
1668
+ return None
1669
+ return (
1670
+ "python-newer-than-verified",
1671
+ f"Python {found[0]}.{found[1]} is newer than the highest tan has verified a "
1672
+ f"complete venv against ({PYTHON_CEILING_KNOWN_GOOD[0]}."
1673
+ f"{PYTHON_CEILING_KNOWN_GOOD[1]}). Not refused -- most hosts and most projects "
1674
+ f"bootstrap cleanly on a newer Python anyway -- but a dependency with no "
1675
+ f"prebuilt wheel yet for this interpreter (hidapi is the one seen so far) can "
1676
+ f"still fall back to a source build and fail. If a later warning reports the "
1677
+ f"venv incomplete: delete {venv_dir} (there is no --recreate-venv) and re-run "
1678
+ f"`tan bootstrap` -- a REUSED venv keeps the interpreter that created it, so "
1679
+ f"installing another Python 3 alongside this one does nothing by itself. On "
1680
+ f"Windows, put that older interpreter first on PATH before re-running (or create "
1681
+ f"the venv yourself, e.g. `py -3.12 -m venv {venv_dir}`), since tan's own default "
1682
+ f"candidate is `py -3`, which resolves to the newest install.",
1683
+ )
1684
+
1685
+
1686
+ # ---------------------------------------------------------------------------
1687
+ # The pip phase's remediation hints (tan-cli#285): gated on the REAL host, not
1688
+ # assumed Linux.
1689
+ # ---------------------------------------------------------------------------
1690
+
1691
+
1692
+ def zephyr_requirements_hint(host: str) -> str:
1693
+ """The OS-gated remedy appended to the `zephyr-requirements` warning.
1694
+
1695
+ Only LINUX and WINDOWS get a named package/command below: those are the
1696
+ two hosts a real failure has actually been measured and diagnosed on (a
1697
+ stock ubuntu-24.04 CI runner; Python 3.14 on Windows, `LINK : fatal error
1698
+ LNK1104`). Printing the Linux line unconditionally used to send a Windows
1699
+ customer to run `sudo apt-get` on a host with no `apt-get` at all, and to
1700
+ misdiagnose an MSVC linker failure as a missing header. macOS/other get a
1701
+ host-neutral line rather than a GUESSED command -- printing an unverified
1702
+ package name would repeat the exact defect this fixes, just against a
1703
+ different OS.
1704
+
1705
+ None of the three text blames "the output above"/"the output" as if a
1706
+ reader can already see it: `--format json` has no terminal output at all
1707
+ -- the caller (`pip_phase`) appends the actual captured pip tail to the
1708
+ SAME message when one was captured, so "the captured pip output" here
1709
+ always names something that is either right there in the message or
1710
+ genuinely was not captured (text mode, where the child's own log already
1711
+ streamed live).
1712
+ """
1713
+ if host == WINDOWS:
1714
+ return (
1715
+ "On Windows this is usually `hidapi` with no prebuilt wheel yet for this "
1716
+ "Python, falling back to a source build that needs the MSVC linker (look "
1717
+ "for `LINK : fatal error LNK1104` in the captured pip output -- this is NOT "
1718
+ "a missing native header): install the \"Desktop development with C++\" "
1719
+ "workload from the Visual Studio Build Tools "
1720
+ "(https://visualstudio.microsoft.com/visual-cpp-build-tools/), which "
1721
+ "supplies both the linker and the Windows SDK libraries hidapi links "
1722
+ "against, then re-run `tan bootstrap`."
1723
+ )
1724
+ if host == LINUX:
1725
+ return (
1726
+ "On Linux this is usually `hidapi` needing native headers: `sudo apt-get "
1727
+ "install -y pkg-config libusb-1.0-0-dev libudev-dev`, then re-run `tan "
1728
+ "bootstrap`."
1729
+ )
1730
+ return (
1731
+ "Check the captured pip output for the real cause (often a native "
1732
+ "dependency with no prebuilt wheel for this host), then re-run `tan bootstrap`."
1733
+ )
1734
+
1735
+
1736
+ def posix_venv_unusable() -> PrereqFailure:
1737
+ """Linux: `python3` runs and clears every check above, but its `venv` module
1738
+ cannot create a usable environment because `ensurepip` is missing --
1739
+ Debian/Ubuntu split `python3-venv` out of the base `python3` package.
1740
+
1741
+ A SECOND check, deliberately not folded into the manifest's
1742
+ `prerequisites.posix` list: that list is an alp-sdk fact and `python3-venv`
1743
+ is not in it upstream. Its own code, like the Python-floor refusals -- and
1744
+ unlike them it HAS a real `{tool, command}` pair, which a Fix button needs.
1745
+
1746
+ `python3-venv`, not the version-specific `python3.NN-venv` Python's own
1747
+ error names: apt resolves the unversioned meta-package to the matching
1748
+ versioned one, and this message cannot know which minor is running.
1749
+ """
1750
+ return PrereqFailure(
1751
+ "venv-unusable",
1752
+ (
1753
+ "python3 found, but its venv module cannot create a usable virtual "
1754
+ "environment (ensurepip is missing). On Debian/Ubuntu: sudo apt-get "
1755
+ "install -y python3-venv, then re-run.",
1756
+ ),
1757
+ (MissingPrerequisite("python3-venv", "sudo apt-get install -y python3-venv"),),
1758
+ )
1759
+
1760
+
1761
+ def reported_missing(
1762
+ missing: tuple[MissingPrerequisite, ...],
1763
+ ) -> list[dict[str, str | int | None]] | None:
1764
+ """The envelope form: `None` when the refusal names no tool.
1765
+
1766
+ `[]` is NEVER a value here. The Python-floor refusals reach this empty, and
1767
+ `[]` on the wire would spell "checked, nothing missing" -- which is what a
1768
+ run that found the list clean reports, as `None`. One fact, one spelling.
1769
+ """
1770
+ return [m.as_dict() for m in missing] if missing else None
1771
+
1772
+
1773
+ # ---------------------------------------------------------------------------
1774
+ # The Yocto host gate
1775
+ # ---------------------------------------------------------------------------
1776
+
1777
+ #: Verdicts of `yocto_gate`.
1778
+ GATE_CLEAR = "clear"
1779
+ GATE_WARN = "warn"
1780
+ GATE_REFUSE = "refuse"
1781
+
1782
+
1783
+ def in_play_runtimes(
1784
+ board_cores: dict[str, str | None] | None,
1785
+ board_os: str | None,
1786
+ topology: dict[str, str],
1787
+ ) -> list[str]:
1788
+ """The distinct runtimes a project puts in play, sorted.
1789
+
1790
+ A `cores:` block IS the project's core selection: each entry resolves
1791
+ through its explicit `os:` override (`"off"` removes the core), else the
1792
+ matching topology entry, else the core-id heuristic. With no `cores:` block
1793
+ a v1 top-level `os:` wins, and failing that the whole SoM topology is in
1794
+ play.
1795
+
1796
+ `topology` empty means the SoM metadata could not be read; an empty RESULT
1797
+ means "unresolvable", which every caller must treat as "proceed".
1798
+ """
1799
+ def from_topology(core_id: str) -> str:
1800
+ return topology.get(core_id) or infer_runtime_for_core_id(core_id)
1801
+
1802
+ def declared(value: str | None) -> str | None:
1803
+ cleaned = (value or "").strip()
1804
+ return cleaned or None
1805
+
1806
+ out: set[str] = set()
1807
+ if board_cores:
1808
+ for core_id, raw in board_cores.items():
1809
+ os_value = declared(raw)
1810
+ if os_value == OS_OFF:
1811
+ continue
1812
+ out.add(os_value or from_topology(core_id))
1813
+ else:
1814
+ top_level = declared(board_os)
1815
+ if top_level is not None and top_level != OS_OFF:
1816
+ out.add(top_level)
1817
+ else:
1818
+ out.update(topology.values())
1819
+ return sorted(out)
1820
+
1821
+
1822
+ def yocto_gate(runtimes: list[str], host: str) -> str:
1823
+ """Refusal is deliberately narrow -- only a project that is *entirely* Yocto
1824
+ on a non-Linux host. Erring toward running is harmless (bootstrap is
1825
+ idempotent); erring toward refusing bricks the command.
1826
+
1827
+ The test is "every runtime in play is `yocto`" rather than "none is
1828
+ `zephyr`/`baremetal`": an unrecognised `os:` string is an unresolvable core,
1829
+ and unresolvable means proceed.
1830
+ """
1831
+ if host == LINUX or not runtimes:
1832
+ return GATE_CLEAR
1833
+ if all(r == "yocto" for r in runtimes):
1834
+ return GATE_REFUSE
1835
+ if any(r == "yocto" for r in runtimes):
1836
+ return GATE_WARN
1837
+ return GATE_CLEAR
1838
+
1839
+
1840
+ def yocto_only_refusal() -> str:
1841
+ return (
1842
+ f"every core in this project targets Yocto. {YOCTO_HOST_DETAIL} Re-run "
1843
+ f"`tan bootstrap` inside WSL2 or on a Linux host."
1844
+ )
1845
+
1846
+
1847
+ def yocto_mixed_warning() -> str:
1848
+ return (
1849
+ f"a Yocto core is in play. {YOCTO_HOST_DETAIL} The Zephyr/baremetal cores "
1850
+ f"bootstrap normally here."
1851
+ )
1852
+
1853
+
1854
+ # ---------------------------------------------------------------------------
1855
+ # `$ZEPHYR_BASE` workspace selection
1856
+ # ---------------------------------------------------------------------------
1857
+
1858
+ #: Outcomes of `decide_workspace_reuse`.
1859
+ REUSE = "reuse"
1860
+ STALE = "stale"
1861
+ MANIFEST_MISMATCH = "manifest-mismatch"
1862
+ INCOMPATIBLE = "incompatible"
1863
+
1864
+
1865
+ def decide_workspace_reuse(
1866
+ version_file: str,
1867
+ top_is_west_workspace: bool,
1868
+ manifest_is_sdk: bool,
1869
+ pin: str,
1870
+ ) -> tuple[str, str]:
1871
+ """`(choice, that tree's Zephyr version)` from already-gathered facts.
1872
+
1873
+ Untouched reuse needs ALL THREE of a `.west/` topdir, a manifest resolving
1874
+ to the SDK root, and an EXACT `MAJOR.MINOR.PATCH` match. A tree clearing the
1875
+ first two but not the third is `STALE` -- it is this SDK's own workspace, so
1876
+ `west update` against this SDK's own `west.yml` is precisely what brings it
1877
+ back to the pins, and adopting it is cheaper and less surprising than
1878
+ cloning a second Zephyr elsewhere.
1879
+
1880
+ STILL NOT COVERED: only `zephyr`'s pin is compared. A bump touching only a
1881
+ non-`zephyr` `west.yml` project (`hal_alif`, `cmsis`, `mcuboot`) leaves the
1882
+ version identical, so this still returns `REUSE`.
1883
+ """
1884
+ version = parse_zephyr_version_file(version_file)
1885
+ if version is None or not top_is_west_workspace:
1886
+ # No readable VERSION -- nothing to judge, so it cannot be adopted.
1887
+ return INCOMPATIBLE, version or ""
1888
+ if not manifest_is_sdk:
1889
+ # #769 stays version-gated: a foreign tree on some unrelated Zephyr is
1890
+ # simply not this workspace, and gets the plain "ignoring it" message.
1891
+ return (MANIFEST_MISMATCH if version == pin else INCOMPATIBLE), version
1892
+ return (REUSE if version == pin else STALE), version
1893
+
1894
+
1895
+ def parent_needs_workspace_guard(
1896
+ entries: list[str],
1897
+ checkout_name: str,
1898
+ venv_dir_name: str,
1899
+ dot_west_is_workspace: bool,
1900
+ ) -> bool:
1901
+ """Whether the checkout's parent needs the workspace-parent guard.
1902
+
1903
+ `west init -l <alp-sdk>` forces the west topdir to be the checkout's own
1904
+ PARENT, so a customer who clones into `~/Downloads` gets
1905
+ zephyr/modules/.west/venv sprayed there, unannounced, outside the checkout
1906
+ where no `.gitignore` can reach it. Proceed silently when the parent holds
1907
+ NOTHING BUT the checkout, bootstrap's OWN venv, and/or an existing west
1908
+ workspace; otherwise guard.
1909
+
1910
+ `dot_west_is_workspace` is a TYPED fact the caller computes with a
1911
+ filesystem check, never inferred from `entries` containing the literal
1912
+ `".west"`: a plain FILE named `.west` is not a workspace, and letting the
1913
+ NAME answer that was a false PROCEED. When it is true, every other entry is
1914
+ that workspace's own content.
1915
+
1916
+ Otherwise the parent is judged purely on COUNT, dotfiles included.
1917
+ Deliberately NOT a directory-NAME check (no `Downloads`/`Desktop` list): a
1918
+ name list is locale-dependent and incomplete by construction.
1919
+ """
1920
+ if dot_west_is_workspace:
1921
+ return False
1922
+ venv_top = re.split(r"[\\/]", venv_dir_name)[0] if venv_dir_name else None
1923
+ return any(entry != checkout_name and entry != venv_top for entry in entries)
1924
+
1925
+
1926
+ def resolve_workspace_target(raw: str, cwd: str) -> str:
1927
+ """Validate + absolutise `--workspace <path>`. Raises `ValueError`.
1928
+
1929
+ This relocates a customer's checkout, so an empty value (`--workspace ""`,
1930
+ the classic unset-`$WS` shell accident) or an ambiguous drive-relative one
1931
+ (an MSYS-style `/e/foo/ws` on Windows) must never resolve to a guess. Pure
1932
+ validation -- no IO.
1933
+ """
1934
+ trimmed = raw.strip()
1935
+ if not trimmed:
1936
+ raise ValueError("--workspace requires a non-empty path")
1937
+ if _is_drive_relative(trimmed):
1938
+ raise ValueError(_drive_relative(trimmed))
1939
+ # Rooted-but-driveless is decided BEFORE absoluteness, not inside it: the
1940
+ # two `isabs` oracles below disagree about this exact shape either side of
1941
+ # CPython 3.13, and the old ordering rode on that (tan-cli#1139 --
1942
+ # `_is_rooted_no_drive`).
1943
+ if _is_rooted_no_drive(trimmed):
1944
+ raise ValueError(_rooted_no_drive(trimmed))
1945
+ if os.path.isabs(trimmed) or ntpath_isabs(trimmed):
1946
+ return os.path.normpath(trimmed)
1947
+ if trimmed.startswith(("/", "\\")):
1948
+ # Not reachable through any input measured on 3.12.3/3.13.15/3.14.7: a
1949
+ # single leading separator is refused above on Windows and for any
1950
+ # `\`-led input, and is POSIX-absolute on the LINE ABOVE otherwise
1951
+ # (`/proj/x` is never refused on POSIX); a doubled one is
1952
+ # `ntpath_isabs`-absolute on all three. Kept so "a rooted path is NEVER
1953
+ # silently joined onto `cwd`" is total rather than contingent on what a
1954
+ # future `ntpath.isabs` says (tan-cli#1139).
1955
+ raise ValueError(_rooted_no_drive(trimmed))
1956
+ return os.path.normpath(os.path.join(cwd, trimmed))
1957
+
1958
+
1959
+ def _is_drive_relative(raw: str) -> bool:
1960
+ """`C:ws` -- a drive letter with NO separator after it (tan-cli#495 defect 8).
1961
+
1962
+ Windows keeps a separate current directory PER DRIVE, so `C:ws` means "`ws`
1963
+ under whatever the process's current directory on C: happens to be" -- the
1964
+ same class of ambiguity `--workspace /e/foo` is already refused for, and
1965
+ the caller is about to RELOCATE a customer's checkout into it.
1966
+
1967
+ Fixed here at the call site rather than in `ntpath_isabs`, which has two
1968
+ callers and wants the opposite answers from them. `ntpath.isabs("C:ws")`
1969
+ is False while `ntpath_isabs`'s own `^[A-Za-z]:` regex is True, and that
1970
+ disagreement is CORRECT for the other caller: `is_plain_relative` must
1971
+ reject `C:ws` as a manifest-supplied directory name, which is exactly what
1972
+ the regex buys it. Narrowing the predicate would have let `C:ws` through
1973
+ there as a "plain relative" name to be joined onto the workspace.
1974
+
1975
+ CONFIRMED post-3.13 (tan-cli#1139), which is not a given: `ntpath.isabs`
1976
+ changed in that release, but not for `"C:ws"` -- `False` on 3.12.3,
1977
+ 3.13.15 and 3.14.7 alike -- so the paragraph above still describes the
1978
+ live code. It does NOT cover the second, distinct divergence, which is
1979
+ also fixed at a call site but for a different reason; see
1980
+ `_is_rooted_no_drive`.
1981
+ """
1982
+ return bool(re.match(r"^[A-Za-z]:(?![\\/])", raw))
1983
+
1984
+
1985
+ def _is_rooted_no_drive(raw: str) -> bool:
1986
+ """`\\proj\\ws` -- a SINGLE leading separator, so a root but no drive
1987
+ (tan-cli#1139; the fourth measured `pathlib`/`ntpath` difference across
1988
+ this repo's supported range, after tan-cli#1126's three). THE canonical
1989
+ site for that measurement -- `ntpath_isabs`, `_is_drive_relative` and
1990
+ `resolve_workspace_target`'s tail comment point here, not restate it.
1991
+
1992
+ Asked as a REGEX over the string rather than as an `isabs` question, and
1993
+ ahead of `resolve_workspace_target`'s absoluteness branch, because
1994
+ `ntpath.isabs` answers this shape differently either side of CPython 3.13
1995
+ while `PureWindowsPath.is_absolute()` has said `False` throughout --
1996
+ measured, not assumed:
1997
+
1998
+ interpreter ntpath.isabs(r"\\proj\\x") PureWindowsPath(...).is_absolute()
1999
+ 3.12.3 True False
2000
+ 3.13.15 False False
2001
+ 3.14.7 False False
2002
+
2003
+ The regex is not merely SUFFICIENT for the shapes someone thought to
2004
+ probe, it is EQUIVALENT to the divergence: over a 40-input corpus, on all
2005
+ three interpreters, `ntpath.isabs` differs across the boundary iff the
2006
+ string matches `^[\\/](?![\\/])` -- zero mismatches in either direction.
2007
+ So this covers the whole class, tripled and mixed separators included.
2008
+
2009
+ `requires-python = ">=3.12"`, so both answers are live. Nested inside the
2010
+ absoluteness branch the guard inherited the split and was DEAD off Windows
2011
+ on 3.12: `--workspace \\proj\\ws` fell through to `normpath` and relocated a
2012
+ checkout into a literal `\\proj\\ws` directory instead of raising.
2013
+
2014
+ Host-aware, because the two separators are not the same fact: a leading
2015
+ `/` IS a genuine absolute path on POSIX and must keep resolving there,
2016
+ while a leading `\\` names no drive on any host and `/` names none on
2017
+ Windows. TWO leading separators are excluded -- a UNC root or a device
2018
+ path (`\\\\srv\\share`, `\\\\?\\C:\\ws`), absolute on all three.
2019
+ """
2020
+ if not re.match(r"^[\\/](?![\\/])", raw):
2021
+ return False
2022
+ return os.name == "nt" or raw[0] == "\\"
2023
+
2024
+
2025
+ def _drive_relative(trimmed: str) -> str:
2026
+ return (
2027
+ f"--workspace '{trimmed}' is drive-relative, which is ambiguous (it would "
2028
+ f"resolve against the current directory on that drive, not the drive root); "
2029
+ f"pass a full absolute path such as '{trimmed[:2]}\\{trimmed[2:]}' instead"
2030
+ )
2031
+
2032
+
2033
+ def _rooted_no_drive(trimmed: str) -> str:
2034
+ return (
2035
+ f"--workspace '{trimmed}' has a root but no drive, which is ambiguous on this "
2036
+ f"host (it would resolve against whichever drive the process happens to be "
2037
+ f"running from); pass a full absolute path instead"
2038
+ )
2039
+
2040
+
2041
+ # ---------------------------------------------------------------------------
2042
+ # `.west/config` (an ini file, read/written by hand -- west is not installed yet)
2043
+ # ---------------------------------------------------------------------------
2044
+
2045
+
2046
+ def _section_header(line: str) -> str | None:
2047
+ trimmed = line.strip()
2048
+ if trimmed.startswith("[") and trimmed.endswith("]"):
2049
+ return trimmed[1:-1].strip()
2050
+ return None
2051
+
2052
+
2053
+ def _key_value(line: str) -> tuple[str, str] | None:
2054
+ trimmed = line.lstrip()
2055
+ if not trimmed or trimmed[0] in "#;":
2056
+ return None
2057
+ key, sep, value = line.partition("=")
2058
+ if not sep or not key.strip():
2059
+ return None
2060
+ return key.strip(), value.strip()
2061
+
2062
+
2063
+ def get_manifest_path(config: str) -> str | None:
2064
+ """The `[manifest]` section's `path = ` value. Section-scoped: a `path =`
2065
+ line under a different section is never returned."""
2066
+ section = ""
2067
+ for line in config.splitlines():
2068
+ header = _section_header(line)
2069
+ if header is not None:
2070
+ section = header
2071
+ continue
2072
+ if section != "manifest":
2073
+ continue
2074
+ pair = _key_value(line)
2075
+ if pair is not None and pair[0].lower() == "path":
2076
+ return pair[1]
2077
+ return None
2078
+
2079
+
2080
+ def same_directory(a: Path, b: Path) -> bool:
2081
+ """True when `a` and `b` name the same directory. `realpath` when both exist
2082
+ (the reliable answer); a lexical `normpath`+`normcase` comparison when either
2083
+ does not -- e.g. a stale config's target SDK version since pruned.
2084
+
2085
+ Moved down from `bootstrap_cmd._same_directory` with `manifest_points_at`
2086
+ (tan-cli#495): it is pure path logic with no IO policy of its own, and
2087
+ leaving it in the command module was half of what forced the deferred
2088
+ imports described on `manifest_points_at`.
2089
+ """
2090
+ try:
2091
+ if a.exists() and b.exists():
2092
+ return os.path.realpath(a) == os.path.realpath(b)
2093
+ except OSError:
2094
+ pass
2095
+ return os.path.normcase(os.path.normpath(str(a))) == os.path.normcase(
2096
+ os.path.normpath(str(b))
2097
+ )
2098
+
2099
+
2100
+ def manifest_points_at(topdir: Path, repo_root: Path) -> bool:
2101
+ """Whether `<topdir>/.west/config`'s `[manifest] path` resolves to
2102
+ `repo_root`. west and the venv are not necessarily set up when this is
2103
+ asked, so the config is read directly rather than shelling
2104
+ `west config manifest.path`.
2105
+
2106
+ **Lives in `tan.core` because `tan.core.venv` is its heaviest caller**
2107
+ (tan-cli#495). It was defined in `tan.commands.bootstrap_cmd`, so
2108
+ `venv.py` -- a `tan.core` module -- reached UP into the command layer four
2109
+ separate times, each behind a deferred `# noqa: PLC0415` import whose only
2110
+ job was to dodge the resulting import cycle. That inverts this repo's
2111
+ layering (pure logic in `tan/core` or `tan/planner`, never the command/IO
2112
+ file), and a cycle-dodging import is a symptom to fix rather than
2113
+ annotate. `bootstrap_cmd` now imports it from here like every other
2114
+ consumer.
2115
+
2116
+ Reads leniently (`errors="replace"`), matching the reader this used to be
2117
+ handed: a `.west/config` with a mojibake byte is a config whose `path` key
2118
+ should still be found, not an exception out of a predicate.
2119
+ """
2120
+ try:
2121
+ config = (topdir / ".west" / "config").read_text(encoding="utf-8", errors="replace")
2122
+ except (OSError, ValueError):
2123
+ return False
2124
+ rel = get_manifest_path(config)
2125
+ if rel is None:
2126
+ return False
2127
+ return same_directory(topdir / rel.strip(), repo_root)
2128
+
2129
+
2130
+ def set_manifest_path(config: str, new_rel: str) -> str | None:
2131
+ """`config` with the `[manifest]` section's `path` rewritten, every other
2132
+ line byte-identical -- each line's own terminator (`\\r\\n`, `\\n`, or none
2133
+ for a final newline-less line) survives, so a CRLF `.west/config` stays
2134
+ CRLF. `None` when there is no line to replace."""
2135
+ section = ""
2136
+ out: list[str] = []
2137
+ rewrote = False
2138
+ for segment in config.splitlines(keepends=True):
2139
+ content = segment.rstrip("\r\n")
2140
+ terminator = segment[len(content) :]
2141
+ header = _section_header(content)
2142
+ if header is not None:
2143
+ section = header
2144
+ elif not rewrote and section == "manifest":
2145
+ pair = _key_value(content)
2146
+ if pair is not None and pair[0].lower() == "path":
2147
+ out.append(f"path = {new_rel}{terminator}")
2148
+ rewrote = True
2149
+ continue
2150
+ out.append(segment)
2151
+ return "".join(out) if rewrote else None
2152
+
2153
+
2154
+ # ---------------------------------------------------------------------------
2155
+ # The `<topdir>/.west/tan-workspace-sdk` record (tan-cli#292). Written by
2156
+ # `tan.commands.bootstrap_cmd.record_workspace_sdk` after a `west update` that
2157
+ # actually ran; read back by `tan.commands.doctor_cmd`'s `venvProvenance`
2158
+ # check. A record-less workspace (bootstrapped by alp-sdk's own
2159
+ # `bootstrap.sh`, `crates/tan-cli/src/venv.rs:25-27`) is NOT an error here --
2160
+ # `parse_workspace_sdk_record` only ever returns "usable" or `None`.
2161
+ # ---------------------------------------------------------------------------
2162
+
2163
+
2164
+ @dataclass(frozen=True)
2165
+ class WorkspaceSdkRecord:
2166
+ """A parsed `<topdir>/.west/tan-workspace-sdk`. `sdk_path` is the only
2167
+ field every record (even one written before tan-cli#292) carries; the
2168
+ venv provenance fields are `None` on an older record, or one written by a
2169
+ caller that could not compute them -- ABSENCE, never a claim, so a
2170
+ consumer never reads a `None` as "confirmed empty"."""
2171
+
2172
+ sdk_path: str
2173
+ #: The venv directory, relative to `topdir` (e.g. `.venv`) -- so a moved
2174
+ #: workspace, or one whose `metadata/bootstrap.json` names a non-default
2175
+ #: `venv.dirName`, still resolves without re-deriving it.
2176
+ venv_dir_name: str | None = None
2177
+ #: The bin-dir layout actually created (`bin` / `Scripts`, tan-cli#291) --
2178
+ #: which directory `venv_dir_name` holds the executables under.
2179
+ venv_layout: str | None = None
2180
+ #: Lowercase-hex SHA-256 of the `zephyr.requirementsPath` file that
2181
+ #: populated the venv's Python packages (`bootstrap_cmd.pip_phase`) --
2182
+ #: the provenance stamp: the venv can be re-verified against a LATER
2183
+ #: read of the same file without re-running pip.
2184
+ requirements_digest: str | None = None
2185
+
2186
+
2187
+ def workspace_sdk_record_json(
2188
+ sdk_path: str,
2189
+ venv_dir_name: str | None = None,
2190
+ venv_layout: str | None = None,
2191
+ requirements_digest: str | None = None,
2192
+ ) -> str:
2193
+ """The `<topdir>/.west/tan-workspace-sdk` record's contents: which SDK a
2194
+ `west update` last synced this topdir's trees to, plus (tan-cli#292) which
2195
+ venv it populated and a content-hash provenance stamp for the Zephyr
2196
+ requirements that filled it. `updatedAt` is `generated_at_iso()`, matching
2197
+ `sdk_pointer_json`'s own self-contained timestamp -- `SOURCE_DATE_EPOCH`
2198
+ wins over the clock, so a captured record is reproducible, and that helper
2199
+ NEVER raises (an out-of-range epoch used to kill `tan init` here).
2200
+
2201
+ Deliberately its OWN function, not a `tan.core.scaffold.sdk_pointer_json`
2202
+ extension: that function is the `.alp/sdk-path` PROJECT pin and
2203
+ `~/.alp/sdk-default` GLOBAL pin -- a different record with different
2204
+ readers (`tan init`'s scaffold, `sdk_cmd`'s resolution ladder) -- growing
2205
+ ITS shape for this record's needs would silently add fields those readers
2206
+ never asked for and never validate.
2207
+
2208
+ `venv_dir_name`/`venv_layout`/`requirements_digest` are omitted from the
2209
+ JSON (not written as `null`) when the caller has nothing to report --
2210
+ mirroring `Check.as_dict`'s optional fields -- so a record predating
2211
+ tan-cli#292 and one written by a caller that could not compute a hash are
2212
+ indistinguishable on the wire, and `parse_workspace_sdk_record` reads both
2213
+ as "nothing to compare against" rather than a false claim.
2214
+ """
2215
+ payload: dict[str, str] = {"sdkPath": sdk_path, "updatedAt": generated_at_iso()}
2216
+ if venv_dir_name is not None:
2217
+ payload["venvDir"] = venv_dir_name
2218
+ if venv_layout is not None:
2219
+ payload["venvLayout"] = venv_layout
2220
+ if requirements_digest is not None:
2221
+ payload["requirementsDigest"] = requirements_digest
2222
+ return json.dumps(payload, indent=2) + "\n"
2223
+
2224
+
2225
+ def parse_workspace_sdk_record(text: str) -> WorkspaceSdkRecord | None:
2226
+ """Parse a `<topdir>/.west/tan-workspace-sdk` record's text. `None` on
2227
+ anything that is not a usable record -- not JSON, not an object, or no
2228
+ usable `sdkPath` -- so a record `doctor` cannot read is "nothing to
2229
+ compare against", the SAME as no record at all, never a mismatch WARNING
2230
+ against a checkout `tan` cannot even name.
2231
+ """
2232
+ try:
2233
+ doc = json.loads(text)
2234
+ except ValueError:
2235
+ return None
2236
+ if not isinstance(doc, dict):
2237
+ return None
2238
+ sdk_path = doc.get("sdkPath")
2239
+ if not isinstance(sdk_path, str) or not sdk_path:
2240
+ return None
2241
+
2242
+ def _opt(key: str) -> str | None:
2243
+ value = doc.get(key)
2244
+ return value if isinstance(value, str) and value else None
2245
+
2246
+ return WorkspaceSdkRecord(
2247
+ sdk_path=sdk_path,
2248
+ venv_dir_name=_opt("venvDir"),
2249
+ venv_layout=_opt("venvLayout"),
2250
+ requirements_digest=_opt("requirementsDigest"),
2251
+ )
2252
+
2253
+
2254
+ # ---------------------------------------------------------------------------
2255
+ # The printed blocks. Copy-pasteable shell snippets, so they carry NO
2256
+ # `bootstrap: ` prefix (unlike the progress lines) and their whitespace is
2257
+ # load-bearing.
2258
+ # ---------------------------------------------------------------------------
2259
+
2260
+
2261
+ def render_env_lines(
2262
+ env: tuple[tuple[str, str], ...], tokens: Tokens, prefix: str, is_windows: bool
2263
+ ) -> list[str]:
2264
+ """The manifest's `env` map as shell-ready lines.
2265
+
2266
+ POSIX (`print_env_lines`) quotes the value only when it looks like a path --
2267
+ contains `/` -- which keeps `export ZEPHYR_TOOLCHAIN_VARIANT=zephyr`
2268
+ unquoted while `ZEPHYR_BASE` is quoted. Windows (`Write-EnvLines`) always
2269
+ quotes.
2270
+
2271
+ One deliberate divergence from `bootstrap.ps1`: a token-substituted value is
2272
+ separator-normalised, so Windows emits `C:\\dev\\ws\\zephyr` rather than the
2273
+ script's mixed `C:\\dev\\ws/zephyr`. Both work; only one is copy-pasteable
2274
+ without a double-take. A value with no token in it is passed through
2275
+ untouched.
2276
+ """
2277
+ lines = []
2278
+ for key, raw in env:
2279
+ value = tokens.apply(raw)
2280
+ substituted = value != raw
2281
+ if is_windows:
2282
+ if substituted:
2283
+ value = value.replace("/", "\\")
2284
+ lines.append(f'{prefix}$env:{key} = "{value}"')
2285
+ elif "/" in value:
2286
+ lines.append(f'{prefix}export {key}="{value}"')
2287
+ else:
2288
+ lines.append(f"{prefix}export {key}={value}")
2289
+ return lines
2290
+
2291
+
2292
+ def print_env_block(
2293
+ facts: BootstrapFacts, tokens: Tokens, venv_bin_dir: str, is_windows: bool
2294
+ ) -> list[str]:
2295
+ """`--print-env`: the venv-activation comment header plus the rendered `env`
2296
+ map. Both scripts print exactly this and exit 0."""
2297
+ venv = facts.venv_dir_name
2298
+ if is_windows:
2299
+ # The workspace token is forward-slash on every OS (the resolved project
2300
+ # path), so it is normalised here or this line comes out mixed
2301
+ # (`C:/Users/dev\.venv\Scripts\Activate.ps1`).
2302
+ workspace = tokens.workspace_dir.replace("/", "\\")
2303
+ lines = [
2304
+ "# Add to your PowerShell profile (or run before invoking the SDK):",
2305
+ "# Activate the workspace venv (west + Zephyr/SDK Python deps live here):",
2306
+ f'# & "{workspace}\\{venv}\\{venv_bin_dir}\\Activate.ps1"',
2307
+ ]
2308
+ else:
2309
+ lines = [
2310
+ "# Add to your shell profile (or run before invoking the SDK):",
2311
+ "# Activate the workspace venv (west + Zephyr/SDK Python deps live here):",
2312
+ f'# source "{tokens.workspace_dir}/{venv}/{venv_bin_dir}/activate"',
2313
+ ]
2314
+ lines.extend(render_env_lines(facts.env, tokens, "", is_windows))
2315
+ return lines
2316
+
2317
+
2318
+ def optional_libs_block(facts: BootstrapFacts, host: str) -> list[str]:
2319
+ """The trailing manual-install hint.
2320
+
2321
+ POSIX prints the manifest's per-OS optional-native-libs note (plus its
2322
+ install command, when the OS has one); native Windows prints
2323
+ `manualInstallHints.windows.note`, one two-space-indented line per element
2324
+ under its own heading -- the SDK-sourced fact, not a hand-typed copy that
2325
+ would desync silently.
2326
+
2327
+ The Windows arm must NOT also read `nativeLibHints.windows.note`: appending
2328
+ both printed the Arm/Zephyr-SDK sentence twice. That field is parsed for
2329
+ round-trip fidelity but rendered by NOTHING here -- host detection reads the
2330
+ real platform, so a Windows host always takes this branch, git-bash or not,
2331
+ and the `bootstrap.sh` arm below is unreachable there.
2332
+
2333
+ NO blank line between the Windows heading and the first note element: the
2334
+ oracle has nothing in between. The POSIX arm below still emits its blank
2335
+ because `bootstrap.sh` genuinely echoes one.
2336
+ """
2337
+ if host == WINDOWS:
2338
+ lines = ["", "bootstrap: NOT auto-installed (manual, one-time):"]
2339
+ lines.extend(f" {line}" for line in facts.manual_install_windows)
2340
+ return lines
2341
+
2342
+ lines = ["", "bootstrap: Optional native libraries unlock the Yocto-side backends:"]
2343
+ hint = facts.native_lib_hint(host)
2344
+ if hint is None:
2345
+ # Structured as the oracle's `match` is (`blocks.rs:191-202`): this arm
2346
+ # pushes its line and FALLS THROUGH to the manual-install block below
2347
+ # rather than returning. Equivalent today -- `native_lib_hint` is None
2348
+ # only for `OTHER`, which that block already excludes -- but the shape
2349
+ # is kept identical so that adding an OS, or making a per-OS hint
2350
+ # optional at parse, cannot silently re-drop the POSIX note the way
2351
+ # tan-cli#495 defect 6 did.
2352
+ lines.append(" (OS not auto-detected; see docs/testing.md)")
2353
+ else:
2354
+ lines.append("")
2355
+ lines.extend(f" {line}" for line in hint.note)
2356
+ if hint.command:
2357
+ lines.append("")
2358
+ lines.append(f" {hint.command}")
2359
+ # tan-cli#495 defect 6: `manualInstallHints.posix.note`, which the port
2360
+ # dropped at parse, at render AND in the fallback -- so Linux/macOS
2361
+ # customers were never told that `west sdk install` is a separate manual
2362
+ # step, the one every Zephyr-on-M customer needs. Lands AFTER the
2363
+ # optional-native-libs section because the oracle prints it after
2364
+ # (`blocks.rs:229-245`, `bootstrap.sh:594` vs `:638`), under the same
2365
+ # heading the Windows arm uses.
2366
+ #
2367
+ # `LINUX`/`MACOS` ONLY, matching the oracle's `matches!(host, Linux |
2368
+ # MacOs)` and the shell `case` behind it -- never `OTHER`, which must not
2369
+ # start rendering a POSIX-specific fact by accident. The Windows arm
2370
+ # returned early above, so this is unreachable there regardless.
2371
+ if host in (LINUX, MACOS) and facts.manual_install_posix:
2372
+ lines.append("")
2373
+ lines.append("bootstrap: NOT auto-installed (manual, one-time):")
2374
+ lines.extend(f" {line}" for line in facts.manual_install_posix)
2375
+ return lines
2376
+
2377
+
2378
+ def next_steps_block(
2379
+ facts: BootstrapFacts,
2380
+ tokens: Tokens,
2381
+ venv_dir: str,
2382
+ venv_bin_dir: str,
2383
+ is_windows: bool,
2384
+ ) -> list[str]:
2385
+ """The closing "Next steps:" block: activate the venv, export the `env`
2386
+ map, run `tan doctor`, and one ready-to-paste build command."""
2387
+ lines = ["", "Next steps:"]
2388
+ if is_windows:
2389
+ lines.append(
2390
+ " # Activate the workspace venv (west + Zephyr/SDK deps + tan's Python "
2391
+ "backend):"
2392
+ )
2393
+ lines.append(f' & "{venv_dir}\\{venv_bin_dir}\\Activate.ps1"')
2394
+ else:
2395
+ lines.append(" # Activate the workspace venv (west + Zephyr/SDK deps live here):")
2396
+ lines.append(f' source "{venv_dir}/{venv_bin_dir}/activate"')
2397
+ lines.append("")
2398
+ lines.append(" # Make Zephyr reachable for builds:")
2399
+ lines.extend(render_env_lines(facts.env, tokens, " ", is_windows))
2400
+ # The pinned install.sh/install.ps1 one-liner, NOT `cargo install --git`
2401
+ # (that built unpinned HEAD). `tan doctor`, not `--build`: plain doctor
2402
+ # already folds in the build-readiness preflight.
2403
+ if is_windows:
2404
+ install_line = (
2405
+ " # for: irm https://raw.githubusercontent.com/alplabai/tan-cli/main/"
2406
+ "install.ps1 | iex):"
2407
+ )
2408
+ else:
2409
+ install_line = (
2410
+ " # for: curl -fsSL https://raw.githubusercontent.com/alplabai/tan-cli/"
2411
+ "main/install.sh | sh):"
2412
+ )
2413
+ lines.extend(
2414
+ [
2415
+ "",
2416
+ " # Sanity-check the host environment (needs tan on PATH -- see README.md",
2417
+ install_line,
2418
+ " tan doctor",
2419
+ "",
2420
+ ]
2421
+ )
2422
+ if is_windows:
2423
+ # `bootstrap.ps1` interpolates a native backslash path here and spells
2424
+ # the example as `examples\...`, so a raw forward-slash `${SDK_ROOT}`
2425
+ # would print mixed.
2426
+ repo_root = tokens.sdk_root.replace("/", "\\")
2427
+ lines.extend(
2428
+ [
2429
+ " # Or jump straight into building an example for real silicon",
2430
+ " # (needs the Zephyr SDK toolchain -- bootstrap just tried to acquire it",
2431
+ " # automatically (ADR 0021 Lane 1 P1); `tan doctor` above confirms "
2432
+ "whether it",
2433
+ " # landed, and names the exact install command if it did not):",
2434
+ " west build -b alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he `",
2435
+ f" examples\\peripheral-io\\uart-echo -- "
2436
+ f"-DEXTRA_ZEPHYR_MODULES={repo_root}",
2437
+ "",
2438
+ "References:",
2439
+ " - docs\\cross-platform-setup.md -- the full per-OS setup guide",
2440
+ " - docs\\cli.md -- the tan CLI verb reference",
2441
+ ]
2442
+ )
2443
+ else:
2444
+ # Routed through `tan build`, not a raw `west build`: the printed
2445
+ # success message otherwise routes the customer around tan's own claim
2446
+ # to be "the single executor and the user command surface".
2447
+ # `--sdk-root`/`--project` are ABSOLUTE because the workspace-parent
2448
+ # guard can have just moved the checkout to a sibling
2449
+ # `alp-workspace/alp-sdk`, so `$PWD` silently builds from the wrong tree.
2450
+ lines.extend(
2451
+ [
2452
+ " # Run the local test suite:",
2453
+ " bash scripts/test-all.sh",
2454
+ "",
2455
+ " # Or jump straight into building an example for real silicon",
2456
+ " # (needs the Zephyr SDK toolchain -- bootstrap just tried to acquire it",
2457
+ " # automatically (ADR 0021 Lane 1 P1); `tan doctor` above confirms "
2458
+ "whether it",
2459
+ " # landed, and names the exact install command if it did not):",
2460
+ f' tan build --sdk-root "{tokens.sdk_root}" \\',
2461
+ f' --project "{tokens.sdk_root}/examples/peripheral-io/uart-echo"',
2462
+ "",
2463
+ "References:",
2464
+ " - docs/testing.md -- full test-coverage map + how to run "
2465
+ "from scratch",
2466
+ " - docs/test-plan.md -- per-feature verification ledger "
2467
+ "(\u23f3 / \U0001f7e1 / \u2705)",
2468
+ ]
2469
+ )
2470
+ return lines
2471
+
2472
+
2473
+ #: Blocking codes that are not an install, with the clause that fits them.
2474
+ #: Every other member reads "<code> did not install".
2475
+ _NOT_AN_INSTALL = {"west-patches-failed": "zephyr/patches.yml was not applied"}
2476
+
2477
+
2478
+ def _blocking_clause(blocking: list[str]) -> str:
2479
+ installs = [code for code in blocking if code not in _NOT_AN_INSTALL]
2480
+ clauses = [f"{', '.join(installs)} did not install"] if installs else []
2481
+ clauses += [_NOT_AN_INSTALL[code] for code in blocking if code in _NOT_AN_INSTALL]
2482
+ return "; ".join(clauses)
2483
+
2484
+
2485
+ def completion_verdict(blocking: list[str], allow_partial: bool) -> tuple[list[str], bool]:
2486
+ """The closing text line(s), and whether the run counts as a SUCCESS,
2487
+ given which install phases left the workspace unable to do what it was
2488
+ bootstrapped for (tan-cli#220 / tan-cli#285).
2489
+
2490
+ Ported from the Rust oracle's `verdict()`
2491
+ (`crates/tan-cli/src/commands/bootstrap/mod.rs`), not re-derived: the
2492
+ wording, the named failures and the `--allow-partial` escape hatch are the
2493
+ ALREADY-SHIPPED, ALREADY TAGGED (`CHANGELOG.md` `[0.5.0-rc1]`) contract
2494
+ tan-cli#220 defined. A second, independently-worded rule for the same
2495
+ decision is exactly how this port's closing line, its escape hatch and its
2496
+ severity drift from the one alp-sdk-vscode and every other consumer
2497
+ already integrated against.
2498
+
2499
+ `blocking` is `Log.blocking()`'s output, in the order the warnings were
2500
+ raised: the subset of recorded warning codes after which the workspace
2501
+ cannot do what it was bootstrapped for (`WORKSPACE_BLOCKING`). Empty (the
2502
+ normal case) reports success, unchanged from before tan-cli#220.
2503
+
2504
+ Printing `bootstrap: complete.` and exiting 0 after a step already warned
2505
+ the venv is incomplete is the original defect: both read as an unqualified
2506
+ green light, and nothing about the exit code or the closing line told a
2507
+ consumer -- human or the extension -- to go look back at a warning that
2508
+ may have scrolled off screen minutes earlier (`hidapi`'s wheel build is
2509
+ minutes into a cold `west update`). `--allow-partial` is the informed
2510
+ escape: it still reports success, but the line still NAMES what did not
2511
+ install, so accepting the gap is a choice rather than a silent default.
2512
+ """
2513
+ if not blocking:
2514
+ return ["bootstrap: complete."], True
2515
+ named = _blocking_clause(blocking)
2516
+ if allow_partial:
2517
+ return (
2518
+ [
2519
+ "bootstrap: complete.",
2520
+ f" (--allow-partial: {named}; commands that need them will fail.)",
2521
+ ],
2522
+ True,
2523
+ )
2524
+ return (
2525
+ [
2526
+ f"bootstrap: INCOMPLETE -- {named}, so this workspace "
2527
+ f"cannot build yet.",
2528
+ " The messages above name the remedy for each. Fix them and re-run `tan "
2529
+ "bootstrap`, or pass --allow-partial to accept this workspace as-is (the "
2530
+ "west workspace and venv are already on disk, and a build that needs none "
2531
+ "of the missing packages will still work).",
2532
+ ],
2533
+ False,
2534
+ )
2535
+
2536
+
2537
+ def capture_tail(stdout: bytes | str, stderr: bytes | str, lines: int = 4) -> str:
2538
+ """The last few non-empty lines of a failed step's captured output. Prefers
2539
+ stderr, falling back to stdout when stderr is empty; `""` when there is
2540
+ nothing usable.
2541
+
2542
+ Without this the JSON envelope carried no failure reason at all -- a pip
2543
+ traceback, a "no such file" -- because only the exit status was read.
2544
+
2545
+ `lines` defaults to 4 -- unchanged for every existing caller. `west sdk
2546
+ install`'s failure path (`bootstrap_cmd._acquire_toolchain`) passes a
2547
+ wider window (tan-cli#990 review): a real CI run's `tar --xz` extraction
2548
+ failure produced a message naming NO cause at all, because the actual
2549
+ subprocess error line sat above the closing frames of `west`'s own
2550
+ Python traceback and the 4-line default discarded it along with
2551
+ everything else -- a diagnostic gap that cannot be recovered after the
2552
+ fact, since the untruncated child output is never written anywhere else.
2553
+ """
2554
+ text = _as_text(stderr)
2555
+ if not text.strip():
2556
+ text = _as_text(stdout)
2557
+ tail = [line for line in text.splitlines() if line.strip()][-lines:]
2558
+ return " | ".join(tail)
2559
+
2560
+
2561
+ def _as_text(value: bytes | str) -> str:
2562
+ if isinstance(value, bytes):
2563
+ return value.decode("utf-8", errors="replace")
2564
+ return value or ""
2565
+
2566
+
2567
+ def die(base: str, detail: str) -> str:
2568
+ """A fatal message: the script's own `die` text plus whatever detail the
2569
+ runner recovered. Text mode usually has none (the child's log already
2570
+ streamed), so the bare message is what the user sees there -- no dangling
2571
+ colon."""
2572
+ return f"{base}: {detail}" if detail.strip() else base