@herbertgao/pi-extensions 2026.8.5 → 2026.8.7
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.
- package/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +272 -22
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +16 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
- package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +22 -15
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +34 -10
- package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +29 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +46 -10
- package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +8 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
- package/node_modules/@narumitw/pi-btw/README.md +119 -103
- package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
- package/node_modules/@narumitw/pi-btw/package.json +13 -9
- package/node_modules/@narumitw/pi-btw/src/btw.ts +40 -42
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +78 -15
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
- package/node_modules/pi-lens/CHANGELOG.md +1004 -0
- package/node_modules/pi-lens/README.md +11 -3
- package/node_modules/pi-lens/config/ruff/core.toml +42 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +427 -0
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +79 -16
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
- package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
- package/node_modules/pi-lens/dist/clients/biome-client.js +176 -37
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
- package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
- package/node_modules/pi-lens/dist/clients/cascade-format.js +76 -2
- package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
- package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +249 -52
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +43 -9
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +153 -37
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +165 -11
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +325 -72
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +81 -41
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +488 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +84 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +943 -93
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +106 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
- package/node_modules/pi-lens/dist/clients/extension-log.js +317 -3
- package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
- package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
- package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
- package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
- package/node_modules/pi-lens/dist/clients/git-guard.js +49 -21
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
- package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +219 -10
- package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
- package/node_modules/pi-lens/dist/clients/host-ports.js +9 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +1119 -101
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
- package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +86 -15
- package/node_modules/pi-lens/dist/clients/knip-client.js +361 -24
- package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +487 -9
- package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
- package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +160 -20
- package/node_modules/pi-lens/dist/clients/lsp/client.js +1666 -148
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +111 -0
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2496 -171
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
- package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +517 -69
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +172 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +61 -3
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
- package/node_modules/pi-lens/dist/clients/mcp/session.js +39 -18
- package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
- package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
- package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +178 -30
- package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +85 -15
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
- package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
- package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
- package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +76 -4
- package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +248 -13
- package/node_modules/pi-lens/dist/clients/runtime-context.js +112 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +472 -61
- package/node_modules/pi-lens/dist/clients/runtime-session.js +359 -13
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +269 -22
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +948 -87
- package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
- package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +224 -8
- package/node_modules/pi-lens/dist/clients/sg-runner.js +249 -37
- package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
- package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
- package/node_modules/pi-lens/dist/clients/startup-timing.js +26 -2
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +574 -32
- package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
- package/node_modules/pi-lens/dist/clients/tool-policy.js +127 -20
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
- package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
- package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
- package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
- package/node_modules/pi-lens/dist/index.js +64677 -49484
- package/node_modules/pi-lens/dist/mcp/server.js +8 -2
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +469 -56
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +190 -46
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +44 -14
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +518 -501
- package/node_modules/pi-lens/docs/dispositions.md +21 -0
- package/node_modules/pi-lens/docs/features.md +33 -6
- package/node_modules/pi-lens/docs/globalconfig.md +14 -0
- package/node_modules/pi-lens/docs/language-coverage.md +71 -5
- package/node_modules/pi-lens/docs/mcp.md +2 -2
- package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
- package/node_modules/pi-lens/docs/settings.md +25 -1
- package/node_modules/pi-lens/docs/usage.md +24 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -0
- package/node_modules/pi-lens/package.json +10 -6
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +22 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +66 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +42 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +132 -1
- package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
- package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +12 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +87 -116
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +170 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +49 -0
- package/node_modules/pi-mcp-adapter/README.md +51 -6
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/cli.js +188 -5
- package/node_modules/pi-mcp-adapter/commands.ts +128 -22
- package/node_modules/pi-mcp-adapter/config.ts +28 -10
- package/node_modules/pi-mcp-adapter/direct-tools.ts +23 -19
- package/node_modules/pi-mcp-adapter/index.ts +219 -32
- package/node_modules/pi-mcp-adapter/init.ts +36 -4
- package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
- package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
- package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
- package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +46 -25
- package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
- package/node_modules/pi-mcp-adapter/package.json +4 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +330 -0
- package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
- package/node_modules/pi-mcp-adapter/server-manager.ts +145 -2
- package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
- package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
- package/node_modules/pi-mcp-adapter/types.ts +95 -10
- package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
- package/node_modules/pi-mcp-adapter/utils.ts +70 -0
- package/node_modules/pi-web-access/CHANGELOG.md +37 -0
- package/node_modules/pi-web-access/README.md +38 -14
- package/node_modules/pi-web-access/anysearch.ts +4 -2
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/brave.ts +16 -4
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +8 -3
- package/node_modules/pi-web-access/curator-server.ts +5 -1
- package/node_modules/pi-web-access/exa.ts +18 -7
- package/node_modules/pi-web-access/extract.ts +134 -37
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +33 -7
- package/node_modules/pi-web-access/index.ts +160 -104
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/page-query.ts +4 -2
- package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
- package/node_modules/pi-web-access/parallel.ts +2 -2
- package/node_modules/pi-web-access/query-rewrite.ts +51 -0
- package/node_modules/pi-web-access/serper.ts +211 -0
- package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
- package/node_modules/pi-web-access/summary-review.ts +51 -23
- package/node_modules/pi-web-access/tavily.ts +16 -4
- package/node_modules/pi-web-access/utils.ts +87 -0
- package/node_modules/pi-web-access/valyu.ts +199 -0
- package/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +13 -13
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
* - pip packages
|
|
46
46
|
* - GitHub releases (platform-specific binaries → ~/.pi-lens/bin/)
|
|
47
47
|
*/
|
|
48
|
-
import { logExtension } from "../extension-log.js";
|
|
49
48
|
import { spawn } from "node:child_process";
|
|
50
49
|
import { existsSync, statSync, unlinkSync } from "node:fs";
|
|
51
50
|
import fs from "node:fs/promises";
|
|
@@ -53,16 +52,20 @@ import https from "node:https";
|
|
|
53
52
|
import { createRequire } from "node:module";
|
|
54
53
|
import os from "node:os";
|
|
55
54
|
import path from "node:path";
|
|
55
|
+
import { writeFileAtomicAsync } from "../atomic-write.js";
|
|
56
56
|
import { BoundedLruCache } from "../bounded-cache.js";
|
|
57
|
+
import { logExtension } from "../extension-log.js";
|
|
57
58
|
import { isFullyQualified } from "../path-utils.js";
|
|
58
59
|
import { assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
59
60
|
const _installerRequire = createRequire(import.meta.url);
|
|
60
61
|
import { createGunzip } from "node:zlib";
|
|
61
|
-
import {
|
|
62
|
+
import { TRANSIENT_MAX_COOLDOWN_MS } from "../dispatch/runners/utils/availability-policy.js";
|
|
63
|
+
import { recordDegradationOnce } from "../degradation-ledger.js";
|
|
62
64
|
import { commitDurableStoreAsync } from "../durable-store.js";
|
|
63
65
|
import { getGlobalPiLensDir } from "../file-utils.js";
|
|
64
66
|
import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
|
|
65
67
|
import { resetSafeSpawnWindowsCommandCache, safeSpawnAsync, } from "../safe-spawn.js";
|
|
68
|
+
import { logSessionStart } from "../sessionstart-logger.js";
|
|
66
69
|
// Global installation directory for pi-lens tools
|
|
67
70
|
const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
|
|
68
71
|
const INSTALL_LOCK_PATH = path.join(TOOLS_DIR, ".install.lock");
|
|
@@ -135,8 +138,7 @@ async function acquireInstallLock() {
|
|
|
135
138
|
// every future install with a full-timeout wait. A lock older
|
|
136
139
|
// than any legitimate install (owner install bound + slack) is
|
|
137
140
|
// stale regardless of what the PID now points at.
|
|
138
|
-
const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) +
|
|
139
|
-
60_000;
|
|
141
|
+
const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) + 60_000;
|
|
140
142
|
const expired = Number.isFinite(owner.createdAt) &&
|
|
141
143
|
Date.now() - owner.createdAt > maxAgeMs;
|
|
142
144
|
if (expired ||
|
|
@@ -241,7 +243,11 @@ export const TOOLS = [
|
|
|
241
243
|
checkCommand: "tsc",
|
|
242
244
|
checkArgs: ["--version"],
|
|
243
245
|
installStrategy: "npm",
|
|
244
|
-
|
|
246
|
+
// The managed compiler serves the classic typescript-language-server
|
|
247
|
+
// fallback. TypeScript 7 removed lib/tsserver.js and is selected only from
|
|
248
|
+
// project-local installs through the native `tsc --lsp --stdio` path.
|
|
249
|
+
// Revisit when typescript-language-server supports TS 7 — refs #1436.
|
|
250
|
+
packageName: "typescript@5.9.3",
|
|
245
251
|
binaryName: "tsc",
|
|
246
252
|
},
|
|
247
253
|
{
|
|
@@ -799,7 +805,11 @@ export const TOOLS = [
|
|
|
799
805
|
id: "powershell-editor-services",
|
|
800
806
|
name: "PowerShell Editor Services",
|
|
801
807
|
checkCommand: "pwsh",
|
|
802
|
-
checkArgs: [
|
|
808
|
+
checkArgs: [
|
|
809
|
+
"-NoProfile",
|
|
810
|
+
"-Command",
|
|
811
|
+
"$PSVersionTable.PSVersion.ToString()",
|
|
812
|
+
],
|
|
803
813
|
installStrategy: "archive",
|
|
804
814
|
binaryName: "powershell-editor-services",
|
|
805
815
|
archive: {
|
|
@@ -830,7 +840,9 @@ export const TOOLS = [
|
|
|
830
840
|
const version = "22.1.0";
|
|
831
841
|
const base = `https://github.com/clangd/clangd/releases/download/${version}`;
|
|
832
842
|
if (platform === "linux")
|
|
833
|
-
return arch === "x64"
|
|
843
|
+
return arch === "x64"
|
|
844
|
+
? `${base}/clangd-linux-${version}.zip`
|
|
845
|
+
: undefined;
|
|
834
846
|
if (platform === "darwin")
|
|
835
847
|
return `${base}/clangd-mac-${version}.zip`;
|
|
836
848
|
if (platform === "win32")
|
|
@@ -1198,6 +1210,31 @@ export const TOOLS = [
|
|
|
1198
1210
|
binaryInArchive: "clojure-lsp",
|
|
1199
1211
|
},
|
|
1200
1212
|
},
|
|
1213
|
+
{
|
|
1214
|
+
// CUE ships a single native binary per platform on GitHub releases;
|
|
1215
|
+
// the LSP runs via `cue lsp serve`. Used as managedToolId by CueServer.
|
|
1216
|
+
id: "cue",
|
|
1217
|
+
name: "CUE",
|
|
1218
|
+
checkCommand: "cue",
|
|
1219
|
+
checkArgs: ["version"],
|
|
1220
|
+
installStrategy: "github",
|
|
1221
|
+
binaryName: "cue",
|
|
1222
|
+
github: {
|
|
1223
|
+
repo: "cue-lang/cue",
|
|
1224
|
+
assetMatch: (platform, arch) => {
|
|
1225
|
+
if (platform === "linux")
|
|
1226
|
+
return arch === "arm64" ? "linux_arm64.tar.gz" : "linux_amd64.tar.gz";
|
|
1227
|
+
if (platform === "darwin")
|
|
1228
|
+
return arch === "arm64"
|
|
1229
|
+
? "darwin_arm64.tar.gz"
|
|
1230
|
+
: "darwin_amd64.tar.gz";
|
|
1231
|
+
if (platform === "win32")
|
|
1232
|
+
return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
|
|
1233
|
+
return undefined;
|
|
1234
|
+
},
|
|
1235
|
+
binaryInArchive: "cue",
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1201
1238
|
{
|
|
1202
1239
|
// gleam ships a single static binary per platform on GitHub releases; the
|
|
1203
1240
|
// LSP runs via `gleam lsp`. Used as managedToolId by GleamServer. The linux
|
|
@@ -1274,9 +1311,7 @@ export const TOOLS = [
|
|
|
1274
1311
|
if (arch !== "x64" && arch !== "arm64")
|
|
1275
1312
|
return undefined;
|
|
1276
1313
|
if (platform === "linux")
|
|
1277
|
-
return arch === "arm64"
|
|
1278
|
-
? "expert_linux_arm64"
|
|
1279
|
-
: "expert_linux_amd64";
|
|
1314
|
+
return arch === "arm64" ? "expert_linux_arm64" : "expert_linux_amd64";
|
|
1280
1315
|
if (platform === "darwin")
|
|
1281
1316
|
return arch === "arm64"
|
|
1282
1317
|
? "expert_darwin_arm64"
|
|
@@ -1295,11 +1330,45 @@ const installFailureReasons = new Map();
|
|
|
1295
1330
|
export function getInstallFailureReason(toolId) {
|
|
1296
1331
|
return installFailureReasons.get(toolId);
|
|
1297
1332
|
}
|
|
1333
|
+
const installAttempts = new Map();
|
|
1334
|
+
function noteInstallAttempt(toolId, outcome, reason) {
|
|
1335
|
+
installAttempts.set(toolId, { outcome, reason, at: Date.now() });
|
|
1336
|
+
}
|
|
1337
|
+
/** Record an outcome only if this attempt has not already recorded its own. */
|
|
1338
|
+
function noteInstallAttemptIfUnrecorded(toolId, outcome, reason) {
|
|
1339
|
+
if (installAttempts.has(toolId))
|
|
1340
|
+
return;
|
|
1341
|
+
noteInstallAttempt(toolId, outcome, reason);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* What the last install attempt for `toolId` did, or `undefined` when no attempt
|
|
1345
|
+
* has been recorded in this session. Consumers map this onto the
|
|
1346
|
+
* `availability_decision` record's install evidence (#1500).
|
|
1347
|
+
*/
|
|
1348
|
+
export function getInstallAttempt(toolId) {
|
|
1349
|
+
return installAttempts.get(toolId);
|
|
1350
|
+
}
|
|
1351
|
+
const lastEnsureResolutionSource = new Map();
|
|
1352
|
+
export function getLastEnsureResolutionSource(toolId) {
|
|
1353
|
+
return lastEnsureResolutionSource.get(toolId);
|
|
1354
|
+
}
|
|
1298
1355
|
// Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
|
|
1299
1356
|
const resolvedPathCache = new BoundedLruCache(256);
|
|
1357
|
+
/** Re-arm resolved tool paths when a new session may have changed PATH. */
|
|
1358
|
+
export function resetResolvedPathCache() {
|
|
1359
|
+
resolvedPathCache.clear();
|
|
1360
|
+
}
|
|
1300
1361
|
const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
|
|
1301
1362
|
const PROBE_CACHE_LOCK_STALE_MS = 180_000;
|
|
1302
1363
|
const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1364
|
+
/**
|
|
1365
|
+
* How long a transient-tainted entry (see `ProbeCacheEntry.transient`) is
|
|
1366
|
+
* served before `getToolPath` is asked again, instead of the full 24h TTL
|
|
1367
|
+
* (#1569). Shares the shared probe policy's transient ceiling rather than
|
|
1368
|
+
* inventing a new number for the same "how long is a stall worth trusting"
|
|
1369
|
+
* question.
|
|
1370
|
+
*/
|
|
1371
|
+
const PROBE_CACHE_TRANSIENT_COOLDOWN_MS = TRANSIENT_MAX_COOLDOWN_MS;
|
|
1303
1372
|
const PROBE_CACHE_FLUSH_LOCK_WAIT_MS = 250;
|
|
1304
1373
|
const PROBE_CACHE_FLUSH_RETRY_DELAY_MS = 300;
|
|
1305
1374
|
const PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS = 30_000;
|
|
@@ -1349,7 +1418,8 @@ function scheduleProbeFlush(delayMs = PROBE_CACHE_FLUSH_RETRY_DELAY_MS) {
|
|
|
1349
1418
|
_probeCacheFlushTimer.unref?.();
|
|
1350
1419
|
}
|
|
1351
1420
|
function scheduleProbeFlushRetry() {
|
|
1352
|
-
const delay = Math.min(PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS, PROBE_CACHE_FLUSH_RETRY_DELAY_MS *
|
|
1421
|
+
const delay = Math.min(PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS, PROBE_CACHE_FLUSH_RETRY_DELAY_MS *
|
|
1422
|
+
2 ** Math.min(_probeCacheRetryAttempt, 6));
|
|
1353
1423
|
_probeCacheRetryAttempt += 1;
|
|
1354
1424
|
scheduleProbeFlush(delay);
|
|
1355
1425
|
}
|
|
@@ -1359,14 +1429,32 @@ function snapshotProbeCacheChanges() {
|
|
|
1359
1429
|
versions: new Map(_probeCacheChangeVersions),
|
|
1360
1430
|
};
|
|
1361
1431
|
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Deserialize the on-disk probe-cache for the LOCKED write-side merge
|
|
1434
|
+
* (`writeProbeCache`'s `commitDurableStoreAsync` call). Unlike `readProbeCache`
|
|
1435
|
+
* (the ordinary session-lookup path, which already degrades a parse/shape
|
|
1436
|
+
* failure to `{}`), this used to THROW on a torn/corrupt file — which does
|
|
1437
|
+
* not crash the caller (`writeProbeCache` wraps the whole commit in try/catch
|
|
1438
|
+
* and retries), but it also means the corrupt file on disk is never repaired:
|
|
1439
|
+
* every retry re-reads the same torn bytes, re-throws, and gives up again,
|
|
1440
|
+
* forever (#1609 layer b). Degrading here too, exactly like `readProbeCache`,
|
|
1441
|
+
* lets the next successful flush's `merge` step overwrite the torn file with
|
|
1442
|
+
* a valid one instead of looping on it indefinitely.
|
|
1443
|
+
*/
|
|
1362
1444
|
function deserializeProbeCache(contents) {
|
|
1363
1445
|
if (contents === undefined)
|
|
1364
1446
|
return {};
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1447
|
+
try {
|
|
1448
|
+
const parsed = JSON.parse(contents);
|
|
1449
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1450
|
+
throw new Error("probe-cache root is not an object");
|
|
1451
|
+
}
|
|
1452
|
+
return parsed;
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
logSessionStart(`auto-install probe-cache: write-side read was corrupt (${err.message}); recovering as empty`);
|
|
1456
|
+
return {};
|
|
1368
1457
|
}
|
|
1369
|
-
return parsed;
|
|
1370
1458
|
}
|
|
1371
1459
|
function applyProbeCacheChanges(disk, changes) {
|
|
1372
1460
|
for (const [toolId, entry] of changes) {
|
|
@@ -1383,9 +1471,12 @@ function applyProbeCacheChanges(disk, changes) {
|
|
|
1383
1471
|
* supplies the probe cache's former quarantine/stale-owner recovery.
|
|
1384
1472
|
*/
|
|
1385
1473
|
function ageProbeCache(disk) {
|
|
1386
|
-
const
|
|
1474
|
+
const now = Date.now();
|
|
1387
1475
|
for (const [toolId, entry] of Object.entries(disk)) {
|
|
1388
|
-
|
|
1476
|
+
const ttl = entry.transient
|
|
1477
|
+
? PROBE_CACHE_TRANSIENT_COOLDOWN_MS
|
|
1478
|
+
: PROBE_CACHE_TTL_MS;
|
|
1479
|
+
if (!Number.isFinite(entry.cachedAt) || entry.cachedAt < now - ttl) {
|
|
1389
1480
|
delete disk[toolId];
|
|
1390
1481
|
}
|
|
1391
1482
|
}
|
|
@@ -1514,8 +1605,15 @@ export async function checkProbeCache(toolId) {
|
|
|
1514
1605
|
const entry = cache[toolId];
|
|
1515
1606
|
if (!entry)
|
|
1516
1607
|
return undefined;
|
|
1517
|
-
|
|
1518
|
-
|
|
1608
|
+
// A transient-tainted entry (#1569) is not trusted past the shorter
|
|
1609
|
+
// transient cooldown, even inside the 24h TTL — the selection it recorded
|
|
1610
|
+
// may be a degraded fallback that a since-recovered preferred tier should
|
|
1611
|
+
// now beat.
|
|
1612
|
+
const ttl = entry.transient
|
|
1613
|
+
? PROBE_CACHE_TRANSIENT_COOLDOWN_MS
|
|
1614
|
+
: PROBE_CACHE_TTL_MS;
|
|
1615
|
+
if (Date.now() - entry.cachedAt > ttl) {
|
|
1616
|
+
logSessionStart(`auto-install probe-cache ${toolId}: miss (${entry.transient ? "transient cooldown" : "ttl"} expired)`);
|
|
1519
1617
|
delete cache[toolId];
|
|
1520
1618
|
markProbeCacheChange(toolId, null);
|
|
1521
1619
|
return undefined;
|
|
@@ -1545,7 +1643,7 @@ export async function checkProbeCache(toolId) {
|
|
|
1545
1643
|
}
|
|
1546
1644
|
}
|
|
1547
1645
|
// Exported for testing only.
|
|
1548
|
-
export async function updateProbeCache(toolId, resolvedPath) {
|
|
1646
|
+
export async function updateProbeCache(toolId, resolvedPath, transient = false) {
|
|
1549
1647
|
try {
|
|
1550
1648
|
const stat = await fs.stat(resolvedPath);
|
|
1551
1649
|
const cache = await readProbeCache();
|
|
@@ -1553,6 +1651,7 @@ export async function updateProbeCache(toolId, resolvedPath) {
|
|
|
1553
1651
|
path: resolvedPath,
|
|
1554
1652
|
mtimeMs: stat.mtimeMs,
|
|
1555
1653
|
cachedAt: Date.now(),
|
|
1654
|
+
...(transient && { transient: true }),
|
|
1556
1655
|
};
|
|
1557
1656
|
cache[toolId] = entry;
|
|
1558
1657
|
markProbeCacheChange(toolId, entry);
|
|
@@ -1569,10 +1668,13 @@ export function resetProbeCacheStateForTesting() {
|
|
|
1569
1668
|
_probeCacheChangeVersions.clear();
|
|
1570
1669
|
_probeCacheChangeGeneration = 0;
|
|
1571
1670
|
_probeCacheRetryAttempt = 0;
|
|
1572
|
-
|
|
1671
|
+
resetResolvedPathCache();
|
|
1573
1672
|
ensureInFlight.clear();
|
|
1574
1673
|
installFailureReasons.clear();
|
|
1674
|
+
installAttempts.clear();
|
|
1675
|
+
lastEnsureResolutionSource.clear();
|
|
1575
1676
|
lastManagedInstallVersion.clear();
|
|
1677
|
+
lastResolveTransient.clear();
|
|
1576
1678
|
resetPathWalkMemo();
|
|
1577
1679
|
if (_probeCacheFlushTimer !== null) {
|
|
1578
1680
|
clearTimeout(_probeCacheFlushTimer);
|
|
@@ -1699,8 +1801,28 @@ const lastManagedInstallVersion = new Map();
|
|
|
1699
1801
|
* `onVersionOutput`, when provided, receives the raw stdout on a successful
|
|
1700
1802
|
* (exit 0) probe — used to piggyback version-pin drift detection onto this
|
|
1701
1803
|
* already-happening spawn instead of adding a new one (#589).
|
|
1804
|
+
*
|
|
1805
|
+
* Exported so every managed-binary check runs THIS verification rather than a
|
|
1806
|
+
* bare `existsSync` of its own: an on-disk shim that cannot run must not
|
|
1807
|
+
* shadow a working PATH binary (#1657).
|
|
1702
1808
|
*/
|
|
1703
|
-
async function verifyToolBinary(binPath, onVersionOutput
|
|
1809
|
+
export async function verifyToolBinary(binPath, onVersionOutput,
|
|
1810
|
+
/**
|
|
1811
|
+
* Called when a `false` verdict came from a probe that never got a fair
|
|
1812
|
+
* run — a spawn timeout/signal or a spawn-boundary EAGAIN/EBUSY/sync throw
|
|
1813
|
+
* — rather than the binary actually rejecting `--version` (#1569). An
|
|
1814
|
+
* unspawnable prober is never a durable verdict: a caller that falls
|
|
1815
|
+
* through to a lower-priority candidate on one of these must not let the
|
|
1816
|
+
* fallback's selection be trusted as if the preferred candidate were
|
|
1817
|
+
* genuinely broken.
|
|
1818
|
+
*/
|
|
1819
|
+
onTransient,
|
|
1820
|
+
/**
|
|
1821
|
+
* Spawn budget, ms. Install paths keep the generous default; a latency-
|
|
1822
|
+
* sensitive caller on the dispatch hot path passes a shorter one and treats
|
|
1823
|
+
* the expiry as transient rather than as a verdict (#1657).
|
|
1824
|
+
*/
|
|
1825
|
+
timeoutMs = 10000) {
|
|
1704
1826
|
return new Promise((resolve) => {
|
|
1705
1827
|
const isWindows = installerPlatform() === "win32";
|
|
1706
1828
|
const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
|
|
@@ -1726,7 +1848,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
|
|
|
1726
1848
|
let proc;
|
|
1727
1849
|
try {
|
|
1728
1850
|
proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
|
|
1729
|
-
timeout:
|
|
1851
|
+
timeout: timeoutMs,
|
|
1730
1852
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1731
1853
|
shell: useShell,
|
|
1732
1854
|
});
|
|
@@ -1734,7 +1856,9 @@ async function verifyToolBinary(binPath, onVersionOutput) {
|
|
|
1734
1856
|
catch (err) {
|
|
1735
1857
|
// SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
|
|
1736
1858
|
// bug class, #533) — best-effort verify, resolve rather than reject.
|
|
1859
|
+
// The prober itself never ran, so this says nothing about the binary.
|
|
1737
1860
|
logSessionStart(`auto-install verify: spawn threw for ${binPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
1861
|
+
onTransient?.();
|
|
1738
1862
|
resolve(false);
|
|
1739
1863
|
return;
|
|
1740
1864
|
}
|
|
@@ -1742,7 +1866,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
|
|
|
1742
1866
|
let stderr = "";
|
|
1743
1867
|
proc.stdout?.on("data", (data) => (stdout += data));
|
|
1744
1868
|
proc.stderr?.on("data", (data) => (stderr += data));
|
|
1745
|
-
proc.on("exit", (code) => {
|
|
1869
|
+
proc.on("exit", (code, signal) => {
|
|
1746
1870
|
if (code === 0) {
|
|
1747
1871
|
debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
|
|
1748
1872
|
onVersionOutput?.(stdout);
|
|
@@ -1755,11 +1879,20 @@ async function verifyToolBinary(binPath, onVersionOutput) {
|
|
|
1755
1879
|
resolve(true);
|
|
1756
1880
|
}
|
|
1757
1881
|
else {
|
|
1758
|
-
|
|
1882
|
+
// `code === null` (usually paired with a `signal`) means the
|
|
1883
|
+
// spawn timeout fired and the process was killed before it could
|
|
1884
|
+
// exit on its own — a stall, not a verdict from the binary.
|
|
1885
|
+
if (code === null || signal)
|
|
1886
|
+
onTransient?.();
|
|
1887
|
+
logSessionStart(`auto-install verify: failed for ${binPath} (exit=${code}${signal ? `, signal=${signal}` : ""})`);
|
|
1759
1888
|
resolve(false);
|
|
1760
1889
|
}
|
|
1761
1890
|
});
|
|
1762
1891
|
proc.on("error", (err) => {
|
|
1892
|
+
const errno = err.code;
|
|
1893
|
+
if (errno === "EAGAIN" || errno === "EBUSY" || errno === "ETIMEDOUT") {
|
|
1894
|
+
onTransient?.();
|
|
1895
|
+
}
|
|
1763
1896
|
logSessionStart(`auto-install verify: error for ${binPath}: ${err.message}`);
|
|
1764
1897
|
resolve(false);
|
|
1765
1898
|
});
|
|
@@ -1958,7 +2091,47 @@ export function resolvePlatformPackageBinary(tool) {
|
|
|
1958
2091
|
}
|
|
1959
2092
|
return undefined;
|
|
1960
2093
|
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Whether the most recent {@link getToolPath} resolution for a tool saw a
|
|
2096
|
+
* transient probe failure on some candidate before landing on its answer
|
|
2097
|
+
* (#1569). A side channel, in the same shape as `lastManagedInstallVersion`
|
|
2098
|
+
* above: `getToolPath` keeps its `string | undefined` return so its many
|
|
2099
|
+
* callers are untouched, and `ensureToolResolved` reads this immediately
|
|
2100
|
+
* after awaiting it to decide how long the persisted probe-cache entry may
|
|
2101
|
+
* be trusted for.
|
|
2102
|
+
*/
|
|
2103
|
+
const lastResolveTransient = new Map();
|
|
2104
|
+
/**
|
|
2105
|
+
* The resolution identity the most recent install of each tool used — a GitHub
|
|
2106
|
+
* release tag, a pinned archive URL, a Maven GAV (#1747).
|
|
2107
|
+
*
|
|
2108
|
+
* A side channel in the same shape as `lastManagedInstallVersion`: the install
|
|
2109
|
+
* functions keep their `string | undefined` return, and `finishInstallAttempt`
|
|
2110
|
+
* reads this to stamp the refresh state at install time. Without that stamp a
|
|
2111
|
+
* freshly installed tool would look "never refreshed" and pay a redundant
|
|
2112
|
+
* re-resolution the first time the cadence came round.
|
|
2113
|
+
*/
|
|
2114
|
+
const lastInstallResolutionId = new Map();
|
|
2115
|
+
/**
|
|
2116
|
+
* True when the tool path `getToolPath` most recently returned for `toolId`
|
|
2117
|
+
* was selected after a candidate tier failed transiently — a stalled or
|
|
2118
|
+
* unspawnable probe, never a clean "not found". Such a selection may be a
|
|
2119
|
+
* degraded fallback masking a preferred tier that is actually fine, so a
|
|
2120
|
+
* caller persisting it must not trust it for the full 24h TTL (#1569).
|
|
2121
|
+
*/
|
|
2122
|
+
export function wasLastResolveTransient(toolId) {
|
|
2123
|
+
return lastResolveTransient.get(toolId) ?? false;
|
|
2124
|
+
}
|
|
1961
2125
|
export async function getToolPath(toolId) {
|
|
2126
|
+
let sawTransient = false;
|
|
2127
|
+
const markTransient = () => {
|
|
2128
|
+
sawTransient = true;
|
|
2129
|
+
};
|
|
2130
|
+
const result = await getToolPathResolved(toolId, markTransient);
|
|
2131
|
+
lastResolveTransient.set(toolId, sawTransient);
|
|
2132
|
+
return result;
|
|
2133
|
+
}
|
|
2134
|
+
async function getToolPathResolved(toolId, onTransient) {
|
|
1962
2135
|
const tool = TOOLS.find((t) => t.id === toolId);
|
|
1963
2136
|
if (!tool)
|
|
1964
2137
|
return undefined;
|
|
@@ -1995,7 +2168,7 @@ export async function getToolPath(toolId) {
|
|
|
1995
2168
|
const cmdPath = `${localBase}.cmd`;
|
|
1996
2169
|
try {
|
|
1997
2170
|
await fs.access(cmdPath);
|
|
1998
|
-
if (await verifyToolBinary(cmdPath, recordVersion)) {
|
|
2171
|
+
if (await verifyToolBinary(cmdPath, recordVersion, onTransient)) {
|
|
1999
2172
|
return cmdPath;
|
|
2000
2173
|
}
|
|
2001
2174
|
logSessionStart(`auto-install verify: ${cmdPath} exists but is broken, will reinstall`);
|
|
@@ -2008,7 +2181,7 @@ export async function getToolPath(toolId) {
|
|
|
2008
2181
|
const exePath = `${localBase}.exe`;
|
|
2009
2182
|
try {
|
|
2010
2183
|
await fs.access(exePath);
|
|
2011
|
-
if (await verifyToolBinary(exePath, recordVersion)) {
|
|
2184
|
+
if (await verifyToolBinary(exePath, recordVersion, onTransient)) {
|
|
2012
2185
|
return exePath;
|
|
2013
2186
|
}
|
|
2014
2187
|
logSessionStart(`auto-install verify: ${exePath} exists but is broken, will reinstall`);
|
|
@@ -2020,7 +2193,7 @@ export async function getToolPath(toolId) {
|
|
|
2020
2193
|
if (installerPlatform() !== "win32") {
|
|
2021
2194
|
try {
|
|
2022
2195
|
await fs.access(localBase);
|
|
2023
|
-
if (await verifyToolBinary(localBase, recordVersion)) {
|
|
2196
|
+
if (await verifyToolBinary(localBase, recordVersion, onTransient)) {
|
|
2024
2197
|
return localBase;
|
|
2025
2198
|
}
|
|
2026
2199
|
logSessionStart(`auto-install verify: ${localBase} exists but is broken, will reinstall`);
|
|
@@ -2035,7 +2208,8 @@ export async function getToolPath(toolId) {
|
|
|
2035
2208
|
// before falling back to PATH or a (re)install.
|
|
2036
2209
|
if (tool.platformPackage) {
|
|
2037
2210
|
const platformBin = resolvePlatformPackageBinary(tool);
|
|
2038
|
-
if (platformBin &&
|
|
2211
|
+
if (platformBin &&
|
|
2212
|
+
(await verifyToolBinary(platformBin, undefined, onTransient))) {
|
|
2039
2213
|
logSessionStart(`auto-install ${toolId}: resolved platform-package binary at ${platformBin}`);
|
|
2040
2214
|
return platformBin;
|
|
2041
2215
|
}
|
|
@@ -2057,14 +2231,14 @@ export async function getToolPath(toolId) {
|
|
|
2057
2231
|
return tool.checkCommand;
|
|
2058
2232
|
}
|
|
2059
2233
|
if (tool.installStrategy === "npm") {
|
|
2060
|
-
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
|
|
2234
|
+
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id, onTransient);
|
|
2061
2235
|
if (npmPath) {
|
|
2062
2236
|
return npmPath;
|
|
2063
2237
|
}
|
|
2064
2238
|
}
|
|
2065
2239
|
// For pip tools, also probe user-level script locations
|
|
2066
2240
|
if (tool.installStrategy === "pip") {
|
|
2067
|
-
const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
|
|
2241
|
+
const pipPath = await findPipUserToolPath(tool.binaryName || tool.id, onTransient);
|
|
2068
2242
|
if (pipPath) {
|
|
2069
2243
|
return pipPath;
|
|
2070
2244
|
}
|
|
@@ -2122,9 +2296,9 @@ function getArchiveBinaryCandidates(binaryName, platform, assetName) {
|
|
|
2122
2296
|
candidates.add(`${binaryName}.cmd`);
|
|
2123
2297
|
return [...candidates];
|
|
2124
2298
|
}
|
|
2125
|
-
async function findNpmGlobalToolPath(binaryName) {
|
|
2299
|
+
async function findNpmGlobalToolPath(binaryName, onTransient) {
|
|
2126
2300
|
const isWindows = process.platform === "win32";
|
|
2127
|
-
const binDirs = await getNpmGlobalBinCandidates();
|
|
2301
|
+
const binDirs = await getNpmGlobalBinCandidates(onTransient);
|
|
2128
2302
|
for (const dir of binDirs) {
|
|
2129
2303
|
const candidates = isWindows
|
|
2130
2304
|
? [
|
|
@@ -2135,7 +2309,7 @@ async function findNpmGlobalToolPath(binaryName) {
|
|
|
2135
2309
|
for (const candidate of candidates) {
|
|
2136
2310
|
try {
|
|
2137
2311
|
await fs.access(candidate);
|
|
2138
|
-
if (await verifyToolBinary(candidate)) {
|
|
2312
|
+
if (await verifyToolBinary(candidate, undefined, onTransient)) {
|
|
2139
2313
|
return candidate;
|
|
2140
2314
|
}
|
|
2141
2315
|
}
|
|
@@ -2146,7 +2320,7 @@ async function findNpmGlobalToolPath(binaryName) {
|
|
|
2146
2320
|
}
|
|
2147
2321
|
return undefined;
|
|
2148
2322
|
}
|
|
2149
|
-
async function getNpmGlobalBinCandidates() {
|
|
2323
|
+
async function getNpmGlobalBinCandidates(onTransient) {
|
|
2150
2324
|
const dirs = [];
|
|
2151
2325
|
const seen = new Set();
|
|
2152
2326
|
const add = (value) => {
|
|
@@ -2167,13 +2341,16 @@ async function getNpmGlobalBinCandidates() {
|
|
|
2167
2341
|
add(path.join(os.homedir(), ".npm-global", "bin"));
|
|
2168
2342
|
}
|
|
2169
2343
|
// Global bin dirs for every installed manager (npm/pnpm/yarn/bun) — a tool
|
|
2170
|
-
// may have been installed globally via any of them.
|
|
2171
|
-
|
|
2344
|
+
// may have been installed globally via any of them. `onTransient` surfaces
|
|
2345
|
+
// a manager whose availability probe stalled rather than genuinely failed,
|
|
2346
|
+
// so its bin dir may be missing from `dirs` for a reason other than "not
|
|
2347
|
+
// installed" (#1585).
|
|
2348
|
+
for (const dir of await allAvailableGlobalBinDirs(onTransient)) {
|
|
2172
2349
|
add(dir);
|
|
2173
2350
|
}
|
|
2174
2351
|
return dirs;
|
|
2175
2352
|
}
|
|
2176
|
-
async function findPipUserToolPath(binaryName) {
|
|
2353
|
+
async function findPipUserToolPath(binaryName, onTransient) {
|
|
2177
2354
|
const isWindows = process.platform === "win32";
|
|
2178
2355
|
const userBaseCandidates = await getPythonUserBaseCandidates();
|
|
2179
2356
|
for (const userBase of userBaseCandidates) {
|
|
@@ -2206,7 +2383,7 @@ async function findPipUserToolPath(binaryName) {
|
|
|
2206
2383
|
for (const candidate of candidates) {
|
|
2207
2384
|
try {
|
|
2208
2385
|
await fs.access(candidate);
|
|
2209
|
-
if (await verifyToolBinary(candidate)) {
|
|
2386
|
+
if (await verifyToolBinary(candidate, undefined, onTransient)) {
|
|
2210
2387
|
return candidate;
|
|
2211
2388
|
}
|
|
2212
2389
|
}
|
|
@@ -2295,12 +2472,20 @@ function sameHost(a, b) {
|
|
|
2295
2472
|
}
|
|
2296
2473
|
}
|
|
2297
2474
|
/**
|
|
2298
|
-
* Fetch a URL, following up to `maxRedirects` redirects
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2475
|
+
* Fetch a URL, following up to `maxRedirects` redirects, and surface the status
|
|
2476
|
+
* line and `ETag` alongside the body.
|
|
2477
|
+
*
|
|
2478
|
+
* `304 Not Modified` resolves rather than rejects: the periodic GitHub
|
|
2479
|
+
* re-resolution (#1747) sends `If-None-Match` and a 304 is its cheapest and
|
|
2480
|
+
* most desirable answer — the release has not moved, no asset is downloaded,
|
|
2481
|
+
* and GitHub does not charge the request against the caller's rate limit. Every
|
|
2482
|
+
* other non-2xx status is still an error.
|
|
2483
|
+
*
|
|
2484
|
+
* Any caller-supplied headers are dropped when a redirect crosses to a
|
|
2485
|
+
* different host, so an Authorization header can never leak to a redirect
|
|
2486
|
+
* target (e.g. a release CDN).
|
|
2302
2487
|
*/
|
|
2303
|
-
function
|
|
2488
|
+
function httpsGetWithMeta(url, maxRedirects = 5, headers = {}) {
|
|
2304
2489
|
return new Promise((resolve, reject) => {
|
|
2305
2490
|
https
|
|
2306
2491
|
.get(url, { headers: { "User-Agent": "pi-lens/1.0", ...headers } }, (res) => {
|
|
@@ -2317,7 +2502,12 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
|
|
|
2317
2502
|
const { Authorization: _drop, ...rest } = headers;
|
|
2318
2503
|
return rest;
|
|
2319
2504
|
})();
|
|
2320
|
-
return resolve(
|
|
2505
|
+
return resolve(httpsGetWithMeta(location, maxRedirects - 1, nextHeaders));
|
|
2506
|
+
}
|
|
2507
|
+
const etag = typeof res.headers.etag === "string" ? res.headers.etag : undefined;
|
|
2508
|
+
if (res.statusCode === 304) {
|
|
2509
|
+
res.resume();
|
|
2510
|
+
return resolve({ statusCode: 304, body: Buffer.alloc(0), etag });
|
|
2321
2511
|
}
|
|
2322
2512
|
if (res.statusCode !== 200) {
|
|
2323
2513
|
res.resume();
|
|
@@ -2325,12 +2515,24 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
|
|
|
2325
2515
|
}
|
|
2326
2516
|
const chunks = [];
|
|
2327
2517
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
2328
|
-
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
2518
|
+
res.on("end", () => resolve({ statusCode: 200, body: Buffer.concat(chunks), etag }));
|
|
2329
2519
|
res.on("error", reject);
|
|
2330
2520
|
})
|
|
2331
2521
|
.on("error", reject);
|
|
2332
2522
|
});
|
|
2333
2523
|
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Fetch a URL and return the raw Buffer of the response body. Rejects on any
|
|
2526
|
+
* non-200 status (a 304 is impossible here — no caller of this form sends a
|
|
2527
|
+
* validator header).
|
|
2528
|
+
*/
|
|
2529
|
+
async function httpsGet(url, maxRedirects = 5, headers = {}) {
|
|
2530
|
+
const response = await httpsGetWithMeta(url, maxRedirects, headers);
|
|
2531
|
+
if (response.statusCode !== 200) {
|
|
2532
|
+
throw new Error(`HTTP ${response.statusCode} for ${url}`);
|
|
2533
|
+
}
|
|
2534
|
+
return response.body;
|
|
2535
|
+
}
|
|
2334
2536
|
/**
|
|
2335
2537
|
* Run a shell command and return true on exit code 0.
|
|
2336
2538
|
*/
|
|
@@ -2358,7 +2560,14 @@ function runCommand(command, args, cwd) {
|
|
|
2358
2560
|
* Download and install a tool from a GitHub release.
|
|
2359
2561
|
* Returns the path to the installed binary, or undefined on failure.
|
|
2360
2562
|
*/
|
|
2361
|
-
async function installGitHubTool(tool
|
|
2563
|
+
async function installGitHubTool(tool,
|
|
2564
|
+
/**
|
|
2565
|
+
* Release metadata the caller already fetched. The periodic refresh (#1747)
|
|
2566
|
+
* resolves `releases/latest` to decide whether the tag moved at all, and
|
|
2567
|
+
* hands the SAME response here rather than paying a second GitHub API call
|
|
2568
|
+
* for an answer it already has.
|
|
2569
|
+
*/
|
|
2570
|
+
prefetchedRelease) {
|
|
2362
2571
|
const spec = tool.github;
|
|
2363
2572
|
if (!spec)
|
|
2364
2573
|
return undefined;
|
|
@@ -2369,15 +2578,35 @@ async function installGitHubTool(tool) {
|
|
|
2369
2578
|
logSessionStart(`github-install ${tool.id}: unsupported platform=${platform} arch=${arch}`);
|
|
2370
2579
|
return undefined;
|
|
2371
2580
|
}
|
|
2372
|
-
// Fetch latest release metadata from GitHub API
|
|
2373
|
-
logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
|
|
2581
|
+
// Fetch latest release metadata from GitHub API, unless the caller already did.
|
|
2374
2582
|
let releaseJson;
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2583
|
+
if (prefetchedRelease) {
|
|
2584
|
+
releaseJson = prefetchedRelease;
|
|
2585
|
+
logSessionStart(`github-install ${tool.id}: using caller-supplied release metadata for ${spec.repo} (${prefetchedRelease.tag_name ?? "untagged"})`);
|
|
2378
2586
|
}
|
|
2379
|
-
|
|
2380
|
-
logSessionStart(`github-install ${tool.id}: release
|
|
2587
|
+
else {
|
|
2588
|
+
logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
|
|
2589
|
+
try {
|
|
2590
|
+
const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
|
|
2591
|
+
releaseJson = JSON.parse(body.toString("utf8"));
|
|
2592
|
+
}
|
|
2593
|
+
catch (err) {
|
|
2594
|
+
logSessionStart(`github-install ${tool.id}: release fetch failed: ${err.message}`);
|
|
2595
|
+
return undefined;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
if (releaseJson.tag_name) {
|
|
2599
|
+
lastInstallResolutionId.set(tool.id, releaseJson.tag_name);
|
|
2600
|
+
}
|
|
2601
|
+
// #1759 review F3: a 200 whose body has no `assets` array (an unexpected
|
|
2602
|
+
// GitHub API shape, not a real release) used to reach `pickReleaseAsset`
|
|
2603
|
+
// and throw inside `.find`, uncaught by anything in this function. For the
|
|
2604
|
+
// REFRESH caller that throw skipped every stamp write, so the tool never
|
|
2605
|
+
// got a failure stamp and re-took the one-per-session slot forever. Failing
|
|
2606
|
+
// here, like a missing asset match, keeps this an ordinary "no install"
|
|
2607
|
+
// outcome both callers already handle.
|
|
2608
|
+
if (!Array.isArray(releaseJson.assets)) {
|
|
2609
|
+
logSessionStart(`github-install ${tool.id}: release metadata has no assets array`);
|
|
2381
2610
|
return undefined;
|
|
2382
2611
|
}
|
|
2383
2612
|
const asset = pickReleaseAsset(releaseJson.assets, assetSubstring) ??
|
|
@@ -2416,7 +2645,10 @@ async function installGitHubTool(tool) {
|
|
|
2416
2645
|
gunzip.on("error", reject);
|
|
2417
2646
|
gunzip.end(assetBuffer);
|
|
2418
2647
|
});
|
|
2419
|
-
await
|
|
2648
|
+
await writeFileAtomicAsync(destPath, decompressed, {
|
|
2649
|
+
bestEffort: false,
|
|
2650
|
+
mode: 0o750,
|
|
2651
|
+
});
|
|
2420
2652
|
}
|
|
2421
2653
|
else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tar.xz")) {
|
|
2422
2654
|
// Write archive to temp file, extract with system tar
|
|
@@ -2483,7 +2715,10 @@ async function installGitHubTool(tool) {
|
|
|
2483
2715
|
}
|
|
2484
2716
|
else {
|
|
2485
2717
|
// Bare binary (e.g. shfmt_*_linux_amd64)
|
|
2486
|
-
await
|
|
2718
|
+
await writeFileAtomicAsync(destPath, assetBuffer, {
|
|
2719
|
+
bestEffort: false,
|
|
2720
|
+
mode: 0o750,
|
|
2721
|
+
});
|
|
2487
2722
|
}
|
|
2488
2723
|
}
|
|
2489
2724
|
catch (err) {
|
|
@@ -2501,9 +2736,7 @@ async function installGitHubTool(tool) {
|
|
|
2501
2736
|
}
|
|
2502
2737
|
try {
|
|
2503
2738
|
const extraBuffer = await httpsGet(extraAsset.browser_download_url);
|
|
2504
|
-
await
|
|
2505
|
-
mode: 0o750,
|
|
2506
|
-
});
|
|
2739
|
+
await writeFileAtomicAsync(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, { bestEffort: false, mode: 0o750 });
|
|
2507
2740
|
logSessionStart(`github-install ${tool.id}: installed extra asset ${extraName} (${extraBuffer.length} bytes)`);
|
|
2508
2741
|
}
|
|
2509
2742
|
catch (err) {
|
|
@@ -2546,6 +2779,581 @@ const NEEDS_POSTINSTALL = new Set([
|
|
|
2546
2779
|
"esbuild",
|
|
2547
2780
|
"intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
|
|
2548
2781
|
]);
|
|
2782
|
+
/**
|
|
2783
|
+
* Does this npm package need its lifecycle scripts to install correctly?
|
|
2784
|
+
*
|
|
2785
|
+
* Exported so the periodic refresh (#1730) re-runs each package under the SAME
|
|
2786
|
+
* script policy its original install used. A refresh that drops
|
|
2787
|
+
* `--ignore-scripts` for biome or `@ast-grep/cli` leaves the JS launcher
|
|
2788
|
+
* updated and its native binary on the old version.
|
|
2789
|
+
*/
|
|
2790
|
+
export function npmToolNeedsPostinstall(packageName) {
|
|
2791
|
+
return NEEDS_POSTINSTALL.has(packageName);
|
|
2792
|
+
}
|
|
2793
|
+
/**
|
|
2794
|
+
* The managed npm tools whose installed version is free to move — every
|
|
2795
|
+
* registry entry with `installStrategy: "npm"` and an UNPINNED `packageName`.
|
|
2796
|
+
*
|
|
2797
|
+
* Derived from `TOOLS` on every call rather than listed by hand: a hand-kept
|
|
2798
|
+
* copy of a registry is the single-source-of-truth defect this repo keeps
|
|
2799
|
+
* finding, and a new npm tool added to `TOOLS` must be refreshed without anyone
|
|
2800
|
+
* remembering a second list.
|
|
2801
|
+
*
|
|
2802
|
+
* Packages carrying an explicit `name@1.2.3` pin are excluded. The pin IS the
|
|
2803
|
+
* intended version, and #589's drift check already reinstalls a managed copy
|
|
2804
|
+
* that wanders off it.
|
|
2805
|
+
*/
|
|
2806
|
+
export function getRefreshableManagedNpmTools() {
|
|
2807
|
+
return getRefreshableManagedTools()
|
|
2808
|
+
.filter((candidate) => candidate.strategy === "npm")
|
|
2809
|
+
.map((candidate) => ({
|
|
2810
|
+
toolId: candidate.toolId,
|
|
2811
|
+
// The npm branch of getRefreshableManagedTools only admits entries that
|
|
2812
|
+
// have both, so these are total.
|
|
2813
|
+
packageName: candidate.packageName,
|
|
2814
|
+
binaryName: candidate.binaryName,
|
|
2815
|
+
}));
|
|
2816
|
+
}
|
|
2817
|
+
/** The Maven GAV a `maven` entry pins, as a stable identity string. */
|
|
2818
|
+
function mavenCoordinate(spec) {
|
|
2819
|
+
return [
|
|
2820
|
+
spec.repoBaseUrl ?? MAVEN_CENTRAL_BASE,
|
|
2821
|
+
spec.groupId,
|
|
2822
|
+
spec.artifactId,
|
|
2823
|
+
spec.version,
|
|
2824
|
+
spec.classifier ?? "",
|
|
2825
|
+
].join(":");
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Every managed tool whose installed copy is allowed to move, across ALL six
|
|
2829
|
+
* install strategies — the single source of truth the periodic refresh selects
|
|
2830
|
+
* from (#1730, #1747).
|
|
2831
|
+
*
|
|
2832
|
+
* Derived from `TOOLS` on every call rather than listed by hand. A hand-kept
|
|
2833
|
+
* copy of a registry is the defect shape this repo keeps re-finding, and a tool
|
|
2834
|
+
* added to `TOOLS` must become refreshable without anyone remembering a second
|
|
2835
|
+
* list.
|
|
2836
|
+
*
|
|
2837
|
+
* Excluded: npm packages carrying an explicit `name@1.2.3` pin (the pin IS the
|
|
2838
|
+
* intended version, and #589's drift check already repairs those), and any
|
|
2839
|
+
* entry with no coordinate to re-resolve at all.
|
|
2840
|
+
*/
|
|
2841
|
+
export function getRefreshableManagedTools() {
|
|
2842
|
+
const refreshable = [];
|
|
2843
|
+
for (const tool of TOOLS) {
|
|
2844
|
+
switch (tool.installStrategy) {
|
|
2845
|
+
case "npm": {
|
|
2846
|
+
if (!tool.packageName)
|
|
2847
|
+
continue;
|
|
2848
|
+
if (parsePinnedVersion(tool.packageName) !== undefined)
|
|
2849
|
+
continue;
|
|
2850
|
+
// `binaryName` is what `installNpmTool` verifies after an install, and
|
|
2851
|
+
// the refresh has to verify the SAME path after an update. An npm
|
|
2852
|
+
// entry without one cannot be verified, so it is not refreshed either.
|
|
2853
|
+
if (!tool.binaryName)
|
|
2854
|
+
continue;
|
|
2855
|
+
refreshable.push({
|
|
2856
|
+
toolId: tool.id,
|
|
2857
|
+
strategy: "npm",
|
|
2858
|
+
packageName: tool.packageName,
|
|
2859
|
+
binaryName: tool.binaryName,
|
|
2860
|
+
});
|
|
2861
|
+
break;
|
|
2862
|
+
}
|
|
2863
|
+
case "pip":
|
|
2864
|
+
case "gem": {
|
|
2865
|
+
if (!tool.packageName)
|
|
2866
|
+
continue;
|
|
2867
|
+
refreshable.push({
|
|
2868
|
+
toolId: tool.id,
|
|
2869
|
+
strategy: tool.installStrategy,
|
|
2870
|
+
packageName: tool.packageName,
|
|
2871
|
+
});
|
|
2872
|
+
break;
|
|
2873
|
+
}
|
|
2874
|
+
case "github": {
|
|
2875
|
+
if (!tool.github)
|
|
2876
|
+
continue;
|
|
2877
|
+
refreshable.push({ toolId: tool.id, strategy: "github" });
|
|
2878
|
+
break;
|
|
2879
|
+
}
|
|
2880
|
+
case "maven": {
|
|
2881
|
+
if (!tool.maven)
|
|
2882
|
+
continue;
|
|
2883
|
+
refreshable.push({
|
|
2884
|
+
toolId: tool.id,
|
|
2885
|
+
strategy: "maven",
|
|
2886
|
+
pinnedCoordinate: mavenCoordinate(tool.maven),
|
|
2887
|
+
});
|
|
2888
|
+
break;
|
|
2889
|
+
}
|
|
2890
|
+
case "archive": {
|
|
2891
|
+
if (!tool.archive)
|
|
2892
|
+
continue;
|
|
2893
|
+
const url = resolveArchiveUrl(tool.archive);
|
|
2894
|
+
// No archive for this platform/arch: nothing is installed and nothing
|
|
2895
|
+
// can be refreshed.
|
|
2896
|
+
if (!url)
|
|
2897
|
+
continue;
|
|
2898
|
+
refreshable.push({
|
|
2899
|
+
toolId: tool.id,
|
|
2900
|
+
strategy: "archive",
|
|
2901
|
+
pinnedCoordinate: url,
|
|
2902
|
+
});
|
|
2903
|
+
break;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
return refreshable;
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* Is this tool present in a location pi-lens itself installs to?
|
|
2911
|
+
*
|
|
2912
|
+
* Deliberately filesystem-only — no spawn, no network. The refresh runs this
|
|
2913
|
+
* over candidates on a background timer, and a presence check that spawned a
|
|
2914
|
+
* probe per tool would cost more than the refresh it gates.
|
|
2915
|
+
*
|
|
2916
|
+
* A tool pi-lens has never installed is never refreshed: `pip install -U` or a
|
|
2917
|
+
* release download on an absent tool would turn a refresh into an unrequested
|
|
2918
|
+
* install.
|
|
2919
|
+
*/
|
|
2920
|
+
export async function isManagedToolPresent(toolId) {
|
|
2921
|
+
const tool = TOOLS.find((t) => t.id === toolId);
|
|
2922
|
+
if (!tool)
|
|
2923
|
+
return false;
|
|
2924
|
+
switch (tool.installStrategy) {
|
|
2925
|
+
case "npm": {
|
|
2926
|
+
if (!tool.packageName)
|
|
2927
|
+
return false;
|
|
2928
|
+
try {
|
|
2929
|
+
await fs.access(path.join(TOOLS_DIR, "node_modules", tool.packageName, "package.json"));
|
|
2930
|
+
return true;
|
|
2931
|
+
}
|
|
2932
|
+
catch {
|
|
2933
|
+
return false;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
case "github":
|
|
2937
|
+
case "maven":
|
|
2938
|
+
return ((await findGitHubToolPath(tool.binaryName ?? tool.id)) !== undefined);
|
|
2939
|
+
case "archive":
|
|
2940
|
+
// A launcher archive leaves a shim in the managed bin; a TREE BUNDLE
|
|
2941
|
+
// leaves only its extract dir, confirmed via the tree marker.
|
|
2942
|
+
return ((await findGitHubToolPath(tool.binaryName ?? tool.id)) !== undefined ||
|
|
2943
|
+
(await getArchiveTreeBundlePath(tool)) !== undefined);
|
|
2944
|
+
case "pip":
|
|
2945
|
+
case "gem": {
|
|
2946
|
+
// pip/gem installs are not namespaced to pi-lens: they land in the
|
|
2947
|
+
// user's site/gem dirs, so there is no pi-lens-owned directory to look
|
|
2948
|
+
// in. The persisted probe cache is the record of where this tool last
|
|
2949
|
+
// resolved, and it is written from the same discovery pass the install
|
|
2950
|
+
// path uses. An entry whose file is gone is not presence.
|
|
2951
|
+
const cached = (await readProbeCache())[toolId];
|
|
2952
|
+
return cached?.path !== undefined && existsSync(cached.path);
|
|
2953
|
+
}
|
|
2954
|
+
default:
|
|
2955
|
+
return false;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
/**
|
|
2959
|
+
* Version this tool reports today, read by running its own `--version` probe.
|
|
2960
|
+
*
|
|
2961
|
+
* Only used for `pip` and `gem`, the two strategies with no readable
|
|
2962
|
+
* coordinate: their package managers install into user-owned directories with
|
|
2963
|
+
* no manifest pi-lens can read, so the binary itself is the only source. Costs
|
|
2964
|
+
* one spawn, bounded by the same one-tool-per-session budget as the refresh.
|
|
2965
|
+
*/
|
|
2966
|
+
async function probeManagedToolVersion(tool) {
|
|
2967
|
+
const cached = (await readProbeCache())[tool.id];
|
|
2968
|
+
if (!cached?.path || !existsSync(cached.path))
|
|
2969
|
+
return undefined;
|
|
2970
|
+
try {
|
|
2971
|
+
const result = await safeSpawnAsync(cached.path, tool.checkArgs, {
|
|
2972
|
+
timeout: 10_000,
|
|
2973
|
+
ignoreAmbientSignal: true,
|
|
2974
|
+
resourceLabel: `tool-refresh-version:${tool.id}`,
|
|
2975
|
+
});
|
|
2976
|
+
if (result.status !== 0)
|
|
2977
|
+
return undefined;
|
|
2978
|
+
return extractVersionToken(`${result.stdout}\n${result.stderr}`);
|
|
2979
|
+
}
|
|
2980
|
+
catch {
|
|
2981
|
+
return undefined;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* The three checks every install-triggering path must clear before touching
|
|
2986
|
+
* `TOOLS_DIR`: the `PI_LENS_DISABLE_TOOL_INSTALL` kill-switch, the
|
|
2987
|
+
* `assertInstallAllowed` project-trust gate (the #1334 S5 boundary), and the
|
|
2988
|
+
* shared install lock — acquired here, released by the caller when its
|
|
2989
|
+
* strategy work finishes.
|
|
2990
|
+
*
|
|
2991
|
+
* Shared by `refreshManagedTool` below (the five non-npm strategies) and
|
|
2992
|
+
* `refreshNpmOne` in `managed-tool-refresh.ts` (#1759 review R2), so a
|
|
2993
|
+
* refusal means the same thing everywhere — a declined, unstamped skip, not
|
|
2994
|
+
* a degradation — regardless of which of the six strategies asked. Before
|
|
2995
|
+
* this was pulled out as its own function, npm's refresh spawned `npm
|
|
2996
|
+
* update` directly with none of these three checks: the kill switch, the
|
|
2997
|
+
* trust gate and the lock governed five strategies and silently exempted
|
|
2998
|
+
* the sixth.
|
|
2999
|
+
*/
|
|
3000
|
+
export async function acquireManagedInstallGate(context) {
|
|
3001
|
+
if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
|
|
3002
|
+
return {
|
|
3003
|
+
ok: false,
|
|
3004
|
+
reason: "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1",
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
if (!assertInstallAllowed(context)) {
|
|
3008
|
+
return { ok: false, reason: `project trust: ${projectTrustDenialReason()}` };
|
|
3009
|
+
}
|
|
3010
|
+
// Held for the whole strategy call, not just the final write — a refresh
|
|
3011
|
+
// can race a concurrent `ensureTool` install of the SAME tool into the SAME
|
|
3012
|
+
// managed destination, and both write to `TOOLS_DIR` unlocked otherwise.
|
|
3013
|
+
const lock = await acquireInstallLock();
|
|
3014
|
+
if (!lock.release) {
|
|
3015
|
+
return { ok: false, reason: lock.reason ?? "install lock held" };
|
|
3016
|
+
}
|
|
3017
|
+
return { ok: true, release: lock.release };
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* Re-resolve one non-npm managed tool and, only if its coordinate actually
|
|
3021
|
+
* moved, reinstall it (#1747).
|
|
3022
|
+
*
|
|
3023
|
+
* Never throws and never removes the installed copy: on any failure the
|
|
3024
|
+
* currently installed version keeps serving and the caller records a
|
|
3025
|
+
* degradation. npm is NOT handled here — `managed-tool-refresh.ts` owns that
|
|
3026
|
+
* path because it needs the package-manager resolver, and gates itself
|
|
3027
|
+
* through `acquireManagedInstallGate` directly.
|
|
3028
|
+
*
|
|
3029
|
+
* #1759 review F2: this used to call the strategy functions directly, which
|
|
3030
|
+
* bypassed every install guard `installTool`/`ensureTool` honor —
|
|
3031
|
+
* `PI_LENS_DISABLE_TOOL_INSTALL`, the `assertInstallAllowed` project-trust
|
|
3032
|
+
* gate (the #1334 S5 boundary), and `acquireInstallLock`. A refresh IS an
|
|
3033
|
+
* install trigger, just an unattended one, so it now passes through the same
|
|
3034
|
+
* three gates before any strategy runs. A refusal is `declined: true`, not a
|
|
3035
|
+
* degradation — see `ManagedToolRefreshAttempt.declined`.
|
|
3036
|
+
*/
|
|
3037
|
+
export async function refreshManagedTool(toolId, known = {}) {
|
|
3038
|
+
const tool = TOOLS.find((t) => t.id === toolId);
|
|
3039
|
+
if (!tool) {
|
|
3040
|
+
return { ok: false, unchanged: true, reason: "unknown tool id" };
|
|
3041
|
+
}
|
|
3042
|
+
if (tool.installStrategy !== "github" &&
|
|
3043
|
+
tool.installStrategy !== "pip" &&
|
|
3044
|
+
tool.installStrategy !== "gem" &&
|
|
3045
|
+
tool.installStrategy !== "maven" &&
|
|
3046
|
+
tool.installStrategy !== "archive") {
|
|
3047
|
+
return {
|
|
3048
|
+
ok: false,
|
|
3049
|
+
unchanged: true,
|
|
3050
|
+
reason: `strategy ${tool.installStrategy} is not refreshable here`,
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
// Captured into a local so the narrowing above survives the `await` below —
|
|
3054
|
+
// TS drops property narrowing (`tool.installStrategy`) across an `await`,
|
|
3055
|
+
// which would otherwise make the switch look non-exhaustive.
|
|
3056
|
+
const strategy = tool.installStrategy;
|
|
3057
|
+
const gate = await acquireManagedInstallGate(`managed tool refresh: ${toolId}`);
|
|
3058
|
+
if (!gate.ok) {
|
|
3059
|
+
return { ok: false, unchanged: true, declined: true, reason: gate.reason };
|
|
3060
|
+
}
|
|
3061
|
+
try {
|
|
3062
|
+
switch (strategy) {
|
|
3063
|
+
case "github":
|
|
3064
|
+
return await refreshGitHubManagedTool(tool, known);
|
|
3065
|
+
case "pip":
|
|
3066
|
+
case "gem":
|
|
3067
|
+
return await refreshPackageManagerManagedTool(tool);
|
|
3068
|
+
case "maven":
|
|
3069
|
+
case "archive":
|
|
3070
|
+
return await refreshPinnedManagedTool(tool, known);
|
|
3071
|
+
default:
|
|
3072
|
+
return {
|
|
3073
|
+
ok: false,
|
|
3074
|
+
unchanged: true,
|
|
3075
|
+
reason: `strategy ${strategy} is not refreshable here`,
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
finally {
|
|
3080
|
+
await gate.release?.();
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* `github`: ask `releases/latest` whether the tag moved, and download only if
|
|
3085
|
+
* it did.
|
|
3086
|
+
*
|
|
3087
|
+
* Three things keep 27 GitHub-release tools off GitHub's rate limit. The
|
|
3088
|
+
* per-tool weekly stamp means one query per tool per week; the one-refresh-per-
|
|
3089
|
+
* session budget means at most one query per session; and the stored `ETag`
|
|
3090
|
+
* replayed as `If-None-Match` makes the common answer a 304, which GitHub does
|
|
3091
|
+
* not charge against the limit at all. A 304, or a tag equal to the recorded
|
|
3092
|
+
* one, downloads nothing.
|
|
3093
|
+
*/
|
|
3094
|
+
async function refreshGitHubManagedTool(tool, known) {
|
|
3095
|
+
const spec = tool.github;
|
|
3096
|
+
if (!spec)
|
|
3097
|
+
return { ok: false, unchanged: true, reason: "no github spec" };
|
|
3098
|
+
let response;
|
|
3099
|
+
try {
|
|
3100
|
+
response = await httpsGetWithMeta(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, {
|
|
3101
|
+
...githubApiAuthHeaders(),
|
|
3102
|
+
...(known.etag ? { "If-None-Match": known.etag } : {}),
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
catch (err) {
|
|
3106
|
+
return {
|
|
3107
|
+
ok: false,
|
|
3108
|
+
unchanged: true,
|
|
3109
|
+
reason: `release query failed: ${err.message}`,
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
if (response.statusCode === 304) {
|
|
3113
|
+
return {
|
|
3114
|
+
ok: true,
|
|
3115
|
+
unchanged: true,
|
|
3116
|
+
resolutionId: known.resolutionId,
|
|
3117
|
+
etag: known.etag,
|
|
3118
|
+
version: known.version,
|
|
3119
|
+
};
|
|
3120
|
+
}
|
|
3121
|
+
let release;
|
|
3122
|
+
try {
|
|
3123
|
+
const parsed = JSON.parse(response.body.toString("utf8"));
|
|
3124
|
+
// #1759 review F3: `JSON.parse` succeeds on `null`, an array, or a bare
|
|
3125
|
+
// string just as happily as on an object — any of those would throw
|
|
3126
|
+
// reading `.tag_name` below, uncaught, and skip every stamp write this
|
|
3127
|
+
// candidate ever gets. Reject the shape here instead of downstream.
|
|
3128
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3129
|
+
throw new Error("release metadata is not an object");
|
|
3130
|
+
}
|
|
3131
|
+
release = parsed;
|
|
3132
|
+
}
|
|
3133
|
+
catch (err) {
|
|
3134
|
+
return {
|
|
3135
|
+
ok: false,
|
|
3136
|
+
unchanged: true,
|
|
3137
|
+
reason: `release metadata unparseable: ${err.message}`,
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
const tag = release.tag_name;
|
|
3141
|
+
if (!tag) {
|
|
3142
|
+
return { ok: false, unchanged: true, reason: "release has no tag_name" };
|
|
3143
|
+
}
|
|
3144
|
+
if (known.resolutionId === tag) {
|
|
3145
|
+
return {
|
|
3146
|
+
ok: true,
|
|
3147
|
+
unchanged: true,
|
|
3148
|
+
resolutionId: tag,
|
|
3149
|
+
etag: response.etag ?? known.etag,
|
|
3150
|
+
version: known.version ?? tag,
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
const installed = await installGitHubTool(tool, release);
|
|
3154
|
+
if (!installed) {
|
|
3155
|
+
return {
|
|
3156
|
+
ok: false,
|
|
3157
|
+
unchanged: true,
|
|
3158
|
+
// Keep the ETag off the failure stamp: replaying it would make the next
|
|
3159
|
+
// attempt a 304 and skip the download this one never completed.
|
|
3160
|
+
resolutionId: known.resolutionId,
|
|
3161
|
+
version: known.version,
|
|
3162
|
+
reason: `install from release ${tag} failed`,
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
const verified = await verifyRefreshedArtifact(tool, installed);
|
|
3166
|
+
if (!verified) {
|
|
3167
|
+
return {
|
|
3168
|
+
ok: false,
|
|
3169
|
+
unchanged: false,
|
|
3170
|
+
resolutionId: known.resolutionId,
|
|
3171
|
+
version: known.version,
|
|
3172
|
+
reason: `release ${tag} installed but its binary does not run`,
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
return {
|
|
3176
|
+
ok: true,
|
|
3177
|
+
unchanged: false,
|
|
3178
|
+
resolutionId: tag,
|
|
3179
|
+
etag: response.etag,
|
|
3180
|
+
version: tag,
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Confirm a just-refreshed artifact still runs, and re-stamp the probe cache
|
|
3185
|
+
* with its new mtime.
|
|
3186
|
+
*
|
|
3187
|
+
* A refresh REPLACES a working binary in place. Without this check a release
|
|
3188
|
+
* that ships a broken or wrong-architecture asset would be written over a
|
|
3189
|
+
* healthy copy and surface later as a dispatch failure with no obvious cause.
|
|
3190
|
+
* The verdict goes back to the caller, which degrades and marks the stamp
|
|
3191
|
+
* failed so the shorter retry cooldown applies.
|
|
3192
|
+
*
|
|
3193
|
+
* An archive TREE BUNDLE has no single binary to run; `installArchiveTool`
|
|
3194
|
+
* already confirms its tree marker, so the install's own success is the check.
|
|
3195
|
+
*/
|
|
3196
|
+
async function verifyRefreshedArtifact(tool, installedPath) {
|
|
3197
|
+
if (tool.installStrategy === "archive" && !tool.archive?.launcher) {
|
|
3198
|
+
// A tree bundle has no single binary to run — `installArchiveTool` already
|
|
3199
|
+
// confirmed its tree marker — but it still needs its NEW mtime recorded
|
|
3200
|
+
// (#1759 review F7), or the persisted probe entry keeps the pre-refresh
|
|
3201
|
+
// mtime and forces a full re-resolution on the next dispatch anyway.
|
|
3202
|
+
await updateProbeCache(tool.id, installedPath);
|
|
3203
|
+
return true;
|
|
3204
|
+
}
|
|
3205
|
+
if (!(await verifyToolBinary(installedPath))) {
|
|
3206
|
+
logSessionStart(`managed-tool-refresh ${tool.id}: refreshed artifact at ${installedPath} failed its --version check`);
|
|
3207
|
+
return false;
|
|
3208
|
+
}
|
|
3209
|
+
// Record the NEW mtime rather than leaving the persisted entry to miss its
|
|
3210
|
+
// mtime check and force a full re-resolution on the next dispatch.
|
|
3211
|
+
await updateProbeCache(tool.id, installedPath);
|
|
3212
|
+
return true;
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* `pip` and `gem`: re-run pi-lens's own install command in upgrade form.
|
|
3216
|
+
*
|
|
3217
|
+
* There is no coordinate to compare first — the registry pins no version and
|
|
3218
|
+
* the package managers install into user-owned directories with no manifest
|
|
3219
|
+
* pi-lens can read — so the tool's own `--version` output before and after is
|
|
3220
|
+
* what says whether anything moved.
|
|
3221
|
+
*/
|
|
3222
|
+
async function refreshPackageManagerManagedTool(tool) {
|
|
3223
|
+
if (!tool.packageName) {
|
|
3224
|
+
return { ok: false, unchanged: true, reason: "no package name" };
|
|
3225
|
+
}
|
|
3226
|
+
const previous = await probeManagedToolVersion(tool);
|
|
3227
|
+
const installed = tool.installStrategy === "pip"
|
|
3228
|
+
? // `-U` is the whole fix: without it pip treats the installed copy as
|
|
3229
|
+
// satisfying the requirement and the day-one version never moves.
|
|
3230
|
+
await installPipTool(tool.packageName, { upgrade: true })
|
|
3231
|
+
: // `gem install` always fetches the newest version that satisfies the
|
|
3232
|
+
// requirement, so the install command IS the upgrade command.
|
|
3233
|
+
await installGemTool(tool.packageName);
|
|
3234
|
+
if (!installed) {
|
|
3235
|
+
return {
|
|
3236
|
+
ok: false,
|
|
3237
|
+
unchanged: true,
|
|
3238
|
+
version: previous,
|
|
3239
|
+
reason: `${tool.installStrategy} upgrade failed`,
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
const current = await probeManagedToolVersion(tool);
|
|
3243
|
+
if (previous !== undefined && current === undefined) {
|
|
3244
|
+
// The tool answered `--version` before the upgrade and does not now: the
|
|
3245
|
+
// upgrade replaced a working copy with a broken one. Say so rather than
|
|
3246
|
+
// stamping success on a tool that no longer runs.
|
|
3247
|
+
return {
|
|
3248
|
+
ok: false,
|
|
3249
|
+
unchanged: false,
|
|
3250
|
+
version: previous,
|
|
3251
|
+
reason: `${tool.installStrategy} upgrade left the binary unable to report a version`,
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
return {
|
|
3255
|
+
ok: true,
|
|
3256
|
+
// An unreadable version on both sides cannot prove a move, so it reads as
|
|
3257
|
+
// unchanged rather than inventing one.
|
|
3258
|
+
unchanged: previous === undefined || current === undefined || previous === current,
|
|
3259
|
+
version: current ?? previous,
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* `maven` and `archive`: both pin an explicit version in THIS registry, so
|
|
3264
|
+
* their coordinate cannot move upstream — it moves when pi-lens bumps it.
|
|
3265
|
+
*
|
|
3266
|
+
* The freeze for these two is therefore the mirror image of the npm one: the
|
|
3267
|
+
* installer only installs when the tool is ABSENT, so a repo that bumps
|
|
3268
|
+
* spotbugs from 4.10.2 to 4.11.0 leaves every existing machine on 4.10.2
|
|
3269
|
+
* forever. Comparing the recorded coordinate against the registry's current one
|
|
3270
|
+
* catches exactly that, and costs no network when they match.
|
|
3271
|
+
*
|
|
3272
|
+
* A tool with no recorded coordinate is reinstalled once. pi-lens cannot read
|
|
3273
|
+
* which pin the installed artifact came from — an archive bundle carries no
|
|
3274
|
+
* manifest it writes — so the only way to make the installed copy provably
|
|
3275
|
+
* match the declared pin is to install it. That happens at most once per tool,
|
|
3276
|
+
* ever, and the stamp it writes suppresses it from then on.
|
|
3277
|
+
*/
|
|
3278
|
+
async function refreshPinnedManagedTool(tool, known) {
|
|
3279
|
+
const coordinate = tool.installStrategy === "maven"
|
|
3280
|
+
? tool.maven && mavenCoordinate(tool.maven)
|
|
3281
|
+
: tool.archive && resolveArchiveUrl(tool.archive);
|
|
3282
|
+
if (!coordinate) {
|
|
3283
|
+
return {
|
|
3284
|
+
ok: false,
|
|
3285
|
+
unchanged: true,
|
|
3286
|
+
reason: "no coordinate for this platform",
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
3289
|
+
if (known.resolutionId === coordinate) {
|
|
3290
|
+
return {
|
|
3291
|
+
ok: true,
|
|
3292
|
+
unchanged: true,
|
|
3293
|
+
resolutionId: coordinate,
|
|
3294
|
+
version: known.version,
|
|
3295
|
+
};
|
|
3296
|
+
}
|
|
3297
|
+
const installed = tool.installStrategy === "maven"
|
|
3298
|
+
? await installMavenTool(tool)
|
|
3299
|
+
: await installArchiveTool(tool);
|
|
3300
|
+
if (!installed) {
|
|
3301
|
+
return {
|
|
3302
|
+
ok: false,
|
|
3303
|
+
unchanged: true,
|
|
3304
|
+
resolutionId: known.resolutionId,
|
|
3305
|
+
version: known.version,
|
|
3306
|
+
reason: `reinstall from ${coordinate} failed`,
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
if (!(await verifyRefreshedArtifact(tool, installed))) {
|
|
3310
|
+
return {
|
|
3311
|
+
ok: false,
|
|
3312
|
+
unchanged: false,
|
|
3313
|
+
resolutionId: known.resolutionId,
|
|
3314
|
+
version: known.version,
|
|
3315
|
+
reason: `reinstall from ${coordinate} produced an artifact that does not run`,
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
return {
|
|
3319
|
+
ok: true,
|
|
3320
|
+
// A first-ever stamp is an adoption, not a version move: the coordinate did
|
|
3321
|
+
// not change, pi-lens simply had no record of it. Reporting it as a move
|
|
3322
|
+
// would put a false "x → y" row in the log.
|
|
3323
|
+
unchanged: known.resolutionId === undefined,
|
|
3324
|
+
resolutionId: coordinate,
|
|
3325
|
+
version: coordinate,
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3328
|
+
/**
|
|
3329
|
+
* The managed `node_modules/.bin` path for a tool binary, spelled the way
|
|
3330
|
+
* `installNpmTool` spells it (npm writes a `.cmd` shim on Windows, and that
|
|
3331
|
+
* shim — not its extensionless POSIX sibling — is the executable).
|
|
3332
|
+
*
|
|
3333
|
+
* Exported so the periodic refresh (#1730) verifies the same path the install
|
|
3334
|
+
* verified, instead of rebuilding the convention next to it.
|
|
3335
|
+
*/
|
|
3336
|
+
export function resolveManagedNpmBinPath(binaryName) {
|
|
3337
|
+
const binBase = path.join(TOOLS_DIR, "node_modules", ".bin", binaryName);
|
|
3338
|
+
return installerPlatform() === "win32" ? `${binBase}.cmd` : binBase;
|
|
3339
|
+
}
|
|
3340
|
+
/**
|
|
3341
|
+
* Drop every cached claim about where a tool resolves: the in-memory
|
|
3342
|
+
* resolved-path cache and the 24h on-disk probe cache.
|
|
3343
|
+
*
|
|
3344
|
+
* The probe cache keys on the binary's path and mtime, and a package-manager
|
|
3345
|
+
* update rewrites the package while frequently leaving the `.bin` shim's mtime
|
|
3346
|
+
* alone. Without this, a refresh that changed the version — or left a binary
|
|
3347
|
+
* that no longer runs — kept serving the previous answer from cache for up to
|
|
3348
|
+
* a day (#1746 review F2). Callers fall through to a fresh probe, which is the
|
|
3349
|
+
* path that can repair a broken install.
|
|
3350
|
+
*/
|
|
3351
|
+
export function invalidateManagedToolResolution(toolId) {
|
|
3352
|
+
resolvedPathCache.delete(toolId);
|
|
3353
|
+
if (_probeCache !== null)
|
|
3354
|
+
delete _probeCache[toolId];
|
|
3355
|
+
markProbeCacheChange(toolId, null);
|
|
3356
|
+
}
|
|
2549
3357
|
const MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2";
|
|
2550
3358
|
/**
|
|
2551
3359
|
* Install a Maven-distributed runnable fat JAR: download it into the managed bin
|
|
@@ -2571,6 +3379,7 @@ async function installMavenTool(tool) {
|
|
|
2571
3379
|
const groupPath = spec.groupId.replace(/\./g, "/");
|
|
2572
3380
|
const jarFile = `${spec.artifactId}-${spec.version}${spec.classifier ? `-${spec.classifier}` : ""}.jar`;
|
|
2573
3381
|
const url = `${base}/${groupPath}/${spec.artifactId}/${spec.version}/${jarFile}`;
|
|
3382
|
+
lastInstallResolutionId.set(tool.id, mavenCoordinate(spec));
|
|
2574
3383
|
logSessionStart(`maven-install ${tool.id}: downloading ${url}`);
|
|
2575
3384
|
let jarBuffer;
|
|
2576
3385
|
try {
|
|
@@ -2583,15 +3392,15 @@ async function installMavenTool(tool) {
|
|
|
2583
3392
|
try {
|
|
2584
3393
|
await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
|
|
2585
3394
|
const jarPath = path.join(GITHUB_BIN_DIR, `${tool.id}.jar`);
|
|
2586
|
-
await
|
|
3395
|
+
await writeFileAtomicAsync(jarPath, jarBuffer, { bestEffort: false });
|
|
2587
3396
|
// Launcher so the tool resolves as a normal command in the managed bin.
|
|
2588
3397
|
const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
|
|
2589
3398
|
const launcherPath = path.join(GITHUB_BIN_DIR, launcherName);
|
|
2590
3399
|
if (isWindows) {
|
|
2591
|
-
await
|
|
3400
|
+
await writeFileAtomicAsync(launcherPath, `@echo off\r\njava -jar "%~dp0${tool.id}.jar" %*\r\n`, { bestEffort: false });
|
|
2592
3401
|
}
|
|
2593
3402
|
else {
|
|
2594
|
-
await
|
|
3403
|
+
await writeFileAtomicAsync(launcherPath, `#!/bin/sh\nexec java -jar "$(dirname "$0")/${tool.id}.jar" "$@"\n`, { bestEffort: false, mode: 0o750 });
|
|
2595
3404
|
}
|
|
2596
3405
|
logSessionStart(`maven-install ${tool.id}: installed → ${launcherPath} (${jarBuffer.length} bytes)`);
|
|
2597
3406
|
debugLog(`[maven] installed ${tool.name} → ${launcherPath}`);
|
|
@@ -2619,6 +3428,76 @@ async function installMavenTool(tool) {
|
|
|
2619
3428
|
export function resolveArchiveUrl(spec, platform = process.platform, arch = process.arch) {
|
|
2620
3429
|
return typeof spec.url === "function" ? spec.url(platform, arch) : spec.url;
|
|
2621
3430
|
}
|
|
3431
|
+
/**
|
|
3432
|
+
* Move a verified-good extracted tree into its final, stable location without
|
|
3433
|
+
* ever leaving `finalDir` empty or missing while a working copy was there
|
|
3434
|
+
* (#1759 review F1).
|
|
3435
|
+
*
|
|
3436
|
+
* Both renames are same-volume directory renames (TOOLS_DIR never spans a
|
|
3437
|
+
* filesystem boundary here), so each one individually is as close to atomic
|
|
3438
|
+
* as the OS gives us. The gap between them — `finalDir` briefly absent — is
|
|
3439
|
+
* unavoidable without OS-level directory-swap support, but it is now
|
|
3440
|
+
* MICROSECONDS, not "however long the network download and tar extraction
|
|
3441
|
+
* take", and a failure on the second rename restores the backup rather than
|
|
3442
|
+
* leaving `finalDir` gone.
|
|
3443
|
+
*
|
|
3444
|
+
* Worst case (#1759 review R4): the second rename AND the rollback rename it
|
|
3445
|
+
* triggers both fail (e.g. the volume went read-only mid-swap). `finalDir` is
|
|
3446
|
+
* then genuinely empty and the only surviving copy sits at `finalDir.rollback`
|
|
3447
|
+
* — this function still throws either way, so the caller's install/refresh
|
|
3448
|
+
* reports failure rather than success, and `invalidateManagedToolResolution`
|
|
3449
|
+
* on that failure path (#1759 review F1) is what actually recovers: it drops
|
|
3450
|
+
* the cached resolved path, so the next `ensureTool` probe finds nothing at
|
|
3451
|
+
* `finalDir`, treats the tool as absent, and reinstalls from scratch. The
|
|
3452
|
+
* orphaned `.rollback` directory is NOT reclaimed by that reinstall — it is
|
|
3453
|
+
* only cleaned up the next time `swapExtractedDir` runs a SUCCESSFUL swap for
|
|
3454
|
+
* this same tool (the unconditional `fs.rm(backupDir, ...)` below), so it can
|
|
3455
|
+
* sit on disk for a while. The degradation this records below is what makes
|
|
3456
|
+
* that orphan discoverable rather than a silent leak.
|
|
3457
|
+
*/
|
|
3458
|
+
// Exported for the rollback-path unit test (#1759 review R3) — the failure
|
|
3459
|
+
// this guards is only reachable by making the SECOND rename throw, which a
|
|
3460
|
+
// full `installArchiveTool` run has no test-controlled pause point to force
|
|
3461
|
+
// without mocking `node:fs` wholesale.
|
|
3462
|
+
export async function swapExtractedDir(toolId, tmpDir, finalDir) {
|
|
3463
|
+
const backupDir = `${finalDir}.rollback`;
|
|
3464
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
3465
|
+
let hadPrevious = false;
|
|
3466
|
+
try {
|
|
3467
|
+
await fs.rename(finalDir, backupDir);
|
|
3468
|
+
hadPrevious = true;
|
|
3469
|
+
}
|
|
3470
|
+
catch (err) {
|
|
3471
|
+
if (err.code !== "ENOENT")
|
|
3472
|
+
throw err;
|
|
3473
|
+
}
|
|
3474
|
+
try {
|
|
3475
|
+
await fs.rename(tmpDir, finalDir);
|
|
3476
|
+
}
|
|
3477
|
+
catch (err) {
|
|
3478
|
+
if (hadPrevious) {
|
|
3479
|
+
try {
|
|
3480
|
+
await fs.rename(backupDir, finalDir);
|
|
3481
|
+
}
|
|
3482
|
+
catch (rollbackErr) {
|
|
3483
|
+
// The double-failure worst case: `finalDir` is empty and the only
|
|
3484
|
+
// surviving copy is the orphan at `backupDir`. Record it — this is
|
|
3485
|
+
// the one path where a swallowed catch here would make that orphan
|
|
3486
|
+
// invisible, not just inconvenient.
|
|
3487
|
+
recordDegradationOnce({
|
|
3488
|
+
kind: "managed-tool-refresh",
|
|
3489
|
+
subject: toolId,
|
|
3490
|
+
reason: `swap rollback failed after a failed install: working copy orphaned at ${backupDir} (${rollbackErr.message})`,
|
|
3491
|
+
});
|
|
3492
|
+
logSessionStart(`archive-install ${toolId}: swap rollback failed — working copy orphaned at ${backupDir} (${rollbackErr.message})`);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
throw err;
|
|
3496
|
+
}
|
|
3497
|
+
if (hadPrevious) {
|
|
3498
|
+
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
2622
3501
|
async function installArchiveTool(tool) {
|
|
2623
3502
|
const spec = tool.archive;
|
|
2624
3503
|
if (!spec)
|
|
@@ -2630,6 +3509,7 @@ async function installArchiveTool(tool) {
|
|
|
2630
3509
|
logSessionStart(`archive-install ${tool.id}: no archive for ${process.platform}/${process.arch} — unsupported, skipping`);
|
|
2631
3510
|
return undefined;
|
|
2632
3511
|
}
|
|
3512
|
+
lastInstallResolutionId.set(tool.id, url);
|
|
2633
3513
|
logSessionStart(`archive-install ${tool.id}: downloading ${url}`);
|
|
2634
3514
|
let archiveBuffer;
|
|
2635
3515
|
try {
|
|
@@ -2644,15 +3524,27 @@ async function installArchiveTool(tool) {
|
|
|
2644
3524
|
// `host:path` ("Cannot connect to C:"). Relative paths work for both GNU tar
|
|
2645
3525
|
// and Windows bsdtar, so we avoid the GNU-only `--force-local` (which bsdtar
|
|
2646
3526
|
// rejects). fs.* calls still use the absolute paths.
|
|
3527
|
+
//
|
|
3528
|
+
// #1759 review F1: extraction and verification happen in a TMP dir, never
|
|
3529
|
+
// in `extractDir` itself. `extractDir` is the location a refresh's caller
|
|
3530
|
+
// (and every prior session) is currently serving from; the old code
|
|
3531
|
+
// `fs.rm`'d it before extraction even started, so a corrupt download or a
|
|
3532
|
+
// failed tar left the tool GONE rather than "kept the installed version" —
|
|
3533
|
+
// exactly the outcome the failure log line claimed did not happen. The
|
|
3534
|
+
// installed copy is now untouched until the replacement is proven good.
|
|
2647
3535
|
const extractName = tool.id;
|
|
3536
|
+
const tmpExtractName = `${extractName}.refresh-tmp`;
|
|
2648
3537
|
const archiveName = `${tool.id}.download.${spec.kind === "zip" ? "zip" : "tgz"}`;
|
|
2649
3538
|
const extractDir = path.join(TOOLS_DIR, extractName);
|
|
3539
|
+
const tmpExtractDir = path.join(TOOLS_DIR, tmpExtractName);
|
|
2650
3540
|
const tmpArchive = path.join(TOOLS_DIR, archiveName);
|
|
2651
3541
|
try {
|
|
2652
3542
|
await fs.mkdir(TOOLS_DIR, { recursive: true });
|
|
2653
|
-
// Clear any prior
|
|
2654
|
-
|
|
2655
|
-
|
|
3543
|
+
// Clear any stale tmp dir from an interrupted prior attempt — this is the
|
|
3544
|
+
// SCRATCH location, never the live install, so clearing it never risks the
|
|
3545
|
+
// working copy.
|
|
3546
|
+
await fs.rm(tmpExtractDir, { recursive: true, force: true });
|
|
3547
|
+
await fs.mkdir(tmpExtractDir, { recursive: true });
|
|
2656
3548
|
await fs.writeFile(tmpArchive, archiveBuffer);
|
|
2657
3549
|
// `--strip-components=N` drops N leading path components. Default 1 drops a
|
|
2658
3550
|
// versioned top-level dir so a launcher path stays stable (bin/… not
|
|
@@ -2664,10 +3556,8 @@ async function installArchiveTool(tool) {
|
|
|
2664
3556
|
spec.kind === "tgz" ? "-xzf" : "-xf",
|
|
2665
3557
|
archiveName,
|
|
2666
3558
|
"-C",
|
|
2667
|
-
|
|
2668
|
-
...(stripComponents > 0
|
|
2669
|
-
? [`--strip-components=${stripComponents}`]
|
|
2670
|
-
: []),
|
|
3559
|
+
tmpExtractName,
|
|
3560
|
+
...(stripComponents > 0 ? [`--strip-components=${stripComponents}`] : []),
|
|
2671
3561
|
];
|
|
2672
3562
|
// Resolve `tar` to an absolute path on Windows (System32\tar.exe is the
|
|
2673
3563
|
// bsdtar shipped with Windows 10+) so extraction can't be hijacked via a
|
|
@@ -2689,39 +3579,54 @@ async function installArchiveTool(tool) {
|
|
|
2689
3579
|
};
|
|
2690
3580
|
await fs.rm(tmpArchive, { force: true });
|
|
2691
3581
|
if (!extracted.ok) {
|
|
2692
|
-
logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr}`);
|
|
3582
|
+
logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr} — keeping installed version`);
|
|
3583
|
+
await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
|
|
2693
3584
|
return undefined;
|
|
2694
3585
|
}
|
|
2695
3586
|
// Tree bundle (no launcher): the whole extracted tree IS the artifact. Verify
|
|
2696
|
-
// the marker exists
|
|
2697
|
-
// launches a runtime against a bootstrap inside it (e.g.
|
|
3587
|
+
// the marker exists — in the TMP dir, before it becomes the live one — the
|
|
3588
|
+
// consuming server launches a runtime against a bootstrap inside it (e.g.
|
|
3589
|
+
// PSES via pwsh).
|
|
2698
3590
|
if (!spec.launcher) {
|
|
2699
|
-
const
|
|
2700
|
-
? path.join(
|
|
2701
|
-
:
|
|
3591
|
+
const tmpMarker = spec.treeMarker
|
|
3592
|
+
? path.join(tmpExtractDir, ...spec.treeMarker.split("/"))
|
|
3593
|
+
: tmpExtractDir;
|
|
2702
3594
|
try {
|
|
2703
|
-
await fs.access(
|
|
3595
|
+
await fs.access(tmpMarker);
|
|
2704
3596
|
}
|
|
2705
3597
|
catch {
|
|
2706
|
-
logSessionStart(`archive-install ${tool.id}: tree marker not found at ${
|
|
3598
|
+
logSessionStart(`archive-install ${tool.id}: tree marker not found at ${tmpMarker} after extraction — keeping installed version`);
|
|
3599
|
+
await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
|
|
2707
3600
|
return undefined;
|
|
2708
3601
|
}
|
|
3602
|
+
await swapExtractedDir(tool.id, tmpExtractDir, extractDir);
|
|
2709
3603
|
logSessionStart(`archive-install ${tool.id}: installed tree bundle → ${extractDir} (extracted ${archiveBuffer.length} bytes)`);
|
|
2710
3604
|
debugLog(`[archive] installed ${tool.name} bundle → ${extractDir}`);
|
|
2711
3605
|
return extractDir;
|
|
2712
3606
|
}
|
|
2713
|
-
// The launcher inside the extracted tree (e.g. bin/spotbugs[.bat])
|
|
2714
|
-
|
|
2715
|
-
const
|
|
3607
|
+
// The launcher inside the TMP extracted tree (e.g. bin/spotbugs[.bat]),
|
|
3608
|
+
// verified before the tree becomes the live one.
|
|
3609
|
+
const launcherParts = spec.launcher.split("/").map((p) => p);
|
|
3610
|
+
const tmpInnerLauncher = path.join(tmpExtractDir, ...launcherParts);
|
|
3611
|
+
const tmpResolvedInner = isWindows
|
|
3612
|
+
? `${tmpInnerLauncher}.bat`
|
|
3613
|
+
: tmpInnerLauncher;
|
|
2716
3614
|
try {
|
|
2717
|
-
await fs.access(
|
|
3615
|
+
await fs.access(tmpResolvedInner);
|
|
2718
3616
|
}
|
|
2719
3617
|
catch {
|
|
2720
|
-
logSessionStart(`archive-install ${tool.id}: launcher not found at ${
|
|
3618
|
+
logSessionStart(`archive-install ${tool.id}: launcher not found at ${tmpResolvedInner} after extraction — keeping installed version`);
|
|
3619
|
+
await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
|
|
2721
3620
|
return undefined;
|
|
2722
3621
|
}
|
|
2723
3622
|
if (!isWindows)
|
|
2724
|
-
await fs.chmod(
|
|
3623
|
+
await fs.chmod(tmpResolvedInner, 0o750).catch(() => { });
|
|
3624
|
+
await swapExtractedDir(tool.id, tmpExtractDir, extractDir);
|
|
3625
|
+
// The launcher path inside `extractDir` — stable across every install and
|
|
3626
|
+
// refresh, since `extractDir` itself never changes — computed fresh now
|
|
3627
|
+
// that the swap has put the verified tree there.
|
|
3628
|
+
const innerLauncher = path.join(extractDir, ...launcherParts);
|
|
3629
|
+
const resolvedInner = isWindows ? `${innerLauncher}.bat` : innerLauncher;
|
|
2725
3630
|
// Thin shim in the managed bin so discovery (findGitHubToolPath) resolves
|
|
2726
3631
|
// it like any other managed tool. `call`/`exec` preserves the real
|
|
2727
3632
|
// launcher's own %~dp0/$0 so it still finds its sibling lib/.
|
|
@@ -2729,10 +3634,10 @@ async function installArchiveTool(tool) {
|
|
|
2729
3634
|
const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
|
|
2730
3635
|
const shimPath = path.join(GITHUB_BIN_DIR, launcherName);
|
|
2731
3636
|
if (isWindows) {
|
|
2732
|
-
await
|
|
3637
|
+
await writeFileAtomicAsync(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`, { bestEffort: false });
|
|
2733
3638
|
}
|
|
2734
3639
|
else {
|
|
2735
|
-
await
|
|
3640
|
+
await writeFileAtomicAsync(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { bestEffort: false, mode: 0o750 });
|
|
2736
3641
|
}
|
|
2737
3642
|
logSessionStart(`archive-install ${tool.id}: installed → ${shimPath} (extracted ${archiveBuffer.length} bytes)`);
|
|
2738
3643
|
debugLog(`[archive] installed ${tool.name} → ${shimPath}`);
|
|
@@ -2740,7 +3645,8 @@ async function installArchiveTool(tool) {
|
|
|
2740
3645
|
}
|
|
2741
3646
|
catch (err) {
|
|
2742
3647
|
await fs.rm(tmpArchive, { force: true }).catch(() => { });
|
|
2743
|
-
|
|
3648
|
+
await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
|
|
3649
|
+
logSessionStart(`archive-install ${tool.id}: install failed: ${err.message} — keeping installed version`);
|
|
2744
3650
|
return undefined;
|
|
2745
3651
|
}
|
|
2746
3652
|
}
|
|
@@ -2754,7 +3660,7 @@ async function installNpmTool(packageName, binaryName) {
|
|
|
2754
3660
|
await fs.access(packageJsonPath);
|
|
2755
3661
|
}
|
|
2756
3662
|
catch {
|
|
2757
|
-
await
|
|
3663
|
+
await writeFileAtomicAsync(packageJsonPath, JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2), { bestEffort: false });
|
|
2758
3664
|
}
|
|
2759
3665
|
// Resolve the package manager for the tools dir and build install args.
|
|
2760
3666
|
const isWindows = installerPlatform() === "win32";
|
|
@@ -2859,31 +3765,43 @@ async function installNpmTool(packageName, binaryName) {
|
|
|
2859
3765
|
/**
|
|
2860
3766
|
* Install a pip package tool
|
|
2861
3767
|
*/
|
|
2862
|
-
async function installPipTool(packageName
|
|
3768
|
+
async function installPipTool(packageName,
|
|
3769
|
+
/**
|
|
3770
|
+
* Add `-U`, turning the install into an upgrade. Without it `pip install`
|
|
3771
|
+
* treats an already-present package as satisfied and leaves the day-one
|
|
3772
|
+
* version in place forever — the freeze #1747 is about. The flag is the ONLY
|
|
3773
|
+
* difference between install and refresh: same command ladder, same
|
|
3774
|
+
* `--user` target, so a refresh can never write somewhere the install would
|
|
3775
|
+
* not have.
|
|
3776
|
+
*/
|
|
3777
|
+
options = {}) {
|
|
2863
3778
|
try {
|
|
2864
3779
|
const isWindows = process.platform === "win32";
|
|
3780
|
+
const verb = options.upgrade
|
|
3781
|
+
? ["install", "-U", "--user"]
|
|
3782
|
+
: ["install", "--user"];
|
|
2865
3783
|
const pipCandidates = isWindows
|
|
2866
3784
|
? [
|
|
2867
|
-
{ command: "pip", args: [
|
|
3785
|
+
{ command: "pip", args: [...verb, packageName] },
|
|
2868
3786
|
{
|
|
2869
3787
|
command: "py",
|
|
2870
|
-
args: ["-m", "pip",
|
|
3788
|
+
args: ["-m", "pip", ...verb, packageName],
|
|
2871
3789
|
},
|
|
2872
3790
|
{
|
|
2873
3791
|
command: "python",
|
|
2874
|
-
args: ["-m", "pip",
|
|
3792
|
+
args: ["-m", "pip", ...verb, packageName],
|
|
2875
3793
|
},
|
|
2876
3794
|
]
|
|
2877
3795
|
: [
|
|
2878
|
-
{ command: "pip3", args: [
|
|
2879
|
-
{ command: "pip", args: [
|
|
3796
|
+
{ command: "pip3", args: [...verb, packageName] },
|
|
3797
|
+
{ command: "pip", args: [...verb, packageName] },
|
|
2880
3798
|
{
|
|
2881
3799
|
command: "python3",
|
|
2882
|
-
args: ["-m", "pip",
|
|
3800
|
+
args: ["-m", "pip", ...verb, packageName],
|
|
2883
3801
|
},
|
|
2884
3802
|
{
|
|
2885
3803
|
command: "python",
|
|
2886
|
-
args: ["-m", "pip",
|
|
3804
|
+
args: ["-m", "pip", ...verb, packageName],
|
|
2887
3805
|
},
|
|
2888
3806
|
];
|
|
2889
3807
|
let lastError = "";
|
|
@@ -3007,8 +3925,46 @@ async function installGemTool(packageName) {
|
|
|
3007
3925
|
return undefined;
|
|
3008
3926
|
}
|
|
3009
3927
|
}
|
|
3928
|
+
/**
|
|
3929
|
+
* Stamp the refresh state at INSTALL time (#1747).
|
|
3930
|
+
*
|
|
3931
|
+
* An install has just done, by definition, the freshest possible resolution.
|
|
3932
|
+
* Recording it here means the periodic refresh starts its cadence from the
|
|
3933
|
+
* install rather than treating a day-old tool as never checked — and it is what
|
|
3934
|
+
* spares the archive/maven entries a redundant re-download the first time their
|
|
3935
|
+
* coordinate comparison runs with no recorded pin.
|
|
3936
|
+
*
|
|
3937
|
+
* npm is skipped: its install case already calls `stampManagedToolInstalled`
|
|
3938
|
+
* directly (#1746 review F4), which reads the installed version off disk. That
|
|
3939
|
+
* call happens synchronously before this funnel runs, so stamping npm again
|
|
3940
|
+
* here — with no version, since `lastInstallResolutionId` is never set for
|
|
3941
|
+
* npm — would overwrite the version-bearing stamp with a version-less one.
|
|
3942
|
+
*
|
|
3943
|
+
* Lazily imported so `installer/index.ts` keeps no static dependency on the
|
|
3944
|
+
* refresh module (which imports this one). Best-effort: a stamp that cannot be
|
|
3945
|
+
* written only costs one extra re-resolution later, so it never fails an
|
|
3946
|
+
* install.
|
|
3947
|
+
*/
|
|
3948
|
+
async function stampInstallResolution(toolId) {
|
|
3949
|
+
const tool = TOOLS.find((t) => t.id === toolId);
|
|
3950
|
+
if (tool?.installStrategy === "npm")
|
|
3951
|
+
return;
|
|
3952
|
+
try {
|
|
3953
|
+
const resolutionId = lastInstallResolutionId.get(toolId);
|
|
3954
|
+
const refresh = await import("./managed-tool-refresh.js");
|
|
3955
|
+
await refresh.stampManagedToolInstall(toolId, resolutionId);
|
|
3956
|
+
}
|
|
3957
|
+
catch {
|
|
3958
|
+
// best-effort
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3010
3961
|
async function finishInstallAttempt(toolId, ok, startedAt) {
|
|
3011
3962
|
logSessionStart(`auto-install ${toolId}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
|
|
3963
|
+
if (ok)
|
|
3964
|
+
await stampInstallResolution(toolId);
|
|
3965
|
+
// Every install strategy funnels its outcome through here, so this one write
|
|
3966
|
+
// records attempt-ness for all of them (#1500).
|
|
3967
|
+
noteInstallAttempt(toolId, ok ? "succeeded" : "failed", ok ? undefined : (installFailureReasons.get(toolId) ?? "install failed"));
|
|
3012
3968
|
if (ok) {
|
|
3013
3969
|
// A prior availability probe may have cached ENOENT for this exact child
|
|
3014
3970
|
// PATH. Make a successful mutation visible immediately rather than waiting
|
|
@@ -3040,11 +3996,13 @@ async function finishInstallAttempt(toolId, ok, startedAt) {
|
|
|
3040
3996
|
export async function installTool(toolId) {
|
|
3041
3997
|
if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
|
|
3042
3998
|
installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
3999
|
+
noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
3043
4000
|
logSessionStart(`auto-install ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
|
|
3044
4001
|
return false;
|
|
3045
4002
|
}
|
|
3046
4003
|
const tool = TOOLS.find((t) => t.id === toolId);
|
|
3047
4004
|
if (!tool) {
|
|
4005
|
+
noteInstallAttempt(toolId, "declined", "unknown tool id");
|
|
3048
4006
|
logSessionStart(`auto-install ${toolId}: unknown tool id`);
|
|
3049
4007
|
return false;
|
|
3050
4008
|
}
|
|
@@ -3056,6 +4014,26 @@ export async function installTool(toolId) {
|
|
|
3056
4014
|
if (!tool.packageName || !tool.binaryName)
|
|
3057
4015
|
return false;
|
|
3058
4016
|
const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
|
|
4017
|
+
if (npmPath !== undefined) {
|
|
4018
|
+
// #1746 review F4: an install just resolved this package's range
|
|
4019
|
+
// against the registry, so record it as freshly checked. Otherwise a
|
|
4020
|
+
// new machine that installs 22 tools today has 22 unstamped tools,
|
|
4021
|
+
// and spends the next 22 sessions running `npm update` on packages
|
|
4022
|
+
// it installed minutes ago.
|
|
4023
|
+
//
|
|
4024
|
+
// Dynamic import, not a static one: managed-tool-refresh.ts imports
|
|
4025
|
+
// THIS module for the tool registry, and a static import back would
|
|
4026
|
+
// be a cycle. By the time this line runs the module graph is long
|
|
4027
|
+
// since evaluated, so the lazy import is safe and also keeps the
|
|
4028
|
+
// refresh module off the startup path.
|
|
4029
|
+
await import("./managed-tool-refresh.js")
|
|
4030
|
+
.then((m) => m.stampManagedToolInstalled(tool.id, tool.packageName))
|
|
4031
|
+
.catch(() => {
|
|
4032
|
+
// Best-effort telemetry: a missing stamp costs one wasted
|
|
4033
|
+
// update later, never a wrong version, and must not fail the
|
|
4034
|
+
// install that just succeeded.
|
|
4035
|
+
});
|
|
4036
|
+
}
|
|
3059
4037
|
return finishInstallAttempt(tool.id, npmPath !== undefined, startedAt);
|
|
3060
4038
|
}
|
|
3061
4039
|
case "pip": {
|
|
@@ -3112,16 +4090,27 @@ export async function ensureTool(toolId, opts) {
|
|
|
3112
4090
|
if (opts?.allowInstall !== false &&
|
|
3113
4091
|
!assertInstallAllowed(`managed tool ensure: ${toolId}`)) {
|
|
3114
4092
|
logSessionStart(`auto-install ensure ${toolId}: install gated — ${projectTrustDenialReason()}; discovery only`);
|
|
3115
|
-
|
|
4093
|
+
const denialReason = projectTrustDenialReason();
|
|
4094
|
+
const discovered = await ensureToolResolved(toolId, {
|
|
4095
|
+
...opts,
|
|
4096
|
+
allowInstall: false,
|
|
4097
|
+
});
|
|
4098
|
+
// AFTER the discovery pass, which clears the per-attempt record on entry.
|
|
4099
|
+
noteInstallAttempt(toolId, "declined", `project trust: ${denialReason}`);
|
|
4100
|
+
return discovered;
|
|
3116
4101
|
}
|
|
3117
4102
|
return ensureToolResolved(toolId, opts);
|
|
3118
4103
|
}
|
|
3119
4104
|
async function ensureToolResolved(toolId, opts) {
|
|
3120
4105
|
installFailureReasons.delete(toolId);
|
|
4106
|
+
// A fresh ensure supersedes whatever the last one recorded, and the trust-gate
|
|
4107
|
+
// branch above deliberately keeps its `declined` record by never reaching here.
|
|
4108
|
+
installAttempts.delete(toolId);
|
|
4109
|
+
lastEnsureResolutionSource.delete(toolId);
|
|
3121
4110
|
const cacheResolvedPath = (result) => {
|
|
3122
4111
|
if (result) {
|
|
3123
4112
|
resolvedPathCache.set(toolId, result);
|
|
3124
|
-
void updateProbeCache(toolId, result);
|
|
4113
|
+
void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
|
|
3125
4114
|
}
|
|
3126
4115
|
return result;
|
|
3127
4116
|
};
|
|
@@ -3144,16 +4133,19 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3144
4133
|
// best-effort
|
|
3145
4134
|
}
|
|
3146
4135
|
if (opts.allowInstall === false) {
|
|
4136
|
+
noteInstallAttempt(toolId, "declined", "install disabled by caller");
|
|
3147
4137
|
logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
|
|
3148
4138
|
return cacheResolvedPath(await getToolPath(toolId));
|
|
3149
4139
|
}
|
|
3150
4140
|
if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
|
|
3151
4141
|
installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
4142
|
+
noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
3152
4143
|
logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
|
|
3153
4144
|
return undefined;
|
|
3154
4145
|
}
|
|
3155
4146
|
const lock = await acquireInstallLock();
|
|
3156
4147
|
if (!lock.release) {
|
|
4148
|
+
noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
|
|
3157
4149
|
logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
|
|
3158
4150
|
return undefined;
|
|
3159
4151
|
}
|
|
@@ -3165,6 +4157,9 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3165
4157
|
await lock.release();
|
|
3166
4158
|
}
|
|
3167
4159
|
if (!installed) {
|
|
4160
|
+
// installTool RAN. Whatever it recorded stands; otherwise this is a
|
|
4161
|
+
// genuine failure, which is what the caller needs to see (#1500).
|
|
4162
|
+
noteInstallAttemptIfUnrecorded(toolId, "failed", "install failed");
|
|
3168
4163
|
logSessionStart(`auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`);
|
|
3169
4164
|
return undefined;
|
|
3170
4165
|
}
|
|
@@ -3178,10 +4173,13 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3178
4173
|
// Fast path 1: in-memory session cache — no I/O.
|
|
3179
4174
|
const cached = resolvedPathCache.get(toolId);
|
|
3180
4175
|
if (cached) {
|
|
3181
|
-
if (!isFullyQualified(cached))
|
|
4176
|
+
if (!isFullyQualified(cached)) {
|
|
4177
|
+
lastEnsureResolutionSource.set(toolId, "session-cache");
|
|
3182
4178
|
return cached;
|
|
4179
|
+
}
|
|
3183
4180
|
try {
|
|
3184
4181
|
await fs.access(cached);
|
|
4182
|
+
lastEnsureResolutionSource.set(toolId, "session-cache");
|
|
3185
4183
|
return cached;
|
|
3186
4184
|
}
|
|
3187
4185
|
catch {
|
|
@@ -3198,6 +4196,7 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3198
4196
|
const diskCached = await checkProbeCache(toolId);
|
|
3199
4197
|
if (diskCached) {
|
|
3200
4198
|
resolvedPathCache.set(toolId, diskCached);
|
|
4199
|
+
lastEnsureResolutionSource.set(toolId, "probe-cache");
|
|
3201
4200
|
logSessionStart(`auto-install ensure ${toolId}: probe cache hit → ${diskCached}`);
|
|
3202
4201
|
return diskCached;
|
|
3203
4202
|
}
|
|
@@ -3242,7 +4241,8 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3242
4241
|
}
|
|
3243
4242
|
}
|
|
3244
4243
|
resolvedPathCache.set(toolId, existingPath);
|
|
3245
|
-
void updateProbeCache(toolId, existingPath);
|
|
4244
|
+
void updateProbeCache(toolId, existingPath, wasLastResolveTransient(toolId));
|
|
4245
|
+
lastEnsureResolutionSource.set(toolId, "path");
|
|
3246
4246
|
logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
|
|
3247
4247
|
return existingPath;
|
|
3248
4248
|
}
|
|
@@ -3251,17 +4251,20 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3251
4251
|
// caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
|
|
3252
4252
|
// we must still return a discovered binary and only skip the actual install.
|
|
3253
4253
|
if (opts?.allowInstall === false) {
|
|
4254
|
+
noteInstallAttempt(toolId, "declined", "install disabled by caller");
|
|
3254
4255
|
logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
|
|
3255
4256
|
return undefined;
|
|
3256
4257
|
}
|
|
3257
4258
|
if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
|
|
3258
4259
|
installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
4260
|
+
noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
|
|
3259
4261
|
logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1 (${Date.now() - ensureStartMs}ms)`);
|
|
3260
4262
|
return undefined;
|
|
3261
4263
|
}
|
|
3262
4264
|
const lock = await acquireInstallLock();
|
|
3263
4265
|
if (!lock.release) {
|
|
3264
4266
|
installFailureReasons.set(toolId, lock.reason ?? "install lock failed");
|
|
4267
|
+
noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
|
|
3265
4268
|
logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
|
|
3266
4269
|
return undefined;
|
|
3267
4270
|
}
|
|
@@ -3271,8 +4274,9 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3271
4274
|
// installed by its predecessor and must not run a second package manager.
|
|
3272
4275
|
const installedByPeer = await getToolPath(toolId);
|
|
3273
4276
|
if (installedByPeer) {
|
|
4277
|
+
noteInstallAttempt(toolId, "succeeded", "installed by a concurrent process");
|
|
3274
4278
|
resolvedPathCache.set(toolId, installedByPeer);
|
|
3275
|
-
void updateProbeCache(toolId, installedByPeer);
|
|
4279
|
+
void updateProbeCache(toolId, installedByPeer, wasLastResolveTransient(toolId));
|
|
3276
4280
|
return installedByPeer;
|
|
3277
4281
|
}
|
|
3278
4282
|
installed = await installTool(toolId);
|
|
@@ -3281,13 +4285,16 @@ async function ensureToolResolved(toolId, opts) {
|
|
|
3281
4285
|
await lock.release();
|
|
3282
4286
|
}
|
|
3283
4287
|
if (!installed) {
|
|
4288
|
+
// installTool RAN and did not succeed. A genuine failure unless it
|
|
4289
|
+
// recorded a more specific outcome of its own (#1500).
|
|
4290
|
+
noteInstallAttemptIfUnrecorded(toolId, "failed", "install failed");
|
|
3284
4291
|
logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
|
|
3285
4292
|
return undefined;
|
|
3286
4293
|
}
|
|
3287
4294
|
const result = await getToolPath(toolId);
|
|
3288
4295
|
if (result) {
|
|
3289
4296
|
resolvedPathCache.set(toolId, result);
|
|
3290
|
-
void updateProbeCache(toolId, result);
|
|
4297
|
+
void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
|
|
3291
4298
|
logSessionStart(`auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`);
|
|
3292
4299
|
}
|
|
3293
4300
|
else {
|
|
@@ -3345,6 +4352,16 @@ export async function checkAllTools() {
|
|
|
3345
4352
|
export function isKnownToolId(toolId) {
|
|
3346
4353
|
return TOOLS.some((tool) => tool.id === toolId);
|
|
3347
4354
|
}
|
|
4355
|
+
/**
|
|
4356
|
+
* The registry's own install strategy for `toolId`, or `undefined` for an
|
|
4357
|
+
* unknown id. Single source of truth for anything that needs to LABEL how a
|
|
4358
|
+
* tool gets installed (e.g. the availability-decision evidence's `source`
|
|
4359
|
+
* tag, #1612) — derived from the same `TOOLS` entry `ensureTool` dispatches
|
|
4360
|
+
* on, so the label can never drift out of sync with the actual installer.
|
|
4361
|
+
*/
|
|
4362
|
+
export function getToolInstallStrategy(toolId) {
|
|
4363
|
+
return TOOLS.find((tool) => tool.id === toolId)?.installStrategy;
|
|
4364
|
+
}
|
|
3348
4365
|
/**
|
|
3349
4366
|
* GitHub-release tools that ship an asset for **every** supported
|
|
3350
4367
|
* platform/arch combo (linux/darwin/win32 × x64/arm64). This is the set the
|
|
@@ -3378,6 +4395,7 @@ export const GITHUB_TOOLS = [
|
|
|
3378
4395
|
"opengrep",
|
|
3379
4396
|
"deno",
|
|
3380
4397
|
"clojure-lsp",
|
|
4398
|
+
"cue",
|
|
3381
4399
|
"gleam",
|
|
3382
4400
|
"marksman",
|
|
3383
4401
|
"expert",
|