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.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/commands/sdk_cmd.py
ADDED
|
@@ -0,0 +1,1723 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`tan sdk <list|install|current|switch>` -- which alp-sdk checkout is active.
|
|
3
|
+
|
|
4
|
+
Mirrors `crates/tan-cli/src/commands/sdk.rs`. A subcommand GROUP, but not a
|
|
5
|
+
Click/clap subcommand group: the Rust takes `subcommand` and `arg` as two plain
|
|
6
|
+
positionals (`cli.rs`'s `SdkArgs`), which is precisely why an unknown verb
|
|
7
|
+
reaches the command's own dispatch and gets a real envelope instead of a
|
|
8
|
+
parser-level usage error. Reproduced here for the same reason -- promoting
|
|
9
|
+
`sdk` to a `typer.Typer()` sub-app would hand `tan sdk bogus` to Click, which
|
|
10
|
+
exits 2 with usage text on stderr and NOTHING on stdout. The
|
|
11
|
+
`sdk-unknown-subcommand` golden pins exit **1** and a full envelope, so the
|
|
12
|
+
group has to stay flat.
|
|
13
|
+
|
|
14
|
+
Two committed goldens drive this file:
|
|
15
|
+
|
|
16
|
+
* `sdk-current-no-sdk` -- `sdk current` in a workspace with no SDK anywhere:
|
|
17
|
+
`sdkPath: null`, `readiness: null`, `sourceTier: "none"`, exit 0. It is a
|
|
18
|
+
SUCCESS: "nothing is selected" is an answer, not a failure.
|
|
19
|
+
* `sdk-unknown-subcommand` -- `sdk bogus`, exit 1. Note the asymmetry, which is
|
|
20
|
+
the contract and not a bug to tidy: the failure envelope carries the
|
|
21
|
+
**`list`-shaped** payload (`{"subcommand": "list", "releases": []}`), because
|
|
22
|
+
the Rust builds its unknown-verb failure out of `ListData`. `data.releases` is
|
|
23
|
+
what the extension reads with a `?? []` fallback, so the shape is deliberate.
|
|
24
|
+
|
|
25
|
+
**It cannot hang.** `current` is pure filesystem probing -- four tier lookups,
|
|
26
|
+
a readiness stat, and an optional `~/.alp/sdk-cache` listing. No subprocess, no
|
|
27
|
+
network, no prompt. That is a property of the no-checkout golden, not an
|
|
28
|
+
accident of it.
|
|
29
|
+
|
|
30
|
+
**Network is opt-in.** `sdk list` is the one verb that talks to GitHub, and it
|
|
31
|
+
only does so behind an explicit `--online`. tan-cli#351: without the flag it
|
|
32
|
+
answers OFFLINE, at exit 0 -- not a refusal. The oracle has no `--online` flag
|
|
33
|
+
at all and reaches the network unconditionally on every `sdk list` call
|
|
34
|
+
(measured: `--help` lists no such option; a live run with network reachable
|
|
35
|
+
succeeds at exit 0 with no flag given), so gating the call is this port's OWN
|
|
36
|
+
addition, for hermeticity (I-23) -- a command that silently opens a socket
|
|
37
|
+
cannot be driven from a hermetic test, an air-gapped host, or a fixture. That
|
|
38
|
+
gate is not a verdict on anything the caller did wrong, so it must not exit
|
|
39
|
+
non-zero: the bare answer says plainly that the releases it reports are
|
|
40
|
+
UPSTREAM and that `--online` is the switch that fetches them, the same way
|
|
41
|
+
`sdk current` answers "nothing configured" at exit 0 instead of failing (see
|
|
42
|
+
`_run_list`'s own docstring for the full reasoning). The fetch itself carries
|
|
43
|
+
an explicit timeout, because a `urlopen` with no timeout inherits the socket
|
|
44
|
+
default of "forever" and a CI job driving `--format json` would hang until the
|
|
45
|
+
runner killed it (I-23's failure mode, arrived at through a socket instead of
|
|
46
|
+
a prompt).
|
|
47
|
+
|
|
48
|
+
**No SDK is ever shelled.** Nothing here runs `python -m alp_cli` or
|
|
49
|
+
`alp_project.py`: readiness is a stat of `scripts/alp_project.py`,
|
|
50
|
+
`metadata/`, `VERSION` and `metadata/sdk_version.yaml`. I-32 and the
|
|
51
|
+
port-invariants anti-pattern list (#22, "Ship `tan init` shelling the SDK
|
|
52
|
+
instead of reading the vendored scaffold tree") forbid giving a command an
|
|
53
|
+
alp-sdk-checkout dependency it does not have; `sdk current`'s whole job is to
|
|
54
|
+
report on a checkout that may not exist at all, so shelling one would be
|
|
55
|
+
circular as well as forbidden.
|
|
56
|
+
|
|
57
|
+
**tan learns no hardware fact** (I-26). This file knows one path literal --
|
|
58
|
+
`scripts/alp_project.py`, the SDK-root marker (I-31) -- and no SKU, address,
|
|
59
|
+
pin name or vendor branch.
|
|
60
|
+
|
|
61
|
+
NOT PORTED, and loud about it: `install` and `switch`. Both refuse with
|
|
62
|
+
`sdk.not-ported` (exit 1, see `_run_not_ported`'s own docstring for why not 5)
|
|
63
|
+
rather than half-working. `switch` in particular must not land partially: the
|
|
64
|
+
Rust writes the active-SDK pointer AND reconciles `<topdir>/.west/config`'s own
|
|
65
|
+
`manifest.path` (tan-cli #62/#31), and a version that writes the pointer while
|
|
66
|
+
silently skipping the reconciliation reports success while `west` keeps
|
|
67
|
+
resolving the manifest from the stale pointer -- which is the bug #62
|
|
68
|
+
reported, re-introduced invisibly. A loud refusal is strictly safer than that.
|
|
69
|
+
|
|
70
|
+
**tan-cli#305: that refusal must not be a dead end.** `doctor_cmd`,
|
|
71
|
+
`bootstrap_cmd`, and this module's own `sdk current` used to independently
|
|
72
|
+
recommend `sdk install`/`sdk switch` as the fix for "no SDK resolved" -- three
|
|
73
|
+
copies of advice naming a subcommand this build refuses, which on a clean
|
|
74
|
+
host (no checkout, no cache, no pointer anywhere) left `tan bootstrap`
|
|
75
|
+
unreachable by any documented path. `NO_SDK_NEXT_STEPS` below is the one
|
|
76
|
+
surviving mechanism (`--sdk-root`, plus how to get a checkout at all) all
|
|
77
|
+
three now read instead.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
import json
|
|
83
|
+
import os
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
88
|
+
# Type-checker only. `urllib.request` is deferred into the two functions
|
|
89
|
+
# that use it (tan-cli#810), so this name exists for annotations and
|
|
90
|
+
# costs nothing at runtime -- `TYPE_CHECKING` is False in a real run.
|
|
91
|
+
from urllib.request import OpenerDirector
|
|
92
|
+
|
|
93
|
+
import typer
|
|
94
|
+
|
|
95
|
+
from tan.core.atomic_write import atomic_write_text
|
|
96
|
+
from tan.core.global_flags import accept_global_flags
|
|
97
|
+
from tan.core.proxy import (
|
|
98
|
+
HTTPS_PROXY_ENV_VARS,
|
|
99
|
+
host_of,
|
|
100
|
+
select_https_proxy,
|
|
101
|
+
unsupported_proxy_scheme,
|
|
102
|
+
)
|
|
103
|
+
# `_has_loader_script`/`_home_alp_dir`/`_read_file` moved to
|
|
104
|
+
# `tan.core.sdk_discovery` alongside `resolve_sdk_tiered` (tan-cli#408 review
|
|
105
|
+
# follow-up) -- `check_sdk_readiness`/`cached_sdk_versions`/
|
|
106
|
+
# `_default_cache_root` below still need them for readiness reporting, which
|
|
107
|
+
# stayed here because it is not part of the discovery/resolution cluster.
|
|
108
|
+
# `_abs_posix`/`_pointer_target` are the same kind of shared filesystem
|
|
109
|
+
# primitive, pulled in for `sdk remove` (tan-cli#790): `_abs_posix` is the
|
|
110
|
+
# cwd-anchored/lexical path form every removal-target comparison below uses,
|
|
111
|
+
# `_pointer_target` is the direct (not `resolve_sdk_tiered`-mediated) read of
|
|
112
|
+
# `~/.alp/sdk-default`'s own `sdkPath`, needed because that pointer can name
|
|
113
|
+
# a checkout THIS workspace does not resolve through at all while still being
|
|
114
|
+
# exactly what removing it would orphan for some OTHER project on the host.
|
|
115
|
+
from tan.core.sdk_discovery import (
|
|
116
|
+
ActiveSdk,
|
|
117
|
+
_abs_posix,
|
|
118
|
+
_has_loader_script,
|
|
119
|
+
_home_alp_dir,
|
|
120
|
+
_pointer_target,
|
|
121
|
+
_read_file,
|
|
122
|
+
global_default_foreign_project_issue,
|
|
123
|
+
project_pin_issue,
|
|
124
|
+
resolve_sdk_root_ladder,
|
|
125
|
+
resolve_sdk_tiered,
|
|
126
|
+
sdk_ladder_divergence_issue,
|
|
127
|
+
)
|
|
128
|
+
from tan.core.sdk_default_registry import (
|
|
129
|
+
load_raw,
|
|
130
|
+
normalized_sdk_path,
|
|
131
|
+
parse_registry,
|
|
132
|
+
prune_entries_by_sdk_path,
|
|
133
|
+
registry_path,
|
|
134
|
+
registry_text,
|
|
135
|
+
)
|
|
136
|
+
from tan.core.sdk_removal import (
|
|
137
|
+
RemovalOutcome,
|
|
138
|
+
is_cache_root_itself,
|
|
139
|
+
is_outside_cache_root,
|
|
140
|
+
removal_would_damage,
|
|
141
|
+
removal_would_take_out,
|
|
142
|
+
remove_sdk_tree,
|
|
143
|
+
resolve_removal_target,
|
|
144
|
+
)
|
|
145
|
+
from tan.core.text_layout import wrap_lines
|
|
146
|
+
from tan.env import wrap_width
|
|
147
|
+
from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
|
|
148
|
+
from tan.exit_codes import ExitCode
|
|
149
|
+
from tan.net import default_ssl_context
|
|
150
|
+
from tan.output_format import FORMAT_HELP, OutputFormat
|
|
151
|
+
|
|
152
|
+
#: GitHub Releases API endpoint for `alplabai/alp-sdk`, from
|
|
153
|
+
#: `tan_core::sdk::GITHUB_RELEASES_URL`.
|
|
154
|
+
GITHUB_RELEASES_URL = "https://api.github.com/repos/alplabai/alp-sdk/releases"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: Wall-clock ceiling on the ONE network call in this file. Every subprocess and
|
|
158
|
+
#: socket probe in the port carries a timeout; `urlopen` without one blocks on
|
|
159
|
+
#: the socket default (no timeout at all), which turns an unreachable proxy into
|
|
160
|
+
#: a hung `--format json` consumer rather than an error it can render.
|
|
161
|
+
NETWORK_TIMEOUT_SECONDS = 20.0
|
|
162
|
+
|
|
163
|
+
#: The verbs `run` dispatches, for the unknown-subcommand text line. Was
|
|
164
|
+
#: verbatim from `sdk.rs`'s failure text until tan-cli#305: the oracle's
|
|
165
|
+
#: `install`/`switch` really work, so its wording is honest there; this port's
|
|
166
|
+
#: do not (`_run_not_ported` below), so repeating it unqualified here was one
|
|
167
|
+
#: more site recommending a subcommand this build refuses.
|
|
168
|
+
AVAILABLE_SUBCOMMANDS = (
|
|
169
|
+
"Available subcommands: list, current, install <version> (refuses -- not "
|
|
170
|
+
"yet ported), switch <version> (refuses -- not yet ported), "
|
|
171
|
+
"remove <version|path>"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
#: `install`/`switch` refuse outright in this build (`_run_not_ported` below)
|
|
175
|
+
#: -- the ONE fact `doctor_cmd.sdk_check` and `bootstrap_cmd`'s SDK-unresolved
|
|
176
|
+
#: refusal must read instead of each independently hardcoding "run `tan sdk
|
|
177
|
+
#: install <ver>`" (tan-cli#305). Three copies of that recommendation, none
|
|
178
|
+
#: checking whether the build it shipped in actually has the subcommand, is
|
|
179
|
+
#: how a clean host was left with no way to reach `tan bootstrap` at all.
|
|
180
|
+
NOT_PORTED_SDK_SUBCOMMANDS = frozenset({"install", "switch"})
|
|
181
|
+
|
|
182
|
+
#: The one accurate way to point tan at an SDK checkout in THIS build. Neither
|
|
183
|
+
#: `sdk install` nor `sdk switch` works (tan-cli#305), so `--sdk-root` on the
|
|
184
|
+
#: invocation itself is the only surviving mechanism, and a host with no
|
|
185
|
+
#: checkout anywhere needs telling how to get one at all -- an accurate manual
|
|
186
|
+
#: instruction beats naming a subcommand that exits 1. Shared by
|
|
187
|
+
#: `doctor_cmd.sdk_check`, `bootstrap_cmd`'s SDK-unresolved refusal, and
|
|
188
|
+
#: `_run_current` below so the three cannot drift into three different,
|
|
189
|
+
#: independently-worded dead ends again.
|
|
190
|
+
NO_SDK_NEXT_STEPS = (
|
|
191
|
+
"get an alp-sdk checkout (`git clone https://github.com/alplabai/alp-sdk`), "
|
|
192
|
+
"then point tan at it with `--sdk-root <path>`"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
#: `sdk list`'s proxy/CA hint, verbatim from `tan_core::sdk::describe_network_error`.
|
|
196
|
+
#: Names only the variables that actually steer an `https://` request -- neither
|
|
197
|
+
#: tan nor git applies `HTTP_PROXY` to one, so naming it sent users to edit a
|
|
198
|
+
#: variable that changes nothing. That claim was FALSE for `ALL_PROXY` until
|
|
199
|
+
#: tan-cli#497: `urllib` mapped it to a key its `https` dispatch never consulted,
|
|
200
|
+
#: so this hint named a variable that really did change nothing. `tan/core/proxy.py`
|
|
201
|
+
#: is what makes all three names accurate.
|
|
202
|
+
_PROXY_HINT = (
|
|
203
|
+
"Check ALL_PROXY/HTTPS_PROXY/NO_PROXY — the configured proxy refused or "
|
|
204
|
+
"could not complete the connection."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
#: #304: this used to open "This is USUALLY a TLS-intercepting proxy or a
|
|
208
|
+
#: corporate CA that this machine does not trust" -- a confident diagnosis with
|
|
209
|
+
#: no evidence behind it. The v0.5.0-rc2 asset hit this exact error with no
|
|
210
|
+
#: proxy and no corporate CA anywhere in sight; the real cause was the frozen
|
|
211
|
+
#: binary shipping with no CA bundle at all (fixed in `tan/net.py`, but a
|
|
212
|
+
#: future build that regresses that fix must not be misdiagnosed the same way
|
|
213
|
+
#: again). A message naming a probable cause needs evidence for that cause or
|
|
214
|
+
#: has to name the alternative -- this names both and gives the one comparison
|
|
215
|
+
#: (`curl`/a browser against the same host) that tells them apart, mirroring
|
|
216
|
+
#: how #304 itself was actually diagnosed.
|
|
217
|
+
_TLS_HINT = (
|
|
218
|
+
"Not necessarily a broken connection -- this can mean a TLS-intercepting "
|
|
219
|
+
"proxy or a corporate CA this machine does not trust, or that this tan "
|
|
220
|
+
"build's own CA trust failed to load. If curl or a browser reach this host "
|
|
221
|
+
"fine from here, the trust store is the likelier cause; if they fail too, "
|
|
222
|
+
"the proxy/CA is."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def global_default_pointer_fix_hint(native_path: str, native_registry_path: str) -> str:
|
|
227
|
+
"""How to fix -- or safely clear -- an already-written `~/.alp/sdk-
|
|
228
|
+
default` pointer (`native_path`) AND its origin-keyed sibling
|
|
229
|
+
`~/.alp/sdk-defaults.json` (`native_registry_path`, tan-cli#466) by hand.
|
|
230
|
+
Both are the caller's to compute -- `_home_alp_dir() / "sdk-default"` and
|
|
231
|
+
`sdk_default_registry.registry_path(_home_alp_dir())`, each rendered
|
|
232
|
+
through whatever this-platform-separator helper it already has
|
|
233
|
+
(`bootstrap_cmd._native` for its callers).
|
|
234
|
+
|
|
235
|
+
Names BOTH files, unconditionally, because tan-cli#466's registry is
|
|
236
|
+
consulted FIRST and a caller cannot tell from the fix-hint's call site
|
|
237
|
+
alone whether the answer it just got came from the registry or fell
|
|
238
|
+
through to the legacy pointer -- so a hint naming only one of them could
|
|
239
|
+
send a reader to edit the file that was not actually the one that
|
|
240
|
+
answered.
|
|
241
|
+
|
|
242
|
+
`_pointer_target` above degrades every read failure on the legacy file
|
|
243
|
+
(missing, invalid JSON, list-shaped, no `sdkPath`) to `None`, and
|
|
244
|
+
`sdk_default_registry.parse_registry` degrades every read/parse failure
|
|
245
|
+
on the registry the identical way, to `{}` -- both resolvers
|
|
246
|
+
(`resolve_sdk_tiered`) then fall through to the next tier on that, so
|
|
247
|
+
DELETING either or both files is always a safe recovery, never a step
|
|
248
|
+
backwards; hand-editing a `"sdkPath"` field is the targeted fix when the
|
|
249
|
+
caller knows what it should say instead.
|
|
250
|
+
|
|
251
|
+
Shared so a caller describing this by hand cannot drift from what
|
|
252
|
+
`_pointer_target`/`parse_registry` actually read. `bootstrap_cmd`'s
|
|
253
|
+
workspace-relocation and rollback-failure messages are why this exists
|
|
254
|
+
(tan-cli#305 follow-up): they used to send a user -- sometimes one
|
|
255
|
+
already in a broken, checkout-moved-but-rollback-incomplete state -- to
|
|
256
|
+
`tan sdk switch --global`, which refuses outright in this build. Naming
|
|
257
|
+
the pointer files directly, not the command, is also honest about the
|
|
258
|
+
mechanism `switch` itself would use once ported, so this will not go
|
|
259
|
+
stale the moment that disposition changes.
|
|
260
|
+
"""
|
|
261
|
+
return (
|
|
262
|
+
f"delete {native_path} and/or {native_registry_path} (tan falls "
|
|
263
|
+
f'through to the next SDK it can resolve), or edit the "sdkPath" '
|
|
264
|
+
f"field(s) by hand"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── readiness (tan_core::sdk::check_sdk_readiness) ──────────────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_sdk_version_yaml(text: str) -> str | None:
|
|
272
|
+
"""`metadata/sdk_version.yaml`'s version: a `version:` line if present, else
|
|
273
|
+
the first bare (colon-free) scalar. A hand-rolled scan, deliberately, exactly
|
|
274
|
+
as the Rust is: the file is comment-heavy prose around a single scalar, and
|
|
275
|
+
tan ships no YAML dependency the offline paths can rely on.
|
|
276
|
+
"""
|
|
277
|
+
bare: str | None = None
|
|
278
|
+
for raw in text.splitlines():
|
|
279
|
+
line = raw.strip()
|
|
280
|
+
if not line or line.startswith("#"):
|
|
281
|
+
continue
|
|
282
|
+
if line.startswith("version:"):
|
|
283
|
+
value = line[len("version:") :].strip().strip('"').strip("'")
|
|
284
|
+
if value:
|
|
285
|
+
return value
|
|
286
|
+
elif bare is None and ":" not in line:
|
|
287
|
+
bare = line.strip('"').strip("'")
|
|
288
|
+
return bare
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_sdk_readiness(sdk_path: str) -> dict[str, Any]:
|
|
292
|
+
"""Inspect a local SDK path. Never raises; a problem is an `issues[]` string.
|
|
293
|
+
|
|
294
|
+
`version` reads `VERSION` FIRST and falls back to
|
|
295
|
+
`metadata/sdk_version.yaml` (tan-cli #162): no released alp-sdk ships a
|
|
296
|
+
top-level `VERSION` file, so before the fallback every `sdk current`
|
|
297
|
+
reported `version (unknown)` while `tan doctor` resolved a real one from the
|
|
298
|
+
other file. `VERSION` stays first so an extracted release archive that DOES
|
|
299
|
+
carry one is never regressed.
|
|
300
|
+
|
|
301
|
+
`state` is `missing` on a absent loader script (not an SDK root at all),
|
|
302
|
+
`partial` when the marker is there but something else is not, else `ready`.
|
|
303
|
+
Note the asymmetry that follows: a missing `metadata/` adds an issue but
|
|
304
|
+
keeps `loaderScriptPresent` true, so the verdict is `partial`, never
|
|
305
|
+
`missing`.
|
|
306
|
+
"""
|
|
307
|
+
root = Path(sdk_path)
|
|
308
|
+
issues: list[str] = []
|
|
309
|
+
|
|
310
|
+
loader_present = _has_loader_script(root)
|
|
311
|
+
if not loader_present:
|
|
312
|
+
issues.append(
|
|
313
|
+
f'scripts/alp_project.py not found — "{sdk_path}" is not a valid '
|
|
314
|
+
"Alp SDK root."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
metadata_present = (root / "metadata").exists()
|
|
318
|
+
if not metadata_present:
|
|
319
|
+
issues.append("metadata/ directory is missing.")
|
|
320
|
+
|
|
321
|
+
version: str | None = None
|
|
322
|
+
version_file = root / "VERSION"
|
|
323
|
+
if version_file.exists():
|
|
324
|
+
contents = _read_file(version_file)
|
|
325
|
+
if contents is None:
|
|
326
|
+
issues.append("VERSION file could not be read.")
|
|
327
|
+
else:
|
|
328
|
+
# An empty/whitespace VERSION is "no version", not the empty string
|
|
329
|
+
# -- `format_readiness_block` renders `None` as `(unknown)`.
|
|
330
|
+
version = contents.strip() or None
|
|
331
|
+
if version is None:
|
|
332
|
+
sdk_version_yaml = root / "metadata" / "sdk_version.yaml"
|
|
333
|
+
if sdk_version_yaml.exists():
|
|
334
|
+
contents = _read_file(sdk_version_yaml)
|
|
335
|
+
if contents is not None:
|
|
336
|
+
version = parse_sdk_version_yaml(contents)
|
|
337
|
+
|
|
338
|
+
if not loader_present:
|
|
339
|
+
state = "missing"
|
|
340
|
+
elif issues:
|
|
341
|
+
state = "partial"
|
|
342
|
+
else:
|
|
343
|
+
state = "ready"
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"sdkPath": sdk_path,
|
|
347
|
+
"version": version,
|
|
348
|
+
"loaderScriptPresent": loader_present,
|
|
349
|
+
"metadataPresent": metadata_present,
|
|
350
|
+
"state": state,
|
|
351
|
+
"issues": issues,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def empty_readiness(sdk_path: str) -> dict[str, Any]:
|
|
356
|
+
"""The placeholder `missing` report a failure envelope carries when no real
|
|
357
|
+
check ran (`sdk.rs`'s `empty_readiness`) -- so `data.readiness` keeps its
|
|
358
|
+
full key set on every path instead of appearing and disappearing."""
|
|
359
|
+
return {
|
|
360
|
+
"sdkPath": sdk_path,
|
|
361
|
+
"version": None,
|
|
362
|
+
"loaderScriptPresent": False,
|
|
363
|
+
"metadataPresent": False,
|
|
364
|
+
"state": "missing",
|
|
365
|
+
"issues": [],
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── the install cache ────────────────────────────────────────────────────────
|
|
370
|
+
# `ActiveSdk`/`resolve_sdk_tiered`/the project-pin and global-default `Issue`
|
|
371
|
+
# builders moved to `tan.core.sdk_discovery` (tan-cli#408 review follow-up,
|
|
372
|
+
# see the module docstring and the top-of-file import) -- `_run_current`
|
|
373
|
+
# below imports them back from there.
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _default_cache_root() -> Path:
|
|
377
|
+
"""`~/.alp/sdk-cache` -- where `tan sdk install` clones to."""
|
|
378
|
+
return _home_alp_dir() / "sdk-cache"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cached_sdk_versions(cache_root: Path | None = None) -> list[str]:
|
|
382
|
+
"""Directory names directly under the cache root that hold a real checkout,
|
|
383
|
+
sorted. An unreadable or absent cache root is an empty list, never an error.
|
|
384
|
+
|
|
385
|
+
This is what lets `current`'s "nothing selected" message name the
|
|
386
|
+
checkouts already sitting in the cache instead of claiming none exist --
|
|
387
|
+
tan-cli #162, a loop with no exit. Deliberately does not guess WHICH
|
|
388
|
+
version to point at; it lists what exists and lets the user choose (via
|
|
389
|
+
`--sdk-root`, since `sdk switch` itself refuses -- tan-cli#305).
|
|
390
|
+
"""
|
|
391
|
+
root = _default_cache_root() if cache_root is None else cache_root
|
|
392
|
+
try:
|
|
393
|
+
return sorted(entry.name for entry in root.iterdir() if _has_loader_script(entry))
|
|
394
|
+
except OSError:
|
|
395
|
+
return []
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ── human text (stderr only; stdout is the envelope channel) ─────────────────
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def no_active_sdk_text(cached: list[str]) -> list[str]:
|
|
402
|
+
"""`sdk current`'s "nothing is configured" guidance. Used to send an empty
|
|
403
|
+
cache to `sdk install` and a populated one to `sdk switch` -- neither
|
|
404
|
+
works in this build (tan-cli#305), so both branches now point at
|
|
405
|
+
`NO_SDK_NEXT_STEPS`, the one mechanism that does."""
|
|
406
|
+
if not cached:
|
|
407
|
+
return [
|
|
408
|
+
"No active SDK configured for this workspace.",
|
|
409
|
+
f"To get started, {NO_SDK_NEXT_STEPS}.",
|
|
410
|
+
]
|
|
411
|
+
return [
|
|
412
|
+
"No active SDK configured for this workspace.",
|
|
413
|
+
f"Already installed under {_default_cache_root()}: {', '.join(cached)}",
|
|
414
|
+
"Point tan at one with `--sdk-root <path>`.",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def format_readiness_block(header: str, report: dict[str, Any]) -> list[str]:
|
|
419
|
+
lines = [
|
|
420
|
+
header,
|
|
421
|
+
f" path {report['sdkPath']}",
|
|
422
|
+
f" version {report['version'] or '(unknown)'}",
|
|
423
|
+
f" state {report['state']}",
|
|
424
|
+
]
|
|
425
|
+
if report["issues"]:
|
|
426
|
+
lines.append(" issues:")
|
|
427
|
+
lines.extend(f" - {issue}" for issue in report["issues"])
|
|
428
|
+
return lines
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def format_release_table(releases: list[dict[str, Any]]) -> list[str]:
|
|
432
|
+
"""Tag, publish date, truncated notes, then the draft/prerelease flags.
|
|
433
|
+
|
|
434
|
+
Flags come AFTER the notes (tan-cli #122): placed between the fixed-width
|
|
435
|
+
date and the notes they would shift the notes column by their own width.
|
|
436
|
+
"""
|
|
437
|
+
if not releases:
|
|
438
|
+
return ["No SDK releases found."]
|
|
439
|
+
lines = [f"Alp SDK releases ({len(releases)})"]
|
|
440
|
+
for release in releases:
|
|
441
|
+
date = release["publishedAt"][:10]
|
|
442
|
+
# Collapse ALL whitespace before truncating. `releaseNotesSummary` is
|
|
443
|
+
# the release body verbatim, and a body whose first line is short
|
|
444
|
+
# (`## Highlights`) used to put a literal newline INSIDE a table row:
|
|
445
|
+
# the row spilled onto a second, unindented line and the `{flags}`
|
|
446
|
+
# suffix landed after the spilled text rather than on the row it
|
|
447
|
+
# describes (#316). Collapsing also spends the 60-char budget on
|
|
448
|
+
# visible text rather than on layout this table then discards.
|
|
449
|
+
summary = " ".join((release["releaseNotesSummary"] or "").split())
|
|
450
|
+
# Char-aware truncation to 60, no ellipsis -- Python slicing is by
|
|
451
|
+
# character, matching the Rust's `chars().take(60)` rather than bytes.
|
|
452
|
+
# The Rust does NOT collapse first, so this is a deliberate divergence
|
|
453
|
+
# on the text surface: measured, no committed fixture and no parity
|
|
454
|
+
# case covers this table, and the oracle renders the same broken row.
|
|
455
|
+
notes = f" {summary[:60]}" if summary else ""
|
|
456
|
+
flags = {
|
|
457
|
+
(True, True): " [draft, prerelease]",
|
|
458
|
+
(True, False): " [draft]",
|
|
459
|
+
(False, True): " [prerelease]",
|
|
460
|
+
(False, False): "",
|
|
461
|
+
}[(bool(release["draft"]), bool(release["prerelease"]))]
|
|
462
|
+
lines.append(f" {release['tag']:<12} {date}{notes}{flags}")
|
|
463
|
+
return lines
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ── sdk list: the one network path, opt-in and timed out ─────────────────────
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _proxy_variable_name() -> str:
|
|
470
|
+
"""The env var [`select_https_proxy`] actually read the winning proxy from,
|
|
471
|
+
for a message that sends the reader to the right line of their shell
|
|
472
|
+
profile. `ALL_PROXY` by default -- the head of the precedence list -- when
|
|
473
|
+
none is set, which only happens if a caller asks outside a proxied run."""
|
|
474
|
+
for name in HTTPS_PROXY_ENV_VARS:
|
|
475
|
+
value = os.environ.get(name)
|
|
476
|
+
if value and value.strip():
|
|
477
|
+
return name
|
|
478
|
+
return HTTPS_PROXY_ENV_VARS[0]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def describe_network_error(error: str, *, proxy_used: bool) -> str:
|
|
482
|
+
"""Append the likely environmental cause to a raw transport error.
|
|
483
|
+
|
|
484
|
+
A middlebox-signed certificate and an unreachable proxy both read to a user
|
|
485
|
+
as "the network is down", so they go hunting in the wrong place. Ordering is
|
|
486
|
+
the whole design and matches `tan_core::sdk::describe_network_error`:
|
|
487
|
+
named-proxy first (a proxy failure that also mentions TLS is still a proxy
|
|
488
|
+
problem), then certificate/TLS even when a proxy IS set (the middlebox is
|
|
489
|
+
reachable; it is its CA that is untrusted, and the proxy vars are the wrong
|
|
490
|
+
knob there), and only then a bare connect failure -- and only when there is
|
|
491
|
+
a proxy to blame.
|
|
492
|
+
|
|
493
|
+
`proxy_used` is the caller's own POST-`NO_PROXY` answer -- whether the
|
|
494
|
+
request that just failed actually went through a proxy -- not a bare env
|
|
495
|
+
scan. The bare scan was a second wrong-blame case (tan-cli#497 defect 8):
|
|
496
|
+
`NO_PROXY=api.github.com` beside a `HTTPS_PROXY` correctly sends this
|
|
497
|
+
request DIRECT (measured: rc 0, 13 releases), yet a failure on that direct
|
|
498
|
+
connection was still hinted at the proxy, which had no part in it. The Rust
|
|
499
|
+
this ports (`http.rs:115`, `proxy_configured() -> env_proxy().is_some()`)
|
|
500
|
+
is post-bypass too.
|
|
501
|
+
"""
|
|
502
|
+
lower = error.lower()
|
|
503
|
+
if "proxy" in lower:
|
|
504
|
+
hint = _PROXY_HINT
|
|
505
|
+
elif "certificate" in lower or "tls" in lower:
|
|
506
|
+
hint = _TLS_HINT
|
|
507
|
+
elif proxy_used and "connect" in lower:
|
|
508
|
+
hint = _PROXY_HINT
|
|
509
|
+
else:
|
|
510
|
+
return error
|
|
511
|
+
return f"{error} {hint}"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _str_field(item: dict[str, Any], key: str) -> str:
|
|
515
|
+
value = item.get(key)
|
|
516
|
+
return value if isinstance(value, str) else ""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _first_paragraph(body: str) -> str:
|
|
520
|
+
trimmed = body.strip()
|
|
521
|
+
if not trimmed:
|
|
522
|
+
return ""
|
|
523
|
+
head, separator, _ = trimmed.partition("\n\n")
|
|
524
|
+
return head.strip() if separator else trimmed
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def parse_remote_sdk_releases(payload: Any) -> list[dict[str, Any]] | None:
|
|
528
|
+
"""Map the GitHub Releases payload onto the wire shape, or `None` when the
|
|
529
|
+
response is not an array at all.
|
|
530
|
+
|
|
531
|
+
Entries without a string `tag_name` are dropped (there is nothing to name
|
|
532
|
+
them). `draft`/`prerelease` default to `False` when absent or non-boolean:
|
|
533
|
+
absent means "not flagged", never a reason to drop the release (tan-cli
|
|
534
|
+
#122).
|
|
535
|
+
"""
|
|
536
|
+
if not isinstance(payload, list):
|
|
537
|
+
return None
|
|
538
|
+
releases: list[dict[str, Any]] = []
|
|
539
|
+
for item in payload:
|
|
540
|
+
if not isinstance(item, dict) or not isinstance(item.get("tag_name"), str):
|
|
541
|
+
continue
|
|
542
|
+
body = _str_field(item, "body")
|
|
543
|
+
releases.append(
|
|
544
|
+
{
|
|
545
|
+
"tag": _str_field(item, "tag_name"),
|
|
546
|
+
"publishedAt": _str_field(item, "published_at"),
|
|
547
|
+
"tarballUrl": _str_field(item, "tarball_url"),
|
|
548
|
+
"releaseNotesSummary": _first_paragraph(body),
|
|
549
|
+
"releaseNotes": body.strip(),
|
|
550
|
+
"draft": item.get("draft") is True,
|
|
551
|
+
"prerelease": item.get("prerelease") is True,
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
return releases
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _unroutable_proxy_refusal(proxy: str | None, url: str) -> str | None:
|
|
558
|
+
"""Why this request must not be attempted at all, or `None`.
|
|
559
|
+
|
|
560
|
+
REFUSES rather than falling back to a direct connection when the selected
|
|
561
|
+
proxy uses a scheme urllib cannot carry (`unsupported_proxy_scheme`). A host
|
|
562
|
+
that exports a SOCKS proxy is telling tan it has no other egress, or that
|
|
563
|
+
direct egress is not permitted; quietly ignoring it is exactly the
|
|
564
|
+
silent-bypass defect this path was fixed for (tan-cli#497 defect 8). The
|
|
565
|
+
oracle CAN dial SOCKS -- `crates/tan-cli/src/http.rs` compiles ureq's
|
|
566
|
+
`socks-proxy` feature, measured -- so this names the limitation instead of
|
|
567
|
+
hiding it.
|
|
568
|
+
|
|
569
|
+
**The remediation names the variable that actually WON, and offers unsetting
|
|
570
|
+
it first.** The first cut said "Set `HTTPS_PROXY` to an http:// or https://
|
|
571
|
+
proxy", which cannot take effect: `HTTPS_PROXY_ENV_VARS` puts `ALL_PROXY`
|
|
572
|
+
ahead of `HTTPS_PROXY`, so the selection never reaches the variable the
|
|
573
|
+
reader was told to set. Measured --
|
|
574
|
+
`ALL_PROXY=socks5://127.0.0.1:45997 HTTPS_PROXY=http://127.0.0.1:45996` gave
|
|
575
|
+
the identical refusal at rc 1 with NEITHER socket touched. That is the same
|
|
576
|
+
self-defeating-remediation class as tan-cli#497 defect 7, which this branch
|
|
577
|
+
fixed two commands away: an instruction the user can follow exactly and be
|
|
578
|
+
no better off.
|
|
579
|
+
"""
|
|
580
|
+
if proxy is None:
|
|
581
|
+
return None
|
|
582
|
+
scheme = unsupported_proxy_scheme(proxy)
|
|
583
|
+
if scheme is None:
|
|
584
|
+
return None
|
|
585
|
+
variable = _proxy_variable_name()
|
|
586
|
+
return (
|
|
587
|
+
f"Alp SDK: {variable} names a {scheme}:// proxy, which this "
|
|
588
|
+
f"build of tan cannot route through (its HTTP client has no {scheme} "
|
|
589
|
+
f"transport). Unset {variable}, or point it at an http:// or https:// "
|
|
590
|
+
f"proxy, or add {host_of(url)} to NO_PROXY to allow a direct connection."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _releases_opener(proxy: str | None) -> OpenerDirector:
|
|
595
|
+
"""An opener carrying OUR proxy decision, never urllib's own.
|
|
596
|
+
|
|
597
|
+
`urlopen` derives its handler from `getproxies_environment()`, which maps
|
|
598
|
+
`ALL_PROXY` to the key `all` -- and `OpenerDirector._open` dispatches on
|
|
599
|
+
`req.type` (`"https"`), so the `all_open` method that installs is never
|
|
600
|
+
called and the proxy is silently bypassed (tan-cli#497 defect 8). Both keys
|
|
601
|
+
carry the same value because a proxy chosen for an `https://` request is
|
|
602
|
+
what `ALL_PROXY` means, and an EMPTY mapping means DIRECT -- not "fall back
|
|
603
|
+
to the environment", which would re-open the `NO_PROXY` half of the same
|
|
604
|
+
defect.
|
|
605
|
+
|
|
606
|
+
tan-cli#810: `urllib.request` is imported HERE rather than at module scope.
|
|
607
|
+
`cli.py` static-imports every command module, so this file's module-scope
|
|
608
|
+
`import urllib.request` -- which drags `email`, `http` and `ssl` behind it
|
|
609
|
+
-- was paid by `tan --version`, for a stack only the two network functions
|
|
610
|
+
in this file ever touch.
|
|
611
|
+
|
|
612
|
+
The return annotation is spelled `OpenerDirector`, bound by the
|
|
613
|
+
`TYPE_CHECKING` block at the top rather than by a runtime import, and that
|
|
614
|
+
is deliberate: an annotation binds in the MODULE namespace, not the
|
|
615
|
+
function's, so `-> urllib.request.OpenerDirector` would have named
|
|
616
|
+
something no longer there. `from __future__ import annotations` keeps it a
|
|
617
|
+
string, so nothing breaks at def or call time -- but
|
|
618
|
+
`typing.get_type_hints()` on this function would raise `NameError`, and
|
|
619
|
+
that API is live in this very file (`accept_global_flags` calls it,
|
|
620
|
+
`core/global_flags.py`). It is aimed at the `sdk` COMMAND callback, never
|
|
621
|
+
at this private helper, so the two do not meet; do not add a wrapper that
|
|
622
|
+
resolves hints here without re-reading this paragraph.
|
|
623
|
+
"""
|
|
624
|
+
import urllib.request # noqa: PLC0415
|
|
625
|
+
|
|
626
|
+
return urllib.request.build_opener(
|
|
627
|
+
urllib.request.ProxyHandler({} if proxy is None else {"http": proxy, "https": proxy}),
|
|
628
|
+
urllib.request.HTTPSHandler(context=default_ssl_context()),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _fetch_releases(url: str = GITHUB_RELEASES_URL) -> tuple[list[dict[str, Any]], str | None]:
|
|
633
|
+
"""GET the releases API and parse it. Returns `(releases, error_message)`;
|
|
634
|
+
exactly one side is meaningful.
|
|
635
|
+
|
|
636
|
+
Every failure -- DNS, TLS, a proxy, a 404, a truncated body, HTML where JSON
|
|
637
|
+
was promised -- comes back as a MESSAGE, so the caller always has an
|
|
638
|
+
envelope to emit. A bare `except Exception` is the point: this is the one
|
|
639
|
+
path in the file that touches a network stack, and an unanticipated
|
|
640
|
+
transport error escaping here is the port's recurring bug class (a traceback
|
|
641
|
+
on stderr, nothing on stdout, and an extension that renders nothing).
|
|
642
|
+
"""
|
|
643
|
+
import urllib.request # noqa: PLC0415 -- deferred, see `_releases_opener`
|
|
644
|
+
|
|
645
|
+
request = urllib.request.Request( # noqa: S310 -- constant https:// endpoint
|
|
646
|
+
url,
|
|
647
|
+
headers={
|
|
648
|
+
"User-Agent": "tan-cli/0",
|
|
649
|
+
"Accept": "application/vnd.github+json",
|
|
650
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
proxy = select_https_proxy(url)
|
|
654
|
+
refusal = _unroutable_proxy_refusal(proxy, url)
|
|
655
|
+
if refusal is not None:
|
|
656
|
+
return [], refusal
|
|
657
|
+
opener = _releases_opener(proxy)
|
|
658
|
+
try:
|
|
659
|
+
with opener.open(request, timeout=NETWORK_TIMEOUT_SECONDS) as response:
|
|
660
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
661
|
+
except Exception as err: # noqa: BLE001 -- see the docstring
|
|
662
|
+
detail = str(err) or type(err).__name__
|
|
663
|
+
return [], describe_network_error(detail, proxy_used=proxy is not None)
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
payload = json.loads(raw)
|
|
667
|
+
except ValueError as err:
|
|
668
|
+
return [], f"Alp SDK: could not parse the GitHub Releases response: {err}"
|
|
669
|
+
|
|
670
|
+
releases = parse_remote_sdk_releases(payload)
|
|
671
|
+
if releases is None:
|
|
672
|
+
return [], "Alp SDK: unexpected response shape from GitHub Releases API."
|
|
673
|
+
return releases, None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ── envelope plumbing ───────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _list_data(releases: list[dict[str, Any]]) -> dict[str, Any]:
|
|
680
|
+
"""`sdk list`'s payload -- and the payload the unknown-subcommand FAILURE
|
|
681
|
+
carries too, matching the Rust, which builds that refusal out of `ListData`.
|
|
682
|
+
`data.releases` is the key the extension reads with `?? []`."""
|
|
683
|
+
return {"subcommand": "list", "releases": releases}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _emit(
|
|
687
|
+
*,
|
|
688
|
+
json_mode: bool,
|
|
689
|
+
data: dict[str, Any],
|
|
690
|
+
issues: list[Issue],
|
|
691
|
+
exit_code: ExitCode,
|
|
692
|
+
text_lines: list[str],
|
|
693
|
+
sdk: SdkInfo | None = None,
|
|
694
|
+
) -> None:
|
|
695
|
+
"""The single exit point: one envelope on stdout in JSON mode, human lines on
|
|
696
|
+
stderr otherwise, then `typer.Exit`.
|
|
697
|
+
|
|
698
|
+
`project` is always null (`sdk.rs`'s `null_project`) -- `sdk` verbs manage a
|
|
699
|
+
checkout, not a board. Text goes to stderr in BOTH modes so stdout can never
|
|
700
|
+
carry a stray byte beside the envelope; the conformance harness asserts
|
|
701
|
+
stderr is empty under `--format json`, which is why nothing is written there
|
|
702
|
+
in that mode at all.
|
|
703
|
+
"""
|
|
704
|
+
if json_mode:
|
|
705
|
+
emit(
|
|
706
|
+
Envelope(
|
|
707
|
+
"sdk",
|
|
708
|
+
Project(root=None, board_yaml=None),
|
|
709
|
+
data,
|
|
710
|
+
issues,
|
|
711
|
+
exit_code,
|
|
712
|
+
sdk=sdk,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
else:
|
|
716
|
+
stream = typer.get_text_stream("stderr")
|
|
717
|
+
for line in text_lines:
|
|
718
|
+
stream.write(f"{line}\n")
|
|
719
|
+
raise typer.Exit(int(exit_code))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _fail(
|
|
723
|
+
*,
|
|
724
|
+
json_mode: bool,
|
|
725
|
+
data: dict[str, Any],
|
|
726
|
+
code: str,
|
|
727
|
+
message: str,
|
|
728
|
+
text_lines: list[str],
|
|
729
|
+
exit_code: ExitCode = ExitCode.RUNTIME_FAILURE,
|
|
730
|
+
issues: list[Issue] | None = None,
|
|
731
|
+
) -> None:
|
|
732
|
+
"""A refusal: the given payload plus the `sdk.<code>` error issue.
|
|
733
|
+
`exit_code` reaches the envelope as well as the process, so `ok` and
|
|
734
|
+
`exitCode` can never disagree.
|
|
735
|
+
|
|
736
|
+
`issues` is ADDITIVE, and always FOLLOWS the refusal (tan-cli#1051): the
|
|
737
|
+
error a caller is being refused for stays `issues[0]` on every branch, so
|
|
738
|
+
the `issues[0]["code"] == "sdk.<...>"` reading every existing test and
|
|
739
|
+
consumer does keeps meaning "the reason this failed" rather than
|
|
740
|
+
"whichever warning happened to be computed first". Its one caller so far
|
|
741
|
+
is `sdk remove`, carrying the `sdk.project-pin-unresolved` warning for a
|
|
742
|
+
`.alp/sdk-path` that no longer resolves -- a fact about the workspace that
|
|
743
|
+
is equally true whether the removal was refused, idempotent, or done, and
|
|
744
|
+
that must therefore reach the refusal branches too."""
|
|
745
|
+
_emit(
|
|
746
|
+
json_mode=json_mode,
|
|
747
|
+
data=data,
|
|
748
|
+
issues=[Issue(f"sdk.{code}", "error", message), *(issues or [])],
|
|
749
|
+
exit_code=exit_code,
|
|
750
|
+
text_lines=text_lines,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ── subcommands ─────────────────────────────────────────────────────────────
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _run_current(*, json_mode: bool, sdk_root: str | None, workspace_root: Path) -> None:
|
|
758
|
+
"""`tan sdk current` -- the active SDK, its readiness, and the winning tier.
|
|
759
|
+
|
|
760
|
+
Always exit 0. "Nothing is configured" is a successful answer to the
|
|
761
|
+
question asked, and the `sdk-current-no-sdk` golden pins it.
|
|
762
|
+
"""
|
|
763
|
+
active = resolve_sdk_tiered(sdk_root, workspace_root)
|
|
764
|
+
if active.path is None:
|
|
765
|
+
# tan-cli#497 defect 1. `resolve_sdk_tiered` alone has NO candidate for
|
|
766
|
+
# a CHILD `<ws>/alp-sdk` -- the README Quickstart cwd, straight after
|
|
767
|
+
# `git clone https://github.com/alplabai/alp-sdk`. Measured there: this
|
|
768
|
+
# command answered `sdkPath: null`, `sourceTier: "none"`, `issues: []`
|
|
769
|
+
# and printed "get an alp-sdk checkout (`git clone ...`)", while
|
|
770
|
+
# `doctor`, `build`, `validate`, `inspect` and `trace` in that SAME cwd
|
|
771
|
+
# all reported `<ws>/alp-sdk` at `sourceTier: "discovery"` through
|
|
772
|
+
# `tan.core.sdk_discovery.resolve_sdk_root_ladder` -- whose own docstring names `sdk
|
|
773
|
+
# current` as one of its thirteen callers, and whose wide-walk TAIL is
|
|
774
|
+
# the tier missing here. `discover_workspace_sdk`'s docstring states the
|
|
775
|
+
# invariant this broke: "the tier it reports has to be what
|
|
776
|
+
# build/validate/doctor would actually resolve here."
|
|
777
|
+
#
|
|
778
|
+
# An intentional divergence from the oracle, which has the identical
|
|
779
|
+
# split (`sdk.rs::run_current` also calls the narrow `resolve_sdk_tiered`
|
|
780
|
+
# while `util::resolve_sdk_root` falls through to `discover_sdk_root`).
|
|
781
|
+
# `sdk current` is the one command whose entire job is answering "which
|
|
782
|
+
# SDK am I on?", so answering it differently from every command that
|
|
783
|
+
# ACTS on that SDK is the defect, not the parity.
|
|
784
|
+
#
|
|
785
|
+
# Consulted ONLY when the narrow ladder resolved nothing, and the ladder
|
|
786
|
+
# itself is reused rather than its tail re-implemented: this can add an
|
|
787
|
+
# answer where there was none, never change one that already existed
|
|
788
|
+
# (which would move the SDK root under a live workspace), and there is
|
|
789
|
+
# no third copy of the tier rule to keep true. `resolve_sdk_root_ladder`
|
|
790
|
+
# is imported at module level, from `tan.core.sdk_discovery`, alongside
|
|
791
|
+
# `resolve_sdk_tiered` and `ActiveSdk` (tan-cli#408 review follow-up) --
|
|
792
|
+
# no cycle between this module and that one exists any more, in either
|
|
793
|
+
# direction.
|
|
794
|
+
ladder = resolve_sdk_root_ladder(sdk_root, workspace_root)
|
|
795
|
+
if ladder.path is not None:
|
|
796
|
+
active = ActiveSdk(
|
|
797
|
+
str(ladder.path),
|
|
798
|
+
ladder.tier,
|
|
799
|
+
ladder.broken_project_pin,
|
|
800
|
+
ladder.foreign_global_default_for,
|
|
801
|
+
)
|
|
802
|
+
readiness = check_sdk_readiness(active.path) if active.path is not None else None
|
|
803
|
+
text = (
|
|
804
|
+
format_readiness_block("Active SDK", readiness)
|
|
805
|
+
if readiness is not None
|
|
806
|
+
else no_active_sdk_text(cached_sdk_versions())
|
|
807
|
+
)
|
|
808
|
+
issues: list[Issue] = []
|
|
809
|
+
pin_issue = project_pin_issue(active.broken_project_pin, active.tier)
|
|
810
|
+
if pin_issue is not None:
|
|
811
|
+
# tan-cli#263: without this, a workspace whose `.alp/sdk-path` names an
|
|
812
|
+
# unreachable checkout reports `ok: true`, `issues: []`, `sourceTier`
|
|
813
|
+
# silently one tier lower -- identical to a workspace that was never
|
|
814
|
+
# pinned at all. `warning`, not `error`: the command still answers the
|
|
815
|
+
# question asked (SOME SDK, or none, is active); it is the SILENCE
|
|
816
|
+
# about the rejected pin that was the defect, not the fallthrough
|
|
817
|
+
# itself.
|
|
818
|
+
text = [
|
|
819
|
+
f'Project pin .alp/sdk-path names "{active.broken_project_pin}", which '
|
|
820
|
+
f"does not resolve to an alp-sdk checkout from here.",
|
|
821
|
+
*text,
|
|
822
|
+
]
|
|
823
|
+
issues.append(pin_issue)
|
|
824
|
+
foreign_issue = global_default_foreign_project_issue(active.foreign_global_default_for)
|
|
825
|
+
if foreign_issue is not None:
|
|
826
|
+
# tan-cli#464: the shared, last-writer-wins `~/.alp/sdk-default` used
|
|
827
|
+
# to answer here with no signal that a DIFFERENT project's bootstrap
|
|
828
|
+
# relocation was what actually decided it -- `ok: true`, `issues: []`,
|
|
829
|
+
# identical to the correct case. `warning`, matching `pin_issue`
|
|
830
|
+
# above: the tier still answers the question asked; it is the silence
|
|
831
|
+
# about WHOSE answer this is that was the defect.
|
|
832
|
+
text = [
|
|
833
|
+
f'The global default SDK was last set for '
|
|
834
|
+
f'"{active.foreign_global_default_for}", not this workspace.',
|
|
835
|
+
*text,
|
|
836
|
+
]
|
|
837
|
+
issues.append(foreign_issue)
|
|
838
|
+
# tan-cli#407 names THIS command in particular: `sdk current` is what a
|
|
839
|
+
# user runs to ask "which SDK am I on?", and in a workspace holding both a
|
|
840
|
+
# child `<ws>/alp-sdk` and a lateral `../alp-sdk` it answers with the
|
|
841
|
+
# narrow one only -- reporting the readiness and VERSION of the checkout
|
|
842
|
+
# `tan generate` did not use. `sdk_ladder_divergence_issue` is imported at
|
|
843
|
+
# module level, from `tan.core.sdk_discovery` (tan-cli#408).
|
|
844
|
+
divergence = sdk_ladder_divergence_issue(sdk_root, workspace_root, wide=False)
|
|
845
|
+
if divergence is not None:
|
|
846
|
+
text = [*text, divergence.message]
|
|
847
|
+
issues.append(divergence)
|
|
848
|
+
if not json_mode:
|
|
849
|
+
# Only in text mode: `wrap_width()` probes stderr, which is the
|
|
850
|
+
# right thing to skip entirely for `--format json`, where `text` is
|
|
851
|
+
# built but never printed (`_emit`'s json branch reads `data`/
|
|
852
|
+
# `issues`, not `text_lines`).
|
|
853
|
+
text = wrap_lines(text, wrap_width())
|
|
854
|
+
_emit(
|
|
855
|
+
json_mode=json_mode,
|
|
856
|
+
data={
|
|
857
|
+
"subcommand": "current",
|
|
858
|
+
"sdkPath": active.path,
|
|
859
|
+
"readiness": readiness,
|
|
860
|
+
"sourceTier": active.tier,
|
|
861
|
+
},
|
|
862
|
+
issues=issues,
|
|
863
|
+
exit_code=ExitCode.SUCCESS,
|
|
864
|
+
text_lines=text,
|
|
865
|
+
# The envelope's optional `sdk` key mirrors the Rust's
|
|
866
|
+
# `sdk_report::record` at this exact call site: `run_current` is the one
|
|
867
|
+
# place whose resolution IS "the active SDK". Recorded even for an
|
|
868
|
+
# invalid `--sdk-root`, matching the `if let Some(root)` there -- and
|
|
869
|
+
# absent entirely (never `null`) when nothing resolved.
|
|
870
|
+
sdk=SdkInfo.from_resolution(active.path, active) if active.path is not None else None,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _run_list(*, json_mode: bool, online: bool) -> None:
|
|
875
|
+
"""`tan sdk list` -- the published alp-sdk releases.
|
|
876
|
+
|
|
877
|
+
tan-cli#351: bare `sdk list` (no `--online`) answers OFFLINE, at exit 0.
|
|
878
|
+
Measured first, since this file already carries the reasoning for why the
|
|
879
|
+
port diverges from the oracle here: the oracle (`target/debug/tan.exe`,
|
|
880
|
+
tan 0.4.1) has no `--online` flag at all -- `sdk list --help` lists none --
|
|
881
|
+
and reaches the network unconditionally on every `sdk list` call (a live
|
|
882
|
+
run against a reachable network succeeds at exit 0 with no flag given).
|
|
883
|
+
Gating the fetch behind `--online` is this PORT's own addition, for
|
|
884
|
+
hermeticity (I-23): a command that silently opens a socket cannot be
|
|
885
|
+
driven from a hermetic test, an air-gapped host, or a fixture. That gate
|
|
886
|
+
is not, itself, a verdict on anything the caller did wrong -- there is no
|
|
887
|
+
"failure" here to report, only a question (`sdk list` answers what alp-sdk
|
|
888
|
+
has published upstream) that needs an explicit flag to actually reach the
|
|
889
|
+
network for. Exiting non-zero for it (as this used to) treated a normal,
|
|
890
|
+
everyday invocation the same as a real error, which is exactly the
|
|
891
|
+
asymmetry `sdk current` never had: "nothing configured" is exit 0 there,
|
|
892
|
+
and "list needs `--online`" now is here too. The message says plainly
|
|
893
|
+
what `sdk list` reports (UPSTREAM releases) and that `--online` is the
|
|
894
|
+
switch that fetches them, rather than reporting the missing flag as a
|
|
895
|
+
network requirement failure.
|
|
896
|
+
"""
|
|
897
|
+
if not online:
|
|
898
|
+
_emit(
|
|
899
|
+
json_mode=json_mode,
|
|
900
|
+
data=_list_data([]),
|
|
901
|
+
issues=[
|
|
902
|
+
Issue(
|
|
903
|
+
"sdk.network-required",
|
|
904
|
+
"warning",
|
|
905
|
+
"`sdk list` reports the Alp SDK releases published upstream "
|
|
906
|
+
"on GitHub -- there is no local/offline copy to answer from. "
|
|
907
|
+
"Add --online to fetch them.",
|
|
908
|
+
)
|
|
909
|
+
],
|
|
910
|
+
exit_code=ExitCode.SUCCESS,
|
|
911
|
+
text_lines=[
|
|
912
|
+
"sdk list: reports Alp SDK releases published upstream on GitHub.",
|
|
913
|
+
"Add --online to fetch them: `tan sdk list --online`.",
|
|
914
|
+
],
|
|
915
|
+
)
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
releases, error = _fetch_releases()
|
|
919
|
+
if error is not None:
|
|
920
|
+
_fail(
|
|
921
|
+
json_mode=json_mode,
|
|
922
|
+
data=_list_data([]),
|
|
923
|
+
code="fetch-failed",
|
|
924
|
+
message=error,
|
|
925
|
+
text_lines=["sdk list: failed to fetch releases.", error],
|
|
926
|
+
)
|
|
927
|
+
return
|
|
928
|
+
|
|
929
|
+
_emit(
|
|
930
|
+
json_mode=json_mode,
|
|
931
|
+
data=_list_data(releases),
|
|
932
|
+
issues=[],
|
|
933
|
+
exit_code=ExitCode.SUCCESS,
|
|
934
|
+
text_lines=format_release_table(releases),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _run_not_ported(*, json_mode: bool, subcommand: str, data: dict[str, Any]) -> None:
|
|
939
|
+
"""`install` / `switch` -- refused outright rather than half-implemented.
|
|
940
|
+
|
|
941
|
+
Exit 1 (`RuntimeFailure`) -- the same code every other refusal in this
|
|
942
|
+
module already uses (`sdk list` without `--online`, a bare `tan sdk`) and
|
|
943
|
+
the same one the seven deferred-verb stubs settled on before they shipped
|
|
944
|
+
(tan-cli#260; their module is gone as of tan-cli#427).
|
|
945
|
+
|
|
946
|
+
This was exit 5 (`InternalFailure`) until #262, on a docstring that
|
|
947
|
+
justified it as "following `validate_cmd`'s precedent for its own unported
|
|
948
|
+
spawn path". That citation was backwards: `validate_cmd` uses exit 1, and
|
|
949
|
+
its module docstring says exit 5 would wrongly tell CI/the extension this
|
|
950
|
+
is a tan crash. The comment argued for the opposite of what the code did.
|
|
951
|
+
|
|
952
|
+
Measured, not inferred -- the oracle's own `sdk switch` failure is exit 1:
|
|
953
|
+
`tan sdk switch 0.0.0-nonexistent --format json` -> rc=1,
|
|
954
|
+
`sdk.path-not-found`. Nothing under `contract/` pins a 5 here.
|
|
955
|
+
|
|
956
|
+
5 stays reserved for a genuine tan crash; 2 would be rendered as a warning
|
|
957
|
+
by a consumer that should be seeing a hard stop. The payload keeps each
|
|
958
|
+
verb's real key set so a consumer reading `data.sdkPath`/`data.version`
|
|
959
|
+
still finds them.
|
|
960
|
+
"""
|
|
961
|
+
_fail(
|
|
962
|
+
json_mode=json_mode,
|
|
963
|
+
data=data,
|
|
964
|
+
code="not-ported",
|
|
965
|
+
message=(
|
|
966
|
+
f"`sdk {subcommand}` is not available in this build of tan. It writes "
|
|
967
|
+
"the active-SDK pointer and reconciles the west workspace manifest, "
|
|
968
|
+
"and a partial implementation would report success while `west` kept "
|
|
969
|
+
"resolving the old SDK."
|
|
970
|
+
),
|
|
971
|
+
text_lines=[
|
|
972
|
+
f"sdk {subcommand}: not available in this build of tan.",
|
|
973
|
+
f"To get started, {NO_SDK_NEXT_STEPS}.",
|
|
974
|
+
"`tan doctor` reports which checkout tan currently resolves.",
|
|
975
|
+
],
|
|
976
|
+
exit_code=ExitCode.RUNTIME_FAILURE,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _run_unknown(*, json_mode: bool, subcommand: str | None) -> None:
|
|
981
|
+
"""Anything else. Exit 1 with the `list`-shaped payload -- both pinned by
|
|
982
|
+
the `sdk-unknown-subcommand` golden. `(none)` stands in for a bare `tan
|
|
983
|
+
sdk`, matching the Rust's `other.unwrap_or("(none)")`; an explicitly EMPTY
|
|
984
|
+
argument is reported as the empty string it was, same as there.
|
|
985
|
+
"""
|
|
986
|
+
sub = "(none)" if subcommand is None else subcommand
|
|
987
|
+
_fail(
|
|
988
|
+
json_mode=json_mode,
|
|
989
|
+
data=_list_data([]),
|
|
990
|
+
code="unknown-subcommand",
|
|
991
|
+
message=f"Unknown sdk subcommand: {sub}",
|
|
992
|
+
text_lines=[f"sdk: unknown subcommand '{sub}'.", AVAILABLE_SUBCOMMANDS],
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
# ── sdk remove (tan-cli#790) ─────────────────────────────────────────────────
|
|
997
|
+
#
|
|
998
|
+
# The one destructive verb in this file, so it earns its own section rather
|
|
999
|
+
# than living beside `install`/`switch`'s refusal stubs. Designed for
|
|
1000
|
+
# long-term customer experience the way the maintainer's own standing
|
|
1001
|
+
# direction for this class of question asks, spelled out here once rather
|
|
1002
|
+
# than re-derived at every call site below:
|
|
1003
|
+
#
|
|
1004
|
+
# * an install that is currently load-bearing -- the ACTIVE resolution for
|
|
1005
|
+
# this workspace, the machine-global default, or a project's own
|
|
1006
|
+
# registered pin -- refuses to be removed without `--force`
|
|
1007
|
+
# ([`_load_bearing_reasons`]); silently orphaning any of the three is a
|
|
1008
|
+
# worse failure than a refusal that names exactly what would break;
|
|
1009
|
+
# * removal is IDEMPOTENT: a target that is already absent succeeds at
|
|
1010
|
+
# `data.removed: false`, so a rotation script never has to pre-check;
|
|
1011
|
+
# * a target outside the cache root refuses without `--force` too -- the
|
|
1012
|
+
# footgun guard `is_outside_cache_root` exists for -- but ONLY once it is
|
|
1013
|
+
# confirmed to exist, so an idempotent no-op never trips it;
|
|
1014
|
+
# * the cache ROOT ITSELF -- every install at once, not one of them --
|
|
1015
|
+
# refuses without `--force` too (`is_cache_root_itself`): the outside-root
|
|
1016
|
+
# guard above deliberately does NOT catch this (`target == destination` is
|
|
1017
|
+
# not "outside"), and no version is individually load-bearing for its own
|
|
1018
|
+
# root, so this was the one target `--force`-less `remove` could wipe the
|
|
1019
|
+
# whole cache with, found live during this review;
|
|
1020
|
+
# * every failure names WHAT blocked it (`sdk.remove-active`,
|
|
1021
|
+
# `sdk.remove-outside-root`, `sdk.remove-is-cache-root`,
|
|
1022
|
+
# `sdk.remove-in-use`, `sdk.remove-permission` -- flat, ONE dot, not the
|
|
1023
|
+
# nested `sdk.remove.active` shape the issue's own prose sketches:
|
|
1024
|
+
# `contract/issue-codes.json`'s `sdk.remove-missing-argument` entry
|
|
1025
|
+
# explains why nested is not available on this wire) and what to do
|
|
1026
|
+
# instead (`--force`, for the first three; close the holder, for the
|
|
1027
|
+
# fourth; fix the permissions/attributes by hand, for the fifth) -- the
|
|
1028
|
+
# issue's own bar: "the refusal messages must name what blocked it and
|
|
1029
|
+
# what to do instead".
|
|
1030
|
+
#
|
|
1031
|
+
# TWO follow-ups closed on top of that design, both about the same refusal.
|
|
1032
|
+
#
|
|
1033
|
+
# tan-cli#1053 -- the four comparisons behind it were a plain string `==`,
|
|
1034
|
+
# which answers the question the CALLER asked rather than the one the
|
|
1035
|
+
# FILESYSTEM would answer. On a case-INSENSITIVE volume (NTFS, and macOS's
|
|
1036
|
+
# DEFAULT APFS volume -- so this reproduced on a maintainer laptop, not only
|
|
1037
|
+
# on Windows) two spellings name ONE directory and compare unequal, so the
|
|
1038
|
+
# refusal never fired and the install was removed without `--force`. All four
|
|
1039
|
+
# now go through `sdk_removal.removal_would_take_out`, whose own section
|
|
1040
|
+
# banner carries the platform reasoning and the limits it still has.
|
|
1041
|
+
#
|
|
1042
|
+
# tan-cli#1051 -- `remove` deletes the install a workspace's `.alp/sdk-path`
|
|
1043
|
+
# names but never touches that FILE, so a forced removal leaves the pin
|
|
1044
|
+
# dangling, and `data.resolvesToAfter` then reports `sourceTier: "none"` --
|
|
1045
|
+
# which is also what a workspace that was never pinned reports. Measured: the
|
|
1046
|
+
# removal came back `issues: []` while `sdk current` in the same directory a
|
|
1047
|
+
# moment later carried `sdk.project-pin-unresolved` naming the dangling
|
|
1048
|
+
# pointer; two commands disagreeing about one workspace was the defect. Every
|
|
1049
|
+
# `_emit`/`_fail` call site below now carries that same warning (`_fail`
|
|
1050
|
+
# takes an ADDITIVE `issues` list for it, after the refusal, so `issues[0]`
|
|
1051
|
+
# still names what blocked the removal) on EVERY branch -- refusal,
|
|
1052
|
+
# idempotent no-op, removal failure, success -- because a dangling pin is a
|
|
1053
|
+
# fact about the workspace, not about whether this call deleted anything.
|
|
1054
|
+
#
|
|
1055
|
+
# The warning carries `resolvesToAfter`'s OWN narrow tier, which is not always
|
|
1056
|
+
# the tier `sdk current` names for the same workspace at the same instant.
|
|
1057
|
+
# Measured, in the tan-cli#497 workspace shape (a child `<ws>/alp-sdk`, which
|
|
1058
|
+
# only the WIDE `resolve_sdk_root_ladder` tail finds):
|
|
1059
|
+
#
|
|
1060
|
+
# sdk remove --force : "... falling through to the none tier instead."
|
|
1061
|
+
# sdk current : "... falling through to the discovery tier instead."
|
|
1062
|
+
#
|
|
1063
|
+
# That divergence is inherited, not introduced: `resolvesToAfter` has reported
|
|
1064
|
+
# the narrow ladder's answer since tan-cli#1028, on the deliberate ground that
|
|
1065
|
+
# `remove`'s reported outcome must come from the SAME ladder `remove`'s own
|
|
1066
|
+
# refusal consulted (see `_resolves_to_after`'s docstring). Feeding the warning
|
|
1067
|
+
# the WIDE tier instead would leave one envelope saying `sourceTier: "none"` in
|
|
1068
|
+
# `data` while its own `issues[]` said "falling through to the discovery tier"
|
|
1069
|
+
# -- a fresh contradiction inside a single response. Widening BOTH would change
|
|
1070
|
+
# a released contract field for exactly that workspace shape and undo
|
|
1071
|
+
# tan-cli#1028's stated design. So the warning stays narrow and internally
|
|
1072
|
+
# consistent, and the claim that the two commands emit the identical issue --
|
|
1073
|
+
# which an earlier draft of this change made in its changelog and pinned with a
|
|
1074
|
+
# test that had no discoverable checkout and so passed vacuously -- is simply
|
|
1075
|
+
# withdrawn. What IS true, and is what tan-cli#1051 asked for, is that `sdk
|
|
1076
|
+
# remove` no longer stays SILENT about a pin `sdk current` warns about.
|
|
1077
|
+
#
|
|
1078
|
+
# REPORTING, not repair, deliberately: clearing `.alp/sdk-path` was the other
|
|
1079
|
+
# live option and is rejected. `--force` on `remove <version>` is consent to
|
|
1080
|
+
# delete THAT INSTALL, not consent to rewrite a workspace config file the
|
|
1081
|
+
# caller never named, and every refusal above fires BEFORE any filesystem
|
|
1082
|
+
# write precisely so the target is the only thing this verb touches. Repair
|
|
1083
|
+
# would also be structurally partial -- `remove` can only reach the ONE
|
|
1084
|
+
# workspace it ran in, while every OTHER project on the host pinned at the
|
|
1085
|
+
# same install stays dangling either way, and only a report can reach those.
|
|
1086
|
+
#
|
|
1087
|
+
# `sdk list`'s proposed `managed`/`active` columns (tan-cli#790's own "related
|
|
1088
|
+
# gap" aside) are deliberately OUT of this change: `sdk list` today reports
|
|
1089
|
+
# UPSTREAM GitHub releases, not local installs, so a per-release
|
|
1090
|
+
# managed/active flag has no local install to describe until `sdk
|
|
1091
|
+
# install`/`sdk switch` are themselves ported (tan-cli#305) -- tracked
|
|
1092
|
+
# separately, not bundled into a single-verb change.
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _sdk_default_pointer_target() -> str | None:
|
|
1096
|
+
"""`~/.alp/sdk-default`'s own `sdkPath`, read DIRECTLY rather than through
|
|
1097
|
+
`resolve_sdk_tiered`. That ladder only ever reports the ONE tier that
|
|
1098
|
+
wins for the CALLER's workspace, and the plain machine-global pointer can
|
|
1099
|
+
name a checkout this workspace's own project pin or registry entry
|
|
1100
|
+
outranks -- so it would never surface as `active.path` here -- while
|
|
1101
|
+
still being exactly what removing it would orphan for every OTHER,
|
|
1102
|
+
unregistered project on the host that falls through to it. `None` on any
|
|
1103
|
+
read/parse failure, the same degrade `_pointer_target` already applies to
|
|
1104
|
+
a missing or malformed pointer.
|
|
1105
|
+
"""
|
|
1106
|
+
return _pointer_target(_home_alp_dir() / "sdk-default")
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _registered_entries_for(target_posix: str) -> list[tuple[str, str]]:
|
|
1110
|
+
"""Every `~/.alp/sdk-defaults.json` `(origin, normalised sdkPath)` whose
|
|
1111
|
+
`sdkPath` names `target_posix` (posix-normalised, matching how the
|
|
1112
|
+
registry itself stores it -- `bootstrap_cmd._write_global_sdk_registry`'s
|
|
1113
|
+
own `_to_posix` write). Sorted for a deterministic message; `[]` on any
|
|
1114
|
+
read/parse failure, matching `parse_registry`'s own best-effort contract.
|
|
1115
|
+
|
|
1116
|
+
Returns the matched SPELLING alongside the origin, not just the origin,
|
|
1117
|
+
because the two callers need it at two different moments: the refusal
|
|
1118
|
+
names the origin BEFORE anything is deleted, while the prune runs AFTER
|
|
1119
|
+
`remove_sdk_tree` -- by which point the target directory is gone and the
|
|
1120
|
+
filesystem arm of `removal_would_take_out` can no longer recognise an
|
|
1121
|
+
alias spelling at all (see `_prune_registry_entries_for`).
|
|
1122
|
+
"""
|
|
1123
|
+
raw = _read_file(registry_path(_home_alp_dir()))
|
|
1124
|
+
registry = parse_registry(raw)
|
|
1125
|
+
return sorted(
|
|
1126
|
+
(origin, normalized_sdk_path(sdk_path))
|
|
1127
|
+
for origin, sdk_path in registry.items()
|
|
1128
|
+
# Separator-folded AND platform-compared, never a raw `==` (which is
|
|
1129
|
+
# what this was through tan-cli#1053): a hand-edited registry on
|
|
1130
|
+
# Windows spells the same directory with backslashes,
|
|
1131
|
+
# `normalized_sdk_path` folds those; a case-INSENSITIVE volume (NTFS,
|
|
1132
|
+
# or macOS's default APFS) spells it in another case, and a symlinked
|
|
1133
|
+
# cache spells it through the link -- `removal_would_take_out` is
|
|
1134
|
+
# what answers those. Missing the match here does not merely skip a
|
|
1135
|
+
# tidy-up: this removal then does NOT refuse and silently orphans that
|
|
1136
|
+
# project.
|
|
1137
|
+
if removal_would_take_out(normalized_sdk_path(sdk_path), target_posix)
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _load_bearing_reasons(
|
|
1142
|
+
target_posix: str, active: ActiveSdk, registered: list[tuple[str, str]]
|
|
1143
|
+
) -> list[str]:
|
|
1144
|
+
"""Every reason removing `target_posix` right now would orphan something
|
|
1145
|
+
live -- the tan-cli#790 design bar itself: "silently orphaning either [the
|
|
1146
|
+
active install or a pinned one] is a worse failure than refusing". `[]`
|
|
1147
|
+
means safe to remove without `--force`. More than one reason can apply at
|
|
1148
|
+
once (the active install for THIS workspace can also be another
|
|
1149
|
+
project's registered default), and the caller reports all of them rather
|
|
1150
|
+
than only the first.
|
|
1151
|
+
|
|
1152
|
+
`registered` is passed IN rather than looked up here (tan-cli#1053
|
|
1153
|
+
review): the caller has to compute it before `remove_sdk_tree` runs
|
|
1154
|
+
anyway, so that the prune afterwards can still recognise the alias
|
|
1155
|
+
spellings whose directory no longer exists, and computing it twice would
|
|
1156
|
+
be two registry reads that can disagree.
|
|
1157
|
+
"""
|
|
1158
|
+
reasons: list[str] = []
|
|
1159
|
+
# `removal_would_take_out`, not `==` (tan-cli#1053) -- on a
|
|
1160
|
+
# case-insensitive volume, or through a symlinked cache, two spellings
|
|
1161
|
+
# name ONE directory and a raw string compare answers this in the UNSAFE
|
|
1162
|
+
# direction: no reason is collected, nothing refuses, the workspace is
|
|
1163
|
+
# orphaned.
|
|
1164
|
+
if active.path is not None and removal_would_damage(_abs_posix(active.path), target_posix):
|
|
1165
|
+
reasons.append(f'the active alp-sdk for this workspace (sourceTier "{active.tier}")')
|
|
1166
|
+
default_target = _sdk_default_pointer_target()
|
|
1167
|
+
if default_target is not None and removal_would_damage(
|
|
1168
|
+
_abs_posix(default_target), target_posix
|
|
1169
|
+
):
|
|
1170
|
+
reasons.append("the machine-global default SDK (~/.alp/sdk-default)")
|
|
1171
|
+
named = {origin for origin, _sdk_path in registered}
|
|
1172
|
+
# `registered` holds entries the removal destroys (equal to, or inside,
|
|
1173
|
+
# the target); an entry whose install CONTAINS the target is damaged too.
|
|
1174
|
+
named |= _registered_containing(target_posix, named)
|
|
1175
|
+
for origin in sorted(named):
|
|
1176
|
+
reasons.append(f'the registered global default for project "{origin}"')
|
|
1177
|
+
return reasons
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _registered_containing(target_posix: str, already: set[str]) -> set[str]:
|
|
1181
|
+
"""Origins of registry entries whose install CONTAINS `target_posix` (a
|
|
1182
|
+
subtree removal damages them without deleting them), minus `already`."""
|
|
1183
|
+
raw = _read_file(registry_path(_home_alp_dir()))
|
|
1184
|
+
return {
|
|
1185
|
+
origin
|
|
1186
|
+
for origin, sdk_path in parse_registry(raw).items()
|
|
1187
|
+
if origin not in already
|
|
1188
|
+
and removal_would_damage(normalized_sdk_path(sdk_path), target_posix)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _prune_registry_entries_for(target_posix: str, registered: list[tuple[str, str]]) -> None:
|
|
1193
|
+
"""Best-effort: drop every `~/.alp/sdk-defaults.json` entry naming the
|
|
1194
|
+
just-removed `target_posix` -- keeps tan-cli#905's registry honest about
|
|
1195
|
+
what still resolves (`sdk_default_registry.prune_entries_by_sdk_path`),
|
|
1196
|
+
the same read-modify-write shape `bootstrap_cmd._write_global_sdk_registry`
|
|
1197
|
+
already uses for the ORIGIN-pruning half of the identical file. Silent on
|
|
1198
|
+
any failure, matching every other best-effort registry write in this
|
|
1199
|
+
codebase: the removal itself already succeeded by the time this runs, and
|
|
1200
|
+
a registry entry this call could not prune degrades no worse than it
|
|
1201
|
+
already would have before this function existed (`deepest_covering_entry`
|
|
1202
|
+
skips a covering entry whose `sdkPath` fails `_has_loader_script`, so a
|
|
1203
|
+
dead entry left behind answers nobody incorrectly -- it is merely not yet
|
|
1204
|
+
tidied).
|
|
1205
|
+
|
|
1206
|
+
`registered` is the match set computed BEFORE `remove_sdk_tree` ran, and
|
|
1207
|
+
it is what makes this the FIFTH site of tan-cli#1053 rather than a sixth
|
|
1208
|
+
defect (review): this function is called after the target directory is
|
|
1209
|
+
gone, so `removal_would_take_out`'s filesystem arm has nothing left to
|
|
1210
|
+
compare `st_dev`/`st_ino` against and an alias-spelled entry -- the very
|
|
1211
|
+
entry the refusal above correctly named -- would be refused and then NOT
|
|
1212
|
+
pruned, leaving the registry claiming a checkout this call just deleted.
|
|
1213
|
+
Measured exactly that way on the first version of this change. The
|
|
1214
|
+
lexical arm still runs for anything `registered` did not capture (an
|
|
1215
|
+
entry written between the two moments), so this widens the prune and
|
|
1216
|
+
never narrows it.
|
|
1217
|
+
"""
|
|
1218
|
+
try:
|
|
1219
|
+
path = registry_path(_home_alp_dir())
|
|
1220
|
+
raw = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
1221
|
+
matched_before_removal = frozenset(sdk_path for _origin, sdk_path in registered)
|
|
1222
|
+
|
|
1223
|
+
def matches(stored: str, target: str) -> bool:
|
|
1224
|
+
return stored in matched_before_removal or removal_would_take_out(stored, target)
|
|
1225
|
+
|
|
1226
|
+
pruned = prune_entries_by_sdk_path(
|
|
1227
|
+
load_raw(raw), sdk_path=target_posix, matches=matches
|
|
1228
|
+
)
|
|
1229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1230
|
+
atomic_write_text(str(path), registry_text(pruned))
|
|
1231
|
+
except Exception: # noqa: BLE001 -- best-effort, matching every other registry write
|
|
1232
|
+
pass
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
class _AfterRemoval(NamedTuple):
|
|
1236
|
+
"""What `sdk remove` reports about the workspace once this call is done:
|
|
1237
|
+
the `data.resolvesToAfter` payload, and the issues that payload alone
|
|
1238
|
+
cannot express (tan-cli#1051). Paired in ONE return value so a call site
|
|
1239
|
+
physically cannot take the payload and drop the warning -- the shape the
|
|
1240
|
+
defect had before this."""
|
|
1241
|
+
|
|
1242
|
+
data: dict[str, Any]
|
|
1243
|
+
issues: list[Issue]
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _resolves_to_after(workspace_root: Path) -> _AfterRemoval:
|
|
1247
|
+
"""The narrow `resolve_sdk_tiered` answer for `workspace_root` right now
|
|
1248
|
+
-- tan-cli#1028's answer to "what resolves after this removal". This is
|
|
1249
|
+
NOT necessarily what `tan sdk current` would report for the same
|
|
1250
|
+
workspace at the same instant: it reuses `resolve_sdk_tiered`, the SAME
|
|
1251
|
+
narrow ladder `_load_bearing_reasons`'/`active` above already call
|
|
1252
|
+
(`resolve_sdk_tiered(None, workspace_root)`, `sdk_root` never threaded
|
|
1253
|
+
through from the `--sdk-root` flag either), so `remove`'s own load-bearing
|
|
1254
|
+
refusal logic and its `resolvesToAfter` answer are always asking the same
|
|
1255
|
+
question of the same ladder -- but `_run_current` falls through to the
|
|
1256
|
+
WIDE `resolve_sdk_root_ladder` tail when the narrow ladder finds nothing
|
|
1257
|
+
(tan-cli#497 defect 1) and honours an explicit `--sdk-root`. Neither
|
|
1258
|
+
fallback runs here, so this can report a tier -- or `"none"` -- one below
|
|
1259
|
+
what `sdk current` would say for the identical workspace right after this
|
|
1260
|
+
call returns. Consistency with `remove`'s own refusal ladder wins over
|
|
1261
|
+
parity with `sdk current`'s wider answer; a caller that needs the wider
|
|
1262
|
+
answer still makes the separate `sdk current` call.
|
|
1263
|
+
|
|
1264
|
+
Called at EVERY `_remove_data` call site below, not only on a load-bearing
|
|
1265
|
+
removal (tan-cli#1028's own design question, answered: an always-present
|
|
1266
|
+
field is easier for a caller to code against than one that appears only
|
|
1267
|
+
sometimes). Computed FRESH at each call site -- after `remove_sdk_tree` on
|
|
1268
|
+
the branches that delete something, unchanged on every
|
|
1269
|
+
refusal/idempotent/failed branch -- so it is truthful on both without a
|
|
1270
|
+
second "did this call mutate the tree" flag to keep in sync with the first.
|
|
1271
|
+
|
|
1272
|
+
Mirrors `sdk current`'s own `data` SHAPE (`sdkPath`, `readiness`,
|
|
1273
|
+
`sourceTier`) rather than inventing a fourth shape for the same question
|
|
1274
|
+
-- a caller that already knows how to read `sdk current`'s answer does
|
|
1275
|
+
not have to learn a second shape -- and the no-SDK-resolves case -- the
|
|
1276
|
+
one a caller most needs told about after a load-bearing removal -- comes
|
|
1277
|
+
back exactly as `sdk-current-no-sdk` pins it: `sdkPath: null`, `readiness:
|
|
1278
|
+
null`, `sourceTier: "none"`.
|
|
1279
|
+
|
|
1280
|
+
That `"none"` reading is also why this returns ISSUES beside the payload
|
|
1281
|
+
(tan-cli#1051): a force-removed project pin is left DANGLING, and
|
|
1282
|
+
`"none"` alone cannot be told apart from a never-pinned workspace. The
|
|
1283
|
+
`project_pin_issue` helper is called on the SAME `ActiveSdk` -- so the
|
|
1284
|
+
same NARROW tier -- this payload is built from, so warning and payload
|
|
1285
|
+
can never name different tiers for one workspace. It is NOT the
|
|
1286
|
+
byte-identical issue `sdk current` emits: see the section banner.
|
|
1287
|
+
"""
|
|
1288
|
+
resolved = resolve_sdk_tiered(None, workspace_root)
|
|
1289
|
+
data = {
|
|
1290
|
+
"sdkPath": resolved.path,
|
|
1291
|
+
"readiness": check_sdk_readiness(resolved.path) if resolved.path is not None else None,
|
|
1292
|
+
"sourceTier": resolved.tier,
|
|
1293
|
+
}
|
|
1294
|
+
pin_issue = project_pin_issue(resolved.broken_project_pin, resolved.tier)
|
|
1295
|
+
return _AfterRemoval(data, [pin_issue] if pin_issue is not None else [])
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _remove_data(
|
|
1299
|
+
*,
|
|
1300
|
+
removed: bool,
|
|
1301
|
+
path: str | None,
|
|
1302
|
+
version: str | None,
|
|
1303
|
+
was_active: bool,
|
|
1304
|
+
freed_bytes: int,
|
|
1305
|
+
resolves_to_after: dict[str, Any],
|
|
1306
|
+
) -> dict[str, Any]:
|
|
1307
|
+
"""`sdk remove`'s payload shape, built at every call site through this one
|
|
1308
|
+
function so a field can never drift between the idempotent, refused and
|
|
1309
|
+
successful arms below."""
|
|
1310
|
+
return {
|
|
1311
|
+
"subcommand": "remove",
|
|
1312
|
+
"removed": removed,
|
|
1313
|
+
"path": path,
|
|
1314
|
+
"version": version,
|
|
1315
|
+
"wasActive": was_active,
|
|
1316
|
+
"freedBytes": freed_bytes,
|
|
1317
|
+
"resolvesToAfter": resolves_to_after,
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _run_remove(
|
|
1322
|
+
*,
|
|
1323
|
+
json_mode: bool,
|
|
1324
|
+
arg: str | None,
|
|
1325
|
+
destination_arg: str | None,
|
|
1326
|
+
force: bool,
|
|
1327
|
+
workspace_root: Path,
|
|
1328
|
+
) -> None:
|
|
1329
|
+
"""`tan sdk remove <version|path>` (tan-cli#790) -- see the section banner
|
|
1330
|
+
above this function for the design this implements. Every refusal fires
|
|
1331
|
+
BEFORE any filesystem write, so the three possible outcomes -- refused,
|
|
1332
|
+
idempotently already-absent, or removed -- are the only ones a caller
|
|
1333
|
+
ever has to handle; nothing here can leave a target partially gone in a
|
|
1334
|
+
way a rerun cannot cleanly finish or recover from.
|
|
1335
|
+
|
|
1336
|
+
`active`/`was_active` are resolved ONCE, right after the target itself,
|
|
1337
|
+
and threaded through every branch below (including every refusal) rather
|
|
1338
|
+
than recomputed per-branch -- so `data.wasActive` is truthful on the
|
|
1339
|
+
outside-root refusal too, not just on the one branch that refuses BECAUSE
|
|
1340
|
+
of it.
|
|
1341
|
+
"""
|
|
1342
|
+
raw_arg = (arg or "").strip()
|
|
1343
|
+
if not raw_arg:
|
|
1344
|
+
after = _resolves_to_after(workspace_root)
|
|
1345
|
+
_fail(
|
|
1346
|
+
json_mode=json_mode,
|
|
1347
|
+
data=_remove_data(
|
|
1348
|
+
removed=False,
|
|
1349
|
+
path=None,
|
|
1350
|
+
version=None,
|
|
1351
|
+
was_active=False,
|
|
1352
|
+
freed_bytes=0,
|
|
1353
|
+
resolves_to_after=after.data,
|
|
1354
|
+
),
|
|
1355
|
+
issues=after.issues,
|
|
1356
|
+
code="remove-missing-argument",
|
|
1357
|
+
message=(
|
|
1358
|
+
"`sdk remove` needs a version name (looked up under --destination) "
|
|
1359
|
+
"or an explicit path naming the install to remove."
|
|
1360
|
+
),
|
|
1361
|
+
text_lines=[
|
|
1362
|
+
"sdk remove: needs a version or path argument, e.g. `tan sdk remove v0.15.0`."
|
|
1363
|
+
],
|
|
1364
|
+
)
|
|
1365
|
+
return
|
|
1366
|
+
|
|
1367
|
+
destination = Path(destination_arg).expanduser() if destination_arg else _default_cache_root()
|
|
1368
|
+
resolution = resolve_removal_target(raw_arg, destination)
|
|
1369
|
+
target = resolution.target
|
|
1370
|
+
target_posix = _abs_posix(str(target))
|
|
1371
|
+
version = raw_arg if resolution.is_named_version else None
|
|
1372
|
+
|
|
1373
|
+
active = resolve_sdk_tiered(None, workspace_root)
|
|
1374
|
+
# The SAME helper `_load_bearing_reasons` compares with (tan-cli#1053), not
|
|
1375
|
+
# a second rule: this one is reporting-only, but two comparisons of one
|
|
1376
|
+
# fact that can disagree are a defect waiting to be re-found. Argument
|
|
1377
|
+
# ORDER is load-bearing -- the active install is the CANDIDATE, the thing
|
|
1378
|
+
# being removed is the TARGET -- because the predicate is asymmetric about
|
|
1379
|
+
# symlinks (see its section banner).
|
|
1380
|
+
active_posix = _abs_posix(active.path) if active.path is not None else None
|
|
1381
|
+
was_active = active_posix is not None and removal_would_damage(
|
|
1382
|
+
active_posix, target_posix
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
# `os.path.lexists`, NOT `target.exists()`: the latter FOLLOWS a link, so a
|
|
1386
|
+
# BROKEN symlink or a junction whose target is gone -- an ordinary leftover in
|
|
1387
|
+
# a cache that has had an install removed out from under a `current ->` style
|
|
1388
|
+
# pointer -- reports False while the link itself is still very much on disk.
|
|
1389
|
+
# Answering "already absent" there breaks the idempotence this branch exists to
|
|
1390
|
+
# provide (tan-cli#790's own point 3): the rotation script that trusted the
|
|
1391
|
+
# success then fails on the NEXT install with a path that already exists.
|
|
1392
|
+
# Everything downstream of this gate already handles the link case correctly --
|
|
1393
|
+
# `compute_tree_bytes` charges the link's OWN lstat size, and
|
|
1394
|
+
# `dir_removal.remove_dir` unlinks the link itself rather than following it --
|
|
1395
|
+
# so this predicate was the single place the link was invisible.
|
|
1396
|
+
if not os.path.lexists(target):
|
|
1397
|
+
after = _resolves_to_after(workspace_root)
|
|
1398
|
+
_emit(
|
|
1399
|
+
json_mode=json_mode,
|
|
1400
|
+
data=_remove_data(
|
|
1401
|
+
removed=False,
|
|
1402
|
+
path=target_posix,
|
|
1403
|
+
version=version,
|
|
1404
|
+
was_active=was_active,
|
|
1405
|
+
freed_bytes=0,
|
|
1406
|
+
resolves_to_after=after.data,
|
|
1407
|
+
),
|
|
1408
|
+
issues=after.issues,
|
|
1409
|
+
exit_code=ExitCode.SUCCESS,
|
|
1410
|
+
text_lines=[f"sdk remove: nothing at {target_posix} -- already absent."],
|
|
1411
|
+
)
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
# Checked BEFORE the outside-root guard, and separately from it:
|
|
1415
|
+
# `is_outside_cache_root` deliberately answers False for `target ==
|
|
1416
|
+
# destination` (a caller CAN name the root on purpose), which left the
|
|
1417
|
+
# single most destructive target -- the whole cache, every version at
|
|
1418
|
+
# once -- completely unguarded: `_load_bearing_reasons` only ever names a
|
|
1419
|
+
# specific version subdirectory, never the root that holds them, so
|
|
1420
|
+
# nothing else in this function would have refused it either. Found live
|
|
1421
|
+
# (`tan sdk remove .` from inside an otherwise-empty cache root, `ok:
|
|
1422
|
+
# true`, no `--force`) -- see `sdk_removal.is_cache_root_itself`.
|
|
1423
|
+
if is_cache_root_itself(target, destination) and not force:
|
|
1424
|
+
after = _resolves_to_after(workspace_root)
|
|
1425
|
+
_fail(
|
|
1426
|
+
json_mode=json_mode,
|
|
1427
|
+
data=_remove_data(
|
|
1428
|
+
removed=False,
|
|
1429
|
+
path=target_posix,
|
|
1430
|
+
version=version,
|
|
1431
|
+
was_active=was_active,
|
|
1432
|
+
freed_bytes=0,
|
|
1433
|
+
resolves_to_after=after.data,
|
|
1434
|
+
),
|
|
1435
|
+
issues=after.issues,
|
|
1436
|
+
code="remove-is-cache-root",
|
|
1437
|
+
message=(
|
|
1438
|
+
f'"{target_posix}" IS the SDK cache root itself; removing it '
|
|
1439
|
+
"would delete every install under it at once, not a single "
|
|
1440
|
+
"one. Pass --force to remove the entire cache root, or name "
|
|
1441
|
+
"a specific version or path to remove one install."
|
|
1442
|
+
),
|
|
1443
|
+
text_lines=[
|
|
1444
|
+
f"sdk remove: {target_posix} is the cache root; refusing without --force."
|
|
1445
|
+
],
|
|
1446
|
+
)
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
if is_outside_cache_root(target, destination) and not force:
|
|
1450
|
+
after = _resolves_to_after(workspace_root)
|
|
1451
|
+
_fail(
|
|
1452
|
+
json_mode=json_mode,
|
|
1453
|
+
data=_remove_data(
|
|
1454
|
+
removed=False,
|
|
1455
|
+
path=target_posix,
|
|
1456
|
+
version=version,
|
|
1457
|
+
was_active=was_active,
|
|
1458
|
+
freed_bytes=0,
|
|
1459
|
+
resolves_to_after=after.data,
|
|
1460
|
+
),
|
|
1461
|
+
issues=after.issues,
|
|
1462
|
+
code="remove-outside-root",
|
|
1463
|
+
message=(
|
|
1464
|
+
f'"{target_posix}" is outside the SDK cache root '
|
|
1465
|
+
f'"{_abs_posix(str(destination))}". Pass --force to remove an '
|
|
1466
|
+
"explicit path outside the managed cache."
|
|
1467
|
+
),
|
|
1468
|
+
text_lines=[
|
|
1469
|
+
f"sdk remove: {target_posix} is outside the cache root; refusing without --force."
|
|
1470
|
+
],
|
|
1471
|
+
)
|
|
1472
|
+
return
|
|
1473
|
+
|
|
1474
|
+
if not resolution.is_named_version:
|
|
1475
|
+
version = check_sdk_readiness(str(target)).get("version")
|
|
1476
|
+
|
|
1477
|
+
# Computed BEFORE any removal and threaded through both users: the prune
|
|
1478
|
+
# at the end of this function runs once the target is gone, and cannot
|
|
1479
|
+
# re-derive an alias match from a filesystem that no longer has it.
|
|
1480
|
+
registered = _registered_entries_for(target_posix)
|
|
1481
|
+
reasons = _load_bearing_reasons(target_posix, active, registered)
|
|
1482
|
+
if reasons and not force:
|
|
1483
|
+
after = _resolves_to_after(workspace_root)
|
|
1484
|
+
_fail(
|
|
1485
|
+
json_mode=json_mode,
|
|
1486
|
+
data=_remove_data(
|
|
1487
|
+
removed=False,
|
|
1488
|
+
path=target_posix,
|
|
1489
|
+
version=version,
|
|
1490
|
+
was_active=was_active,
|
|
1491
|
+
freed_bytes=0,
|
|
1492
|
+
resolves_to_after=after.data,
|
|
1493
|
+
),
|
|
1494
|
+
issues=after.issues,
|
|
1495
|
+
code="remove-active",
|
|
1496
|
+
message=(
|
|
1497
|
+
f'"{target_posix}" is currently load-bearing: it is '
|
|
1498
|
+
+ "; and it is ".join(reasons)
|
|
1499
|
+
+ ". Pass --force to remove it anyway."
|
|
1500
|
+
),
|
|
1501
|
+
text_lines=[
|
|
1502
|
+
f"sdk remove: {target_posix} is still in use; refusing without --force."
|
|
1503
|
+
],
|
|
1504
|
+
)
|
|
1505
|
+
return
|
|
1506
|
+
|
|
1507
|
+
outcome: RemovalOutcome = remove_sdk_tree(target)
|
|
1508
|
+
if not outcome.ok:
|
|
1509
|
+
failing = (outcome.failing_path or target_posix).replace("\\", "/")
|
|
1510
|
+
after = _resolves_to_after(workspace_root)
|
|
1511
|
+
failure_data = _remove_data(
|
|
1512
|
+
removed=False,
|
|
1513
|
+
path=target_posix,
|
|
1514
|
+
version=version,
|
|
1515
|
+
was_active=was_active,
|
|
1516
|
+
freed_bytes=outcome.freed_bytes,
|
|
1517
|
+
# Recomputed AFTER the attempt, not carried from the pre-attempt
|
|
1518
|
+
# `active` above: `remove_sdk_tree` can fail partway through a
|
|
1519
|
+
# multi-entry tree (`outcome.freed_bytes` above is already
|
|
1520
|
+
# partial-attempt-aware for the identical reason), so the
|
|
1521
|
+
# resolution has to be re-read from the filesystem it just
|
|
1522
|
+
# touched rather than assumed unchanged.
|
|
1523
|
+
resolves_to_after=after.data,
|
|
1524
|
+
)
|
|
1525
|
+
# TWO literal `code=` call sites, deliberately not one dynamic
|
|
1526
|
+
# `f"remove-{outcome.kind}"`: `test_every_issue_code_is_registered.py`
|
|
1527
|
+
# can only resolve a `code=` keyword argument to a registered wire
|
|
1528
|
+
# string when it is a literal at the call site, exactly the same
|
|
1529
|
+
# non-vacuity discipline that gate applies to every OTHER command in
|
|
1530
|
+
# this codebase.
|
|
1531
|
+
if outcome.kind == "in-use":
|
|
1532
|
+
_fail(
|
|
1533
|
+
json_mode=json_mode,
|
|
1534
|
+
data=failure_data,
|
|
1535
|
+
issues=after.issues,
|
|
1536
|
+
code="remove-in-use",
|
|
1537
|
+
message=(
|
|
1538
|
+
f"could not remove {failing}: {outcome.detail} -- another "
|
|
1539
|
+
"process still holds this open; close whatever has it open "
|
|
1540
|
+
"(a shell, a build, an editor, an indexer) and retry."
|
|
1541
|
+
),
|
|
1542
|
+
text_lines=[f"sdk remove: failed removing {target_posix} (in-use)."],
|
|
1543
|
+
)
|
|
1544
|
+
else:
|
|
1545
|
+
_fail(
|
|
1546
|
+
json_mode=json_mode,
|
|
1547
|
+
data=failure_data,
|
|
1548
|
+
issues=after.issues,
|
|
1549
|
+
code="remove-permission",
|
|
1550
|
+
message=(
|
|
1551
|
+
f"could not remove {failing}: {outcome.detail} -- tan could "
|
|
1552
|
+
"not clear the permissions/attributes blocking this; check "
|
|
1553
|
+
"ownership/ACLs on the path above, or remove it by hand with "
|
|
1554
|
+
"elevated privileges."
|
|
1555
|
+
),
|
|
1556
|
+
text_lines=[f"sdk remove: failed removing {target_posix} (permission)."],
|
|
1557
|
+
)
|
|
1558
|
+
return
|
|
1559
|
+
|
|
1560
|
+
_prune_registry_entries_for(target_posix, registered)
|
|
1561
|
+
# tan-cli#1498: a forced subtree removal also damages entries whose
|
|
1562
|
+
# install CONTAINS the target. Those are not pruned (the install they
|
|
1563
|
+
# name still exists), so say so rather than leaving them silent.
|
|
1564
|
+
containing = sorted(_registered_containing(target_posix, {o for o, _p in registered}))
|
|
1565
|
+
after = _resolves_to_after(workspace_root)
|
|
1566
|
+
if containing:
|
|
1567
|
+
after = _AfterRemoval(
|
|
1568
|
+
after.data,
|
|
1569
|
+
[
|
|
1570
|
+
*after.issues,
|
|
1571
|
+
*[
|
|
1572
|
+
Issue(
|
|
1573
|
+
"sdk.remove-registry-entry-damaged",
|
|
1574
|
+
"warning",
|
|
1575
|
+
f'the registered global default for project "{origin}" names an '
|
|
1576
|
+
f"install that contained {target_posix}; it was left in "
|
|
1577
|
+
f"~/.alp/sdk-defaults.json but is now incomplete -- restore "
|
|
1578
|
+
f"that SDK or remove that registry entry.",
|
|
1579
|
+
)
|
|
1580
|
+
for origin in containing
|
|
1581
|
+
],
|
|
1582
|
+
],
|
|
1583
|
+
)
|
|
1584
|
+
_emit(
|
|
1585
|
+
json_mode=json_mode,
|
|
1586
|
+
data=_remove_data(
|
|
1587
|
+
removed=True,
|
|
1588
|
+
path=target_posix,
|
|
1589
|
+
version=version,
|
|
1590
|
+
was_active=was_active,
|
|
1591
|
+
freed_bytes=outcome.freed_bytes,
|
|
1592
|
+
resolves_to_after=after.data,
|
|
1593
|
+
),
|
|
1594
|
+
issues=after.issues,
|
|
1595
|
+
exit_code=ExitCode.SUCCESS,
|
|
1596
|
+
text_lines=[f"sdk remove: removed {target_posix} ({outcome.freed_bytes} bytes freed)."],
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
# ── the command ─────────────────────────────────────────────────────────────
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def sdk(
|
|
1604
|
+
subcommand: str = typer.Argument(
|
|
1605
|
+
None,
|
|
1606
|
+
metavar="SUBCOMMAND",
|
|
1607
|
+
help=(
|
|
1608
|
+
"list, current, install, switch, or remove. install/switch are not "
|
|
1609
|
+
"yet ported and refuse in this build -- use --sdk-root instead "
|
|
1610
|
+
"(tan-cli#305)."
|
|
1611
|
+
),
|
|
1612
|
+
),
|
|
1613
|
+
arg: str = typer.Argument(
|
|
1614
|
+
None,
|
|
1615
|
+
metavar="ARG",
|
|
1616
|
+
help="Version for install, version|path for switch/remove.",
|
|
1617
|
+
),
|
|
1618
|
+
# `--destination` steers `install`/`switch` cache-root resolution (neither
|
|
1619
|
+
# ported, tan-cli#305) AND `remove`'s (tan-cli#790, live): a bare version
|
|
1620
|
+
# name is looked up under this root, and it is the root `remove`'s
|
|
1621
|
+
# outside-root footgun guard is measured against.
|
|
1622
|
+
destination: str = typer.Option(
|
|
1623
|
+
None, "--destination", metavar="PATH", help="SDK cache root (default: ~/.alp/sdk-cache)."
|
|
1624
|
+
),
|
|
1625
|
+
force: bool = typer.Option(
|
|
1626
|
+
False,
|
|
1627
|
+
"--force",
|
|
1628
|
+
help=(
|
|
1629
|
+
"With remove: also remove the active/pinned install, or a path "
|
|
1630
|
+
"outside the cache root."
|
|
1631
|
+
),
|
|
1632
|
+
),
|
|
1633
|
+
global_: bool = typer.Option(
|
|
1634
|
+
False, "--global", help="With switch: pin the machine-global default."
|
|
1635
|
+
),
|
|
1636
|
+
online: bool = typer.Option(
|
|
1637
|
+
False, "--online", help="Allow `list` to query the GitHub releases API."
|
|
1638
|
+
),
|
|
1639
|
+
project: str = typer.Option(
|
|
1640
|
+
None, "--project", metavar="PATH", help="Project root (defaults to '.')."
|
|
1641
|
+
),
|
|
1642
|
+
sdk_root: str = typer.Option(
|
|
1643
|
+
None, "--sdk-root", metavar="PATH", help="Explicit alp-sdk checkout to report."
|
|
1644
|
+
),
|
|
1645
|
+
output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
|
|
1646
|
+
) -> None:
|
|
1647
|
+
"""Manage local Alp SDK installs."""
|
|
1648
|
+
json_mode = output_format == "json"
|
|
1649
|
+
|
|
1650
|
+
try:
|
|
1651
|
+
# `cwd.join(project)` unnormalised, matching `util.rs`'s
|
|
1652
|
+
# `cli_workspace_root`: every other reader of `.alp/sdk-path` resolves it
|
|
1653
|
+
# under this same root, so `tan --project ./firmware sdk current` must
|
|
1654
|
+
# report the pointer build/flash consult for THAT project, not the
|
|
1655
|
+
# repo-root one.
|
|
1656
|
+
workspace_root = Path.cwd() / project if project else Path.cwd()
|
|
1657
|
+
|
|
1658
|
+
if subcommand == "current":
|
|
1659
|
+
_run_current(
|
|
1660
|
+
json_mode=json_mode, sdk_root=sdk_root, workspace_root=workspace_root
|
|
1661
|
+
)
|
|
1662
|
+
elif subcommand == "list":
|
|
1663
|
+
_run_list(json_mode=json_mode, online=online)
|
|
1664
|
+
elif subcommand == "install":
|
|
1665
|
+
_run_not_ported(
|
|
1666
|
+
json_mode=json_mode,
|
|
1667
|
+
subcommand="install",
|
|
1668
|
+
data={
|
|
1669
|
+
"subcommand": "install",
|
|
1670
|
+
"version": arg or "",
|
|
1671
|
+
"sdkPath": "",
|
|
1672
|
+
"readiness": empty_readiness(""),
|
|
1673
|
+
"selected": False,
|
|
1674
|
+
},
|
|
1675
|
+
)
|
|
1676
|
+
elif subcommand == "switch":
|
|
1677
|
+
_run_not_ported(
|
|
1678
|
+
json_mode=json_mode,
|
|
1679
|
+
subcommand="switch",
|
|
1680
|
+
data={
|
|
1681
|
+
"subcommand": "switch",
|
|
1682
|
+
"sdkPath": "",
|
|
1683
|
+
"version": None,
|
|
1684
|
+
"scope": "global" if global_ else "project",
|
|
1685
|
+
},
|
|
1686
|
+
)
|
|
1687
|
+
elif subcommand == "remove":
|
|
1688
|
+
_run_remove(
|
|
1689
|
+
json_mode=json_mode,
|
|
1690
|
+
arg=arg,
|
|
1691
|
+
destination_arg=destination,
|
|
1692
|
+
force=force,
|
|
1693
|
+
workspace_root=workspace_root,
|
|
1694
|
+
)
|
|
1695
|
+
else:
|
|
1696
|
+
_run_unknown(json_mode=json_mode, subcommand=subcommand)
|
|
1697
|
+
except typer.Exit:
|
|
1698
|
+
# `_emit`'s own signal. `click.exceptions.Exit` subclasses RuntimeError,
|
|
1699
|
+
# so the catch-all below would otherwise swallow every successful run and
|
|
1700
|
+
# re-report it as an internal failure.
|
|
1701
|
+
raise
|
|
1702
|
+
except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
|
|
1703
|
+
# The backstop for the failure nobody enumerated. Without it an unhandled
|
|
1704
|
+
# exception replaces the envelope with a traceback on stderr while stdout
|
|
1705
|
+
# stays empty, and the extension renders nothing at all -- no error, no
|
|
1706
|
+
# log, no warning. Five Criticals in this port were exactly that.
|
|
1707
|
+
_fail(
|
|
1708
|
+
json_mode=json_mode,
|
|
1709
|
+
data={"subcommand": subcommand or "(none)", "releases": []},
|
|
1710
|
+
code="internal-failure",
|
|
1711
|
+
message=f"sdk failed unexpectedly: {err}",
|
|
1712
|
+
text_lines=[f"sdk: unexpected failure: {err}"],
|
|
1713
|
+
exit_code=ExitCode.INTERNAL_FAILURE,
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
# tan-cli#261: adds the eight oracle `GlobalArgs` flags this command was
|
|
1718
|
+
# missing entirely (`--all`/`--board-yaml`/`--ci`/`--no-color`/
|
|
1719
|
+
# `--non-interactive`/`--quiet`/`--target`/`--verbose`); see
|
|
1720
|
+
# `tan.core.global_flags`. All inert here: every envelope `sdk` emits reports
|
|
1721
|
+
# `Project(root=None, board_yaml=None)`, an SDK-wide fact with no project of
|
|
1722
|
+
# its own to anchor a `--board-yaml`/`--target` on.
|
|
1723
|
+
sdk = accept_global_flags(sdk)
|