From 5712789c75c573051aaa47c91b73306eb8538642 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 11:20:52 -0400 Subject: [PATCH 1/6] fix(repo): update all repository URLs after rename to pythinker-cli The GitHub repository was renamed from Pythoughts-labs/pythinker-code to Pythoughts-labs/pythinker-cli, and the old name's redirect no longer applies. Update every repo-slug constant and URL: install scripts (all copies), the in-app updater, the default feedback repo, the docs-pages repository guard, winget/homebrew/scoop workflow links, README, docs, and matching tests. --- .github/ISSUE_TEMPLATE/1-bug-report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/pull_request_template.md | 4 +- .github/workflows/docs-pages.yml | 2 +- .github/workflows/homebrew-tap.yml | 4 +- .github/workflows/scoop-bucket.yml | 4 +- .github/workflows/winget.yml | 2 +- CHANGELOG.md | 51 ++++++++++--------- CONTRIBUTING.md | 2 +- README.md | 48 ++++++++--------- SECURITY.md | 2 +- docs/.vitepress/config.ts | 2 +- docs/en/guides/getting-started.md | 4 +- docs/en/index.md | 2 +- docs/en/release-notes/changelog.md | 50 +++++++++--------- docs/history/CHANGELOG-pre-0.8.0.md | 2 +- docs/public/install.ps1 | 2 +- docs/public/install.sh | 2 +- examples/feedback-worker/wrangler.jsonc | 2 +- packages/homebrew-tap/generate-formula.py | 2 +- packages/linux-installer/README.md | 8 +-- packages/linux-installer/build.sh | 2 +- packages/pythinker-core/CHANGELOG.md | 2 +- packages/pythinker-core/pyproject.toml | 8 +-- .../contrib/chat_provider/openai_legacy.py | 2 +- .../api_snapshot_tests/test_openai_legacy.py | 2 +- packages/pythinker-host/pyproject.toml | 8 +-- packages/pythinker-review/pyproject.toml | 4 +- .../src/pythinker_review/output/sarif.py | 2 +- packages/scoop-bucket/generate-manifest.py | 2 +- .../scoop-bucket/pythinker-code.json.tmpl | 4 +- packages/windows-installer/installer.iss | 4 +- pyproject.toml | 10 ++-- scripts/install-native.sh | 2 +- scripts/install.ps1 | 2 +- sdks/pythinker-sdk/pyproject.toml | 8 +-- src/pythinker_code/feedback_repo.py | 2 +- src/pythinker_code/native.py | 2 +- .../skills/pythinker-code-help/SKILL.md | 2 +- src/pythinker_code/ui/shell/slash.py | 4 +- src/pythinker_code/ui/shell/update.py | 4 +- tests/core/test_config.py | 6 +-- tests/core/test_simple_compaction.py | 4 +- tests/test_installation_docs.py | 6 +-- tests/test_native.py | 2 +- .../ui_and_conv/test_shell_feedback_slash.py | 12 ++--- web/public/install.ps1 | 2 +- web/public/install.sh | 2 +- 48 files changed, 155 insertions(+), 154 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index 82d7d883..a802ab3e 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -9,7 +9,7 @@ body: value: | Thank you for submitting a bug report! It helps make Pythinker Code better for everyone. - If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-code/discussions](https://github.com/Pythoughts-labs/pythinker-code/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. + If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-code/discussions](https://github.com/Pythoughts-labs/pythinker-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. Make sure you are running the latest version of Pythinker Code (`uv tool upgrade pythinker-code` to upgrade). The bug you are experiencing may already have been fixed. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e6451805..ef3acd5d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Questions and General Discussion - url: https://github.com/Pythoughts-labs/pythinker-code/discussions + url: https://github.com/Pythoughts-labs/pythinker-cli/discussions about: Have questions? Welcome to open a discussion! diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9beee5e5..019355ea 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,7 +3,7 @@ Thank you for your contribution to Pythinker Code! Please make sure you already discussed the feature or bugfix you are proposing in an issue with the maintainers. Please understand that if you have not gotten confirmation from the maintainers, your pull request may be closed or ignored without further review due to limited bandwidth. -See https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md for more. +See https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md for more. --> ## Related Issue @@ -18,7 +18,7 @@ Resolve #(issue_number) ## Checklist -- [ ] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. +- [ ] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md) document. - [ ] I have linked the related issue, if any. - [ ] I have added tests that prove my fix is effective or that my feature works. - [ ] I have run `make gen-changelog` to update the changelog. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8d6a4428..2c223598 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -21,7 +21,7 @@ concurrency: jobs: build: # Only run on the original repository, not on forks - if: github.repository == 'Pythoughts-labs/pythinker-code' + if: github.repository == 'Pythoughts-labs/pythinker-cli' runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 8697477d..82cd8217 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -158,14 +158,14 @@ jobs: cat > README.md < README.md <<'EOF' # scoop-pythinker - Scoop bucket for [Pythinker Code](https://github.com/Pythoughts-labs/pythinker-code). + Scoop bucket for [Pythinker Code](https://github.com/Pythoughts-labs/pythinker-cli). ```pwsh scoop bucket add pythinker https://github.com/Pythoughts-labs/scoop-pythinker @@ -137,7 +137,7 @@ jobs: ``` This bucket is auto-updated by the - [scoop-bucket.yml](https://github.com/Pythoughts-labs/pythinker-code/blob/main/.github/workflows/scoop-bucket.yml) + [scoop-bucket.yml](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/.github/workflows/scoop-bucket.yml) workflow on every semver release tag. Do not hand-edit `bucket/*` — your edits will be overwritten on the next release. EOF diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 49420d0a..3e372523 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -45,7 +45,7 @@ jobs: if (-not $env:WINGET_TOKEN) { throw "WINGET_SUBMIT_TOKEN is not configured" } - $installerUrl = "https://github.com/Pythoughts-labs/pythinker-code/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" + $installerUrl = "https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" $wingetCreateSha256 = "8BD738851B524885410112678E3771B341C5C716DE60FBBECB88AB0A363ED85D" Invoke-WebRequest -Uri $wingetCreateUrl -OutFile wingetcreate.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b582da..c9b68e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Update all repository URLs and repo-slug constants to `Pythoughts-labs/pythinker-cli` after the GitHub repository rename (install scripts, updater, feedback default repo, docs, workflows, packaging). - Retry the post-update smoke check briefly before recording `VERIFICATION_FAILED`, absorbing the Homebrew launcher-relink race that falsely failed successful upgrades, and print the verification failure on screen instead of leaving "Updated successfully!" as the last word when verification genuinely fails. ## 0.62.0 (2026-07-22) @@ -88,7 +89,7 @@ GitHub Releases page; `0.8.0` is the new starting line. (`true` → `download`, `false` → `notify`); `pythinker info` now reports the mode string, and `/update auto` accepts the new mode names. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.59.0 (2026-07-17) @@ -100,7 +101,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, - **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. - **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.58.0 (2026-07-11) @@ -147,7 +148,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, recorded as failed (never blurred to not-applicable), and request-assembly telemetry no longer emits unbounded per-request token values as metric attributes. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.57.0 (2026-07-05) @@ -173,7 +174,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, later turns through a compact reminder until stopped by name or "normal mode". -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.56.0 (2026-07-02) @@ -192,7 +193,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, an older Homebrew install no longer leaves a permanent "Restart to apply" banner — the `/update` command surfaces again as expected. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.55.0 (2026-06-30) @@ -208,7 +209,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, regardless of whether each call reports success — catching loops where a tool falsely reports success on a call that never made progress. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.54.0 (2026-06-30) @@ -220,7 +221,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, structured-schema output per agent call, enforce an optional token budget, and require a single up-front approval rather than one per spawned subagent. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.53.0 (2026-06-23) @@ -326,7 +327,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, so brew can't delete the in-use Cellar version mid-session; the new build is staged side-by-side and goes live on restart. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.50.0 (2026-06-20) @@ -512,7 +513,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, version tracking for `didChange`, open-document state cleared on server restart, empty diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.47.0 (2026-06-16) @@ -602,7 +603,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, receive the goal, scope, expected output contract, and verification criteria; the Haiku-style tool-use summary from the upstream reference was deliberately not ported. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.46.0 (2026-06-14) @@ -610,7 +611,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, - **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected. - **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.45.0 (2026-06-14) @@ -699,7 +700,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, to `loop_control.max_truncation_recoveries` times per turn (default 3; `0` disables). pythinker-core now surfaces the provider's truncation signal so the loop can detect it. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.44.0 (2026-06-13) @@ -707,7 +708,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, - **Windows in-app update no longer shows a spurious "could not close the program" error.** The native installer now waits for the launching `pythinker.exe` to fully exit (its PID is passed via `/PID`) before its Restart Manager scan runs, so the scan no longer races the launcher's teardown into a false "close the program and retry" dialog. The update already succeeded in that case; now it completes cleanly without the alarming prompt. - **Simplified the Windows pip/uv/pipx update path.** Now that every shipped Windows install updates through the native installer, the Windows-only detached-spawn upgrade helper is removed; the remaining pip/uv/pipx path (a Windows source checkout, or any macOS/Linux install) runs the upgrade inline like POSIX, surfacing real command output and errors instead of a fire-and-forget process. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.43.0 (2026-06-13) @@ -725,7 +726,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, - **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. - **Error telemetry is emitted at ERROR severity.** `error`/`crash`/`api_error` events were forwarded to OpenTelemetry logs at INFO, so severity filters and the error saved-views never matched them; they now map to ERROR (`session_load_failed` to WARN) with canonical `error.*` attributes, and a startup crash before the telemetry sink attaches is still flushed. Opt-out telemetry behavior is unchanged. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.42.0 (2026-06-12) @@ -746,7 +747,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, - **Inline `/command` references get acted on, not just explained away.** When a message mentions a slash command mid-sentence (e.g. "your `/goal` today is to `/plan` and build the page"), the command doesn't auto-run — but the agent no longer leads its reply by reporting it as failed. The per-turn reminder and the system prompt now steer the agent to act on the intent: call the real `EnterPlanMode` tool for `/plan` (clarified as a genuine, callable tool so models stop doubting it exists), pursue the described objective for `/goal`, load `/skill:` via `ReadSkill`, and apply equivalent guidance for other commands — only surfacing how to invoke the literal command when genuinely needed. - **The "thinking" shimmer no longer runs while a foreground command does.** When the agent started a long-running foreground process — a dev server via `npm`/`docker`, a watch task — the shimmering verb spinner ("Working…/Thinking…") kept animating for the whole turn, implying the agent was busy when it was really just awaiting the subprocess. The spinner is now suppressed while any foreground tool is mid-execution; the tool card's own animated running marker (and its streaming output) carries the liveness, so the shimmer means "the agent is thinking" again and reappears the moment the command returns. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.41.0 (2026-06-11) @@ -765,14 +766,14 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, - **TUI polish.** No more transient red `` flash while tool-call arguments stream (incomplete `None`-valued keys are dropped before rendering); flicker-free streaming on terminals with synchronized output (DEC mode 2026, capability-gated, `PYTHINKER_NO_SYNC_OUTPUT=1` kill switch); slash commands ghost-complete inline with Tab to accept; finished tool-call rows are monotonic (a late or duplicated wire event can't flip a failed row to successful); shell error briefs show the trailing output of a failed command as plain text; and the cursor-position probe can no longer leave the terminal wedged in raw mode on exit. - **`compact_prompt` config override.** A new optional top-level config key replaces the built-in compaction summarization prompt for both manual and automatic compaction; a `/compact` focus argument is still appended on top, and leaving it unset preserves current behavior. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.1 (2026-06-10) - **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404. - **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.0 (2026-06-10) @@ -784,7 +785,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, - **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept. - **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.39.0 (2026-06-09) @@ -797,7 +798,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, - **The agent sets up and removes MCP servers on request instead of refusing.** Asked to add, remove, or set up an MCP server, the default agent now knows it runs in Pythinker: it configures the server with the `pythinker mcp add`/`remove` CLI (or by editing `~/.pythinker/mcp.json` / `./.pythinker/mcp.json`), verifies with `pythinker mcp list`/`test`, and tells you to restart or `/reload` to load the change — rather than refusing or citing Claude Code/Desktop config paths (`~/.claude.json`) it cannot use. The prompt now also hard-steers the agent away from writing `mcpServers` into `~/.pythinker/config.yaml` (YAML is never parsed for MCP, so such an entry is silently dropped and the server never appears in `/mcp`). As a backstop, MCP config loading now logs a warning when it finds an `mcpServers` block in a `config.yaml` (global or project), so a human or agent that misplaces it gets a diagnosable trace instead of a silent drop. - **Security: dependency vulnerability remediation.** Cleared the open Dependabot advisories across all manifests. Python: `asyncssh` 2.22.0 → 2.23.0 (path-traversal in `AuthorizedKeysFile %u`) in the `pythinker-host` pin and both lockfiles, and `starlette` 1.0.0 → 1.2.1 (Host-header path poisoning). JS: regenerated the `web`, `vis`, and `install-counter-worker` lockfiles and bumped the worker's `vitest` to `^3.2.6` (critical Vitest UI arbitrary file read/exec), clearing all critical/high/moderate advisories. The only residual is a handful of low-severity transitive `elliptic`/`bn.js` advisories in `web`'s browser crypto polyfill chain, left unforced because the fix downgrades `vite-plugin-node-polyfills` and majors `ai`, breaking the build for marginal benefit. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.38.0 (2026-06-08) @@ -806,7 +807,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, - **Live model pricing from models.dev.** Cost estimates in `/usage` and the session stats panel now pull per-model input/output pricing from the models.dev catalog (fetched once and cached for 24 hours), improving cost accuracy across providers. - **More robust ripgrep resolution.** File search now verifies that a bundled `rg` binary can actually execute on the host platform and architecture before using it, falling back to a system or freshly downloaded ripgrep when the bundled one cannot run — fixing search failures on mismatched-architecture installs. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.37.0 (2026-06-07) @@ -817,7 +818,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, - **Recon-first `planner` subagent.** A new read-only `planner` built-in agent type decomposes open-ended tasks into distinct parallel seed descriptions emitted as `` JSON, enabling structured fan-out before parallel workers start. - **Coder artifact contract.** The `coder` subagent now emits a `` JSON block at the end of every response, providing structured handoff data (`files_changed`, `test_command`, `expected_behavior`, optional `edge_cases_claimed`) that the `verifier` subagent can consume directly. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.36.0 (2026-06-05) @@ -825,7 +826,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, - **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.35.0 (2026-06-04) @@ -914,7 +915,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.29.0`, ### What changed in this release -- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-code`; existing configs that still reference the previous owner are auto-migrated to the new default. +- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-cli`; existing configs that still reference the previous owner are auto-migrated to the new default. - **Broadened distribution channels for releases.** Releases now include best-effort Docker/GHCR, Scoop, Nix, and manual WinGet distribution plumbing with channel-native update markers where the installer format supports them. - **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Node.js 24 CI with pinned, credential-checked workflows.** CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. @@ -993,7 +994,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.25.0`, - **Complete native installer `Fetch` fix.** The 0.23.0 release bundled trafilatura's data files but missed `justext`'s stoplists directory, so the `Fetch` tool still crashed with `FileNotFoundError: ./_MEIxxxx/justext/stoplists` on `.exe`, `.deb`, and `.rpm` installs. All three installer specs now bundle both `trafilatura` and `justext` data files. PyPI / `pip install` was unaffected. - **Atomic "latest" release gating.** The GitHub Release is no longer marked "latest" until every platform asset (4 archives, 1 `.exe`, 4 `.deb`/`.rpm`) is attached. A dispatch workflow polls for completeness before flipping the flag, so `/releases/latest` and the in-app updater no longer serve a partially-built release during the publish window. - **Smarter `/update` command.** `run_update_prompt` now routes through `do_update(check_only=True)` to get a fresh PyPI version before showing the update modal, and verifies that the expected binary asset for your platform exists on the GitHub Release before initiating a native upgrade. -- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-code`. +- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-cli`. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.24.0`, or use the native installer for your OS (see the README install table). @@ -1107,7 +1108,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.14.0`, ### What changed in this release - **Native installers for macOS and Linux.** `brew install Pythoughts-labs/pythinker/pythinker-code` (Homebrew tap) covers both macOS (Intel + Apple Silicon) and Linux brew installs from a single auto-generated formula. Debian/Ubuntu users get `pythinker-code_x.y.z_.deb` and Fedora/RHEL/openSUSE users get `pythinker-code-x.y.z..rpm`, both attached to every GitHub Release for `x86_64` and `aarch64`. Together with the Windows `PythinkerSetup-x.y.z.exe` shipped in 0.12.0, Pythinker now ships native installers for every supported platform — no Python, Node, or `uv` prerequisite. -- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. +- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. - **Homebrew tap auto-published on every release.** A new `.github/workflows/homebrew-tap.yml` waits for the PyPI publish to land, runs an in-tree formula generator (replaces the unmaintained `homebrew-pypi-poet` — see release notes for the why), and pushes `Formula/pythinker-code.rb` to the `homebrew-pythinker` tap repo. 132 transitive deps are enumerated automatically — no hand-curation per release. - **Tag-triggered Linux package pipeline.** A new `.github/workflows/linux-installer.yml` matrix-builds `.deb` and `.rpm` for `x86_64` and `aarch64` (the latter via QEMU on `ubuntu-latest`), then uploads all six artifacts to the GitHub Release via `softprops/action-gh-release@v2`. - **Frozen-binary data-files fix.** The PyInstaller specs for both the Windows and Linux pipelines now call `collect_data_files(pkg, include_py_files=False)` per package, so `pythinker_code/prompts/*.md`, `agents/default/*.yaml`, `tools/*/description.md`, `skills/*/SKILL.md`, and similar package resources are bundled into `_internal/`. Without this fix the frozen binary crashed the first time it tried to load `init.md` or an agent yaml. The Windows installer that shipped in 0.12.0 is affected; users on that build should upgrade to 0.13.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f76f42b..1490fe57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for being interested in contributing to Pythinker CLI! We welcome all kinds of contributions, including bug fixes, features, document improvements, typo fixes, etc. To maintain a high-quality codebase and user experience, we provide the following guidelines for contributions: -1. We only merge pull requests that align with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/Pythoughts-labs/pythinker-code/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review. +1. We only merge pull requests that align with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/Pythoughts-labs/pythinker-cli/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review. 2. We insist on high code quality. Please ensure your code is as good as, if not better than, the code written by frontier coding agents. Changes may be requested before your pull request can be merged. ## Prek hooks diff --git a/README.md b/README.md index b1b854c9..5b3ae028 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# Pythinker logo Pythinker Code +# Pythinker logo Pythinker Code ### *Think first, then code. Your terminal-native review-first AI engineering agent.* @@ -11,12 +11,12 @@ [![PyPI](https://img.shields.io/pypi/v/pythinker-code?style=for-the-badge&logo=pypi&logoColor=white&color=2563eb&label=pythinker-code)](https://pypi.org/project/pythinker-code/) [![Downloads](https://img.shields.io/pepy/dt/pythinker-code?style=for-the-badge&logo=python&logoColor=white&color=f97316&label=downloads)](https://pepy.tech/projects/pythinker-code) -[![License](https://img.shields.io/badge/License-Apache--2.0-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) +[![License](https://img.shields.io/badge/License-Apache--2.0-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/LICENSE) [![Ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg?style=for-the-badge&logo=ruff&logoColor=d7ff64)](https://docs.astral.sh/ruff/) [![Homepage](https://img.shields.io/badge/home-pythinker.com-ec4899.svg?style=for-the-badge&logo=globe&logoColor=white)](https://pythinker.com) -[![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-code/ci-pythinker-cli.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/actions/workflows/ci-pythinker-cli.yml?query=branch%3Amain) -[![Python](https://img.shields.io/badge/Python-3.12%2B-3776ab?style=for-the-badge&logo=python&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/pyproject.toml) +[![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-cli/ci-pythinker-cli.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-cli/actions/workflows/ci-pythinker-cli.yml?query=branch%3Amain) +[![Python](https://img.shields.io/badge/Python-3.12%2B-3776ab?style=for-the-badge&logo=python&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/pyproject.toml)
@@ -30,7 +30,7 @@

-Pythinker Code terminal demo +Pythinker Code terminal demo
@@ -53,7 +53,7 @@ It speaks the [**Agent Client Protocol (ACP)**](https://github.com/agentclientpr - **Updates surface during long-running sessions.** Pythinker now checks periodically for newly available releases, shows each new update notice once per session, and bounds every check with a watchdog so a stalled attempt cannot prevent later retries. - **Repository-local agent workflows stay local.** This checkout now treats its root `.agents/` directory as local agent configuration instead of version-controlled project content. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.62.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.62.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). --- @@ -147,7 +147,7 @@ Run trusted local JSONL fixtures with `/benchmark:swe --dataset --trusted > Built-in shell commands such as `cd` are not yet supported in shell command mode.
-Shell command mode demo +Shell command mode demo
--- @@ -159,13 +159,13 @@ matches your OS — no Python, Node, or `uv` prerequisite. | Platform | Recommended install | Artifact source | |---|---|---| -| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.62.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | -| **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | +| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.62.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | +| **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | | **macOS — Homebrew** | `brew install Pythoughts-labs/pythinker/pythinker-code` | auto-published Homebrew tap | | **🐳 Docker** | `docker run --rm -it ghcr.io/pythoughts-labs/pythinker-code` | GHCR multi-arch image | | **🪟 Windows — Scoop** | `scoop bucket add pythinker https://github.com/Pythoughts-labs/scoop-pythinker && scoop install pythinker-code` | auto-published Scoop bucket | -| **❄️ Nix** | `nix run github:Pythoughts-labs/pythinker-code` | flake `apps.default` | -| **Linux — system package** | Download the `.deb` or `.rpm` for your distro below | [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | +| **❄️ Nix** | `nix run github:Pythoughts-labs/pythinker-cli` | flake `apps.default` | +| **Linux — system package** | Download the `.deb` or `.rpm` for your distro below | [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | | **🐍 Python fallback** | `pip install pythinker-code` | PyPI | Every artifact ships with a matching `.sha256` file — verify before install on @@ -270,8 +270,8 @@ sudo apt-get install -f # only if dpkg reports missing deps sudo dpkg -i pythinker-code_0.62.0_arm64.deb # Fedora / RHEL / openSUSE (x86_64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.x86_64.rpm.sha256 # Fedora / RHEL: sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm @@ -279,8 +279,8 @@ sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm sudo zypper install ./pythinker-code-0.62.0.x86_64.rpm # Fedora / RHEL (aarch64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.aarch64.rpm.sha256 sudo dnf install ./pythinker-code-0.62.0.aarch64.rpm ``` @@ -373,7 +373,7 @@ pythinker login ```
-Pythinker login provider picker +Pythinker login provider picker
Z.AI exposes two independent OpenAI-compatible routes. Choose the route that owns your key; @@ -582,7 +582,7 @@ The ACP server provides: | 🔄 **Hot model swap** | Change models for a running ACP session |
-ACP IDE integration demo +ACP IDE integration demo
--- @@ -658,7 +658,7 @@ Pythinker is a small, extensible runtime — not a monolith. Build on it. ## 🏗️ Architecture
-Pythinker Code architecture diagram +Pythinker Code architecture diagram
--- @@ -738,7 +738,7 @@ are used automatically unless you turn telemetry off. ### 🏁 Prepare the workspace ```sh -git clone https://github.com/Pythoughts-labs/pythinker-code.git +git clone https://github.com/Pythoughts-labs/pythinker-cli.git cd pythinker-code make prepare ``` @@ -819,9 +819,9 @@ pythinker-code/ Contributions are warmly welcome — bug reports, PRs, plugins, skills, and docs all help. -- 📖 Start with [`CONTRIBUTING.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) -- 🔐 See [`SECURITY.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/SECURITY.md) for responsible disclosure -- 📜 Skim [`AGENTS.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/AGENTS.md) for the agent design notes +- 📖 Start with [`CONTRIBUTING.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md) +- 🔐 See [`SECURITY.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/SECURITY.md) for responsible disclosure +- 📜 Skim [`AGENTS.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/AGENTS.md) for the agent design notes If Pythinker helps you, **a ⭐ on GitHub goes a long way.** @@ -829,7 +829,7 @@ If Pythinker helps you, **a ⭐ on GitHub goes a long way.** ## 📜 License -Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) for the full text and [`NOTICE`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/NOTICE) for attributions. +Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/LICENSE) for the full text and [`NOTICE`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/NOTICE) for attributions.
@@ -839,7 +839,7 @@ Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com [🌐 pythinker.com](https://pythinker.com)  ·  [📦 PyPI](https://pypi.org/project/pythinker-code/)  ·  -[🐙 GitHub](https://github.com/Pythoughts-labs/pythinker-code)  ·  +[🐙 GitHub](https://github.com/Pythoughts-labs/pythinker-cli)  ·  [🧩 ACP](https://github.com/agentclientprotocol/agent-client-protocol)  ·  [🔌 MCP](https://modelcontextprotocol.io/) diff --git a/SECURITY.md b/SECURITY.md index 10aabe71..a4180601 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,4 +6,4 @@ Currently, Pythinker CLI only provides security support for the latest version. ## Reporting a Vulnerability -Please report a vulnerability via the [Pythoughts-labs/pythinker-code - Security](https://github.com/Pythoughts-labs/pythinker-code/security) page, or open an [issue](https://github.com/Pythoughts-labs/pythinker-code/issues) if it can be published publicly. +Please report a vulnerability via the [Pythoughts-labs/pythinker-cli - Security](https://github.com/Pythoughts-labs/pythinker-cli/security) page, or open an [issue](https://github.com/Pythoughts-labs/pythinker-cli/issues) if it can be published publicly. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 74f2bafd..a9d94a52 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -112,7 +112,7 @@ export default withMermaid(defineConfig({ outline: [2, 3], search: { provider: 'local' }, socialLinks: [ - { icon: 'github', link: 'https://github.com/Pythoughts-labs/pythinker-code' }, + { icon: 'github', link: 'https://github.com/Pythoughts-labs/pythinker-cli' }, ], }, diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index 7bfc6893..e6d157ef 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -17,7 +17,7 @@ Pythinker Code supports the following usage modes: - **[Agent integration (`pythinker acp`)](../reference/pythinker-acp.md)**: Run as a service and integrate with [IDEs](./ides.md) and other local agent clients via the [Agent Client Protocol] ::: info Tip -If you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/Pythoughts-labs/pythinker-code/issues). +If you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/Pythoughts-labs/pythinker-cli/issues). ::: [Agent Client Protocol]: https://agentclientprotocol.com/ @@ -44,7 +44,7 @@ On Windows, run the PowerShell bootstrap. It downloads the native installer, ver irm https://pythinker.com/install.ps1 | iex ``` -You can also download `PythinkerSetup-0.62.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +You can also download `PythinkerSetup-0.62.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). Verify the installation: diff --git a/docs/en/index.md b/docs/en/index.md index c3a2273a..13c5645c 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -10,5 +10,5 @@ hero: link: /en/guides/getting-started - theme: alt text: GitHub - link: https://github.com/Pythoughts-labs/pythinker-code + link: https://github.com/Pythoughts-labs/pythinker-cli --- diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 3b669945..ef0399a7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -88,7 +88,7 @@ GitHub Releases page; `0.8.0` is the new starting line. (`true` → `download`, `false` → `notify`); `pythinker info` now reports the mode string, and `/update auto` accepts the new mode names. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.59.0 (2026-07-17) @@ -100,7 +100,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, - **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. - **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.58.0 (2026-07-11) @@ -147,7 +147,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, recorded as failed (never blurred to not-applicable), and request-assembly telemetry no longer emits unbounded per-request token values as metric attributes. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.57.0 (2026-07-05) @@ -173,7 +173,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, later turns through a compact reminder until stopped by name or "normal mode". -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.56.0 (2026-07-02) @@ -192,7 +192,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, an older Homebrew install no longer leaves a permanent "Restart to apply" banner — the `/update` command surfaces again as expected. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.55.0 (2026-06-30) @@ -208,7 +208,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, regardless of whether each call reports success — catching loops where a tool falsely reports success on a call that never made progress. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.54.0 (2026-06-30) @@ -220,7 +220,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, structured-schema output per agent call, enforce an optional token budget, and require a single up-front approval rather than one per spawned subagent. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.53.0 (2026-06-23) @@ -326,7 +326,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, so brew can't delete the in-use Cellar version mid-session; the new build is staged side-by-side and goes live on restart. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.50.0 (2026-06-20) @@ -512,7 +512,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, version tracking for `didChange`, open-document state cleared on server restart, empty diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.47.0 (2026-06-16) @@ -602,7 +602,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, receive the goal, scope, expected output contract, and verification criteria; the Haiku-style tool-use summary from the upstream reference was deliberately not ported. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.46.0 (2026-06-14) @@ -610,7 +610,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, - **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected. - **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.45.0 (2026-06-14) @@ -699,7 +699,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, to `loop_control.max_truncation_recoveries` times per turn (default 3; `0` disables). pythinker-core now surfaces the provider's truncation signal so the loop can detect it. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.44.0 (2026-06-13) @@ -707,7 +707,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, - **Windows in-app update no longer shows a spurious "could not close the program" error.** The native installer now waits for the launching `pythinker.exe` to fully exit (its PID is passed via `/PID`) before its Restart Manager scan runs, so the scan no longer races the launcher's teardown into a false "close the program and retry" dialog. The update already succeeded in that case; now it completes cleanly without the alarming prompt. - **Simplified the Windows pip/uv/pipx update path.** Now that every shipped Windows install updates through the native installer, the Windows-only detached-spawn upgrade helper is removed; the remaining pip/uv/pipx path (a Windows source checkout, or any macOS/Linux install) runs the upgrade inline like POSIX, surfacing real command output and errors instead of a fire-and-forget process. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.43.0 (2026-06-13) @@ -725,7 +725,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, - **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. - **Error telemetry is emitted at ERROR severity.** `error`/`crash`/`api_error` events were forwarded to OpenTelemetry logs at INFO, so severity filters and the error saved-views never matched them; they now map to ERROR (`session_load_failed` to WARN) with canonical `error.*` attributes, and a startup crash before the telemetry sink attaches is still flushed. Opt-out telemetry behavior is unchanged. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.42.0 (2026-06-12) @@ -746,7 +746,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, - **Inline `/command` references get acted on, not just explained away.** When a message mentions a slash command mid-sentence (e.g. "your `/goal` today is to `/plan` and build the page"), the command doesn't auto-run — but the agent no longer leads its reply by reporting it as failed. The per-turn reminder and the system prompt now steer the agent to act on the intent: call the real `EnterPlanMode` tool for `/plan` (clarified as a genuine, callable tool so models stop doubting it exists), pursue the described objective for `/goal`, load `/skill:` via `ReadSkill`, and apply equivalent guidance for other commands — only surfacing how to invoke the literal command when genuinely needed. - **The "thinking" shimmer no longer runs while a foreground command does.** When the agent started a long-running foreground process — a dev server via `npm`/`docker`, a watch task — the shimmering verb spinner ("Working…/Thinking…") kept animating for the whole turn, implying the agent was busy when it was really just awaiting the subprocess. The spinner is now suppressed while any foreground tool is mid-execution; the tool card's own animated running marker (and its streaming output) carries the liveness, so the shimmer means "the agent is thinking" again and reappears the moment the command returns. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.41.0 (2026-06-11) @@ -765,14 +765,14 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, - **TUI polish.** No more transient red `` flash while tool-call arguments stream (incomplete `None`-valued keys are dropped before rendering); flicker-free streaming on terminals with synchronized output (DEC mode 2026, capability-gated, `PYTHINKER_NO_SYNC_OUTPUT=1` kill switch); slash commands ghost-complete inline with Tab to accept; finished tool-call rows are monotonic (a late or duplicated wire event can't flip a failed row to successful); shell error briefs show the trailing output of a failed command as plain text; and the cursor-position probe can no longer leave the terminal wedged in raw mode on exit. - **`compact_prompt` config override.** A new optional top-level config key replaces the built-in compaction summarization prompt for both manual and automatic compaction; a `/compact` focus argument is still appended on top, and leaving it unset preserves current behavior. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.1 (2026-06-10) - **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404. - **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.0 (2026-06-10) @@ -784,7 +784,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, - **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept. - **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.39.0 (2026-06-09) @@ -797,7 +797,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, - **The agent sets up and removes MCP servers on request instead of refusing.** Asked to add, remove, or set up an MCP server, the default agent now knows it runs in Pythinker: it configures the server with the `pythinker mcp add`/`remove` CLI (or by editing `~/.pythinker/mcp.json` / `./.pythinker/mcp.json`), verifies with `pythinker mcp list`/`test`, and tells you to restart or `/reload` to load the change — rather than refusing or citing Claude Code/Desktop config paths (`~/.claude.json`) it cannot use. The prompt now also hard-steers the agent away from writing `mcpServers` into `~/.pythinker/config.yaml` (YAML is never parsed for MCP, so such an entry is silently dropped and the server never appears in `/mcp`). As a backstop, MCP config loading now logs a warning when it finds an `mcpServers` block in a `config.yaml` (global or project), so a human or agent that misplaces it gets a diagnosable trace instead of a silent drop. - **Security: dependency vulnerability remediation.** Cleared the open Dependabot advisories across all manifests. Python: `asyncssh` 2.22.0 → 2.23.0 (path-traversal in `AuthorizedKeysFile %u`) in the `pythinker-host` pin and both lockfiles, and `starlette` 1.0.0 → 1.2.1 (Host-header path poisoning). JS: regenerated the `web`, `vis`, and `install-counter-worker` lockfiles and bumped the worker's `vitest` to `^3.2.6` (critical Vitest UI arbitrary file read/exec), clearing all critical/high/moderate advisories. The only residual is a handful of low-severity transitive `elliptic`/`bn.js` advisories in `web`'s browser crypto polyfill chain, left unforced because the fix downgrades `vite-plugin-node-polyfills` and majors `ai`, breaking the build for marginal benefit. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.38.0 (2026-06-08) @@ -806,7 +806,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, - **Live model pricing from models.dev.** Cost estimates in `/usage` and the session stats panel now pull per-model input/output pricing from the models.dev catalog (fetched once and cached for 24 hours), improving cost accuracy across providers. - **More robust ripgrep resolution.** File search now verifies that a bundled `rg` binary can actually execute on the host platform and architecture before using it, falling back to a system or freshly downloaded ripgrep when the bundled one cannot run — fixing search failures on mismatched-architecture installs. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.37.0 (2026-06-07) @@ -817,7 +817,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, - **Recon-first `planner` subagent.** A new read-only `planner` built-in agent type decomposes open-ended tasks into distinct parallel seed descriptions emitted as `` JSON, enabling structured fan-out before parallel workers start. - **Coder artifact contract.** The `coder` subagent now emits a `` JSON block at the end of every response, providing structured handoff data (`files_changed`, `test_command`, `expected_behavior`, optional `edge_cases_claimed`) that the `verifier` subagent can consume directly. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.36.0 (2026-06-05) @@ -825,7 +825,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, - **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.35.0 (2026-06-04) @@ -914,7 +914,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.29.0`, ### What changed in this release -- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-code`; existing configs that still reference the previous owner are auto-migrated to the new default. +- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-cli`; existing configs that still reference the previous owner are auto-migrated to the new default. - **Broadened distribution channels for releases.** Releases now include best-effort Docker/GHCR, Scoop, Nix, and manual WinGet distribution plumbing with channel-native update markers where the installer format supports them. - **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Node.js 24 CI with pinned, credential-checked workflows.** CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. @@ -993,7 +993,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.25.0`, - **Complete native installer `Fetch` fix.** The 0.23.0 release bundled trafilatura's data files but missed `justext`'s stoplists directory, so the `Fetch` tool still crashed with `FileNotFoundError: ./_MEIxxxx/justext/stoplists` on `.exe`, `.deb`, and `.rpm` installs. All three installer specs now bundle both `trafilatura` and `justext` data files. PyPI / `pip install` was unaffected. - **Atomic "latest" release gating.** The GitHub Release is no longer marked "latest" until every platform asset (4 archives, 1 `.exe`, 4 `.deb`/`.rpm`) is attached. A dispatch workflow polls for completeness before flipping the flag, so `/releases/latest` and the in-app updater no longer serve a partially-built release during the publish window. - **Smarter `/update` command.** `run_update_prompt` now routes through `do_update(check_only=True)` to get a fresh PyPI version before showing the update modal, and verifies that the expected binary asset for your platform exists on the GitHub Release before initiating a native upgrade. -- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-code`. +- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-cli`. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.24.0`, or use the native installer for your OS (see the README install table). @@ -1107,7 +1107,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.14.0`, ### What changed in this release - **Native installers for macOS and Linux.** `brew install Pythoughts-labs/pythinker/pythinker-code` (Homebrew tap) covers both macOS (Intel + Apple Silicon) and Linux brew installs from a single auto-generated formula. Debian/Ubuntu users get `pythinker-code_x.y.z_.deb` and Fedora/RHEL/openSUSE users get `pythinker-code-x.y.z..rpm`, both attached to every GitHub Release for `x86_64` and `aarch64`. Together with the Windows `PythinkerSetup-x.y.z.exe` shipped in 0.12.0, Pythinker now ships native installers for every supported platform — no Python, Node, or `uv` prerequisite. -- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. +- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. - **Homebrew tap auto-published on every release.** A new `.github/workflows/homebrew-tap.yml` waits for the PyPI publish to land, runs an in-tree formula generator (replaces the unmaintained `homebrew-pypi-poet` — see release notes for the why), and pushes `Formula/pythinker-code.rb` to the `homebrew-pythinker` tap repo. 132 transitive deps are enumerated automatically — no hand-curation per release. - **Tag-triggered Linux package pipeline.** A new `.github/workflows/linux-installer.yml` matrix-builds `.deb` and `.rpm` for `x86_64` and `aarch64` (the latter via QEMU on `ubuntu-latest`), then uploads all six artifacts to the GitHub Release via `softprops/action-gh-release@v2`. - **Frozen-binary data-files fix.** The PyInstaller specs for both the Windows and Linux pipelines now call `collect_data_files(pkg, include_py_files=False)` per package, so `pythinker_code/prompts/*.md`, `agents/default/*.yaml`, `tools/*/description.md`, `skills/*/SKILL.md`, and similar package resources are bundled into `_internal/`. Without this fix the frozen binary crashed the first time it tried to load `init.md` or an agent yaml. The Windows installer that shipped in 0.12.0 is affected; users on that build should upgrade to 0.13.0. diff --git a/docs/history/CHANGELOG-pre-0.8.0.md b/docs/history/CHANGELOG-pre-0.8.0.md index 23903a4c..3377be95 100644 --- a/docs/history/CHANGELOG-pre-0.8.0.md +++ b/docs/history/CHANGELOG-pre-0.8.0.md @@ -22,7 +22,7 @@ Packaging fix: pin `pythinker-core[contrib]==1.1.0` so the Kimi K2.x / DeepSeek ### Why 2.6.0 lands the same day as 2.5.0 -The runtime fix for the strict-interleaved `thinking is enabled but reasoning_content is missing in assistant tool call message at index N` rejection landed in the `pythinker-core` source tree on 2026-05-11 (released in `pythinker-code` 2.4.0 source), but **the published `pythinker-core==1.0.0` on PyPI predates that change** (uploaded 2026-05-07). `pythinker-code` 2.4.0 and 2.5.0 both pinned `pythinker-core[contrib]==1.0.0`, so PyPI users on Kimi K2.5 / K2.6 / DeepSeek through OpenCode Go (and elsewhere) kept hitting the bug even on the latest CLI. Reported in [#37](https://github.com/Pythoughts-labs/pythinker-code/issues/37). +The runtime fix for the strict-interleaved `thinking is enabled but reasoning_content is missing in assistant tool call message at index N` rejection landed in the `pythinker-core` source tree on 2026-05-11 (released in `pythinker-code` 2.4.0 source), but **the published `pythinker-core==1.0.0` on PyPI predates that change** (uploaded 2026-05-07). `pythinker-code` 2.4.0 and 2.5.0 both pinned `pythinker-core[contrib]==1.0.0`, so PyPI users on Kimi K2.5 / K2.6 / DeepSeek through OpenCode Go (and elsewhere) kept hitting the bug even on the latest CLI. Reported in [#37](https://github.com/Pythoughts-labs/pythinker-cli/issues/37). ### The fix diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 index 146ded20..a118158b 100644 --- a/docs/public/install.ps1 +++ b/docs/public/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/docs/public/install.sh b/docs/public/install.sh index 21f0c70f..f7f0944d 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' diff --git a/examples/feedback-worker/wrangler.jsonc b/examples/feedback-worker/wrangler.jsonc index 39880062..341fb0f2 100644 --- a/examples/feedback-worker/wrangler.jsonc +++ b/examples/feedback-worker/wrangler.jsonc @@ -10,7 +10,7 @@ } ], "vars": { - "GITHUB_REPO": "Pythoughts-labs/pythinker-code", + "GITHUB_REPO": "Pythoughts-labs/pythinker-cli", "GITHUB_LABELS": "feedback,pythinker-cli", "SUPPORT_EMAIL": "support@pythinker.com", "FROM_EMAIL": "Pythinker Feedback " diff --git a/packages/homebrew-tap/generate-formula.py b/packages/homebrew-tap/generate-formula.py index 566a8547..00bcd925 100644 --- a/packages/homebrew-tap/generate-formula.py +++ b/packages/homebrew-tap/generate-formula.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" diff --git a/packages/linux-installer/README.md b/packages/linux-installer/README.md index 2370d461..8b4d1abf 100644 --- a/packages/linux-installer/README.md +++ b/packages/linux-installer/README.md @@ -8,15 +8,15 @@ End-user install from the current GitHub Release: ```sh # Debian / Ubuntu -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb.sha256 sha256sum -c pythinker-code_0.62.0_amd64.deb.sha256 sudo dpkg -i pythinker-code_0.62.0_amd64.deb sudo apt-get install -f # only needed if dependencies fail to resolve # Fedora / RHEL / openSUSE -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.x86_64.rpm.sha256 sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm # or, on openSUSE: diff --git a/packages/linux-installer/build.sh b/packages/linux-installer/build.sh index fc367263..cf44efc3 100755 --- a/packages/linux-installer/build.sh +++ b/packages/linux-installer/build.sh @@ -87,7 +87,7 @@ FPM_COMMON=( -v "$VERSION" --license "Apache-2.0" --maintainer "Pythinker " - --url "https://github.com/Pythoughts-labs/pythinker-code" + --url "https://github.com/Pythoughts-labs/pythinker-cli" --description "Pythinker Code: terminal-native review-first AI engineering agent." --vendor "Pythinker" ) diff --git a/packages/pythinker-core/CHANGELOG.md b/packages/pythinker-core/CHANGELOG.md index 70c8353b..170dfddf 100644 --- a/packages/pythinker-core/CHANGELOG.md +++ b/packages/pythinker-core/CHANGELOG.md @@ -16,7 +16,7 @@ pythinker-core 1.1.0 depends on mcp<1.17 and >=1 Strict-interleaved reasoning replay for Kimi K2.x and DeepSeek, plus the SDK 0.101 anthropic compat fix. -- **`contrib.chat_provider.openai_legacy._convert_message`** — for strict-interleaved providers (`kimi-k2*`, `deepseek*`) now **always** emits `reasoning_content` on assistant turns, even when no `ThinkPart` was captured. Falls back to the assistant text, then to `"[reasoning unavailable]"` so Kimi-style "thinking is enabled but reasoning_content is missing in assistant tool call message at index N" rejections no longer trip multi-step tool flows. Fixes pythinker-code [#37](https://github.com/Pythoughts-labs/pythinker-code/issues/37). +- **`contrib.chat_provider.openai_legacy._convert_message`** — for strict-interleaved providers (`kimi-k2*`, `deepseek*`) now **always** emits `reasoning_content` on assistant turns, even when no `ThinkPart` was captured. Falls back to the assistant text, then to `"[reasoning unavailable]"` so Kimi-style "thinking is enabled but reasoning_content is missing in assistant tool call message at index N" rejections no longer trip multi-step tool flows. Fixes pythinker-code [#37](https://github.com/Pythoughts-labs/pythinker-cli/issues/37). - **`chat_provider.pythinker`** — same `reasoning_content` replay guarantee on the native pythinker provider for parity. - **`contrib.chat_provider.anthropic`** — added `case _:` fallbacks at the prompt-cache-injection site and the streaming content-block start site so the six new tool-result block types in anthropic SDK 0.101 (`web_fetch_tool_result`, `code_execution_tool_result`, `bash_code_execution_tool_result`, `text_editor_code_execution_tool_result`, `tool_search_tool_result`, `container_upload`) don't trip `pyright`'s exhaustive-match check. - No public API changes; minor patch contract — drop-in upgrade. diff --git a/packages/pythinker-core/pyproject.toml b/packages/pythinker-core/pyproject.toml index b0126220..0ed74c7e 100644 --- a/packages/pythinker-core/pyproject.toml +++ b/packages/pythinker-core/pyproject.toml @@ -38,10 +38,10 @@ contrib = [ ] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/packages/pythinker-core/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/packages/pythinker-core/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py index d0f63f15..ead67266 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py @@ -145,7 +145,7 @@ async def generate( # Auto-enable reasoning_effort when the history contains ThinkPart but reasoning # was not explicitly configured. This prevents server validation errors from APIs # (e.g. One API) that require reasoning_effort when messages contain reasoning_content. - # See: https://github.com/Pythoughts-labs/pythinker-code/issues/1616 + # See: https://github.com/Pythoughts-labs/pythinker-cli/issues/1616 if ( self._auto_reasoning_effort and isinstance(reasoning_effort, Omit) diff --git a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py index dffeb998..c72aaee9 100644 --- a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py +++ b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py @@ -315,7 +315,7 @@ async def test_openai_legacy_auto_reasoning_effort_when_history_has_think_part() """When reasoning_effort is not set but history contains ThinkPart and reasoning_key is configured, reasoning_effort should be auto-set to avoid server validation errors. - Reproduces: https://github.com/Pythoughts-labs/pythinker-code/issues/1616 + Reproduces: https://github.com/Pythoughts-labs/pythinker-cli/issues/1616 """ with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( diff --git a/packages/pythinker-host/pyproject.toml b/packages/pythinker-host/pyproject.toml index ea29114c..467ae434 100644 --- a/packages/pythinker-host/pyproject.toml +++ b/packages/pythinker-host/pyproject.toml @@ -26,10 +26,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/packages/pythinker-host/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/packages/pythinker-host/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/packages/pythinker-review/pyproject.toml b/packages/pythinker-review/pyproject.toml index 3f26c15c..cf04e68b 100644 --- a/packages/pythinker-review/pyproject.toml +++ b/packages/pythinker-review/pyproject.toml @@ -33,8 +33,8 @@ pythinker-debug = "pythinker_review.cli.debug:app" pythinker-security-scan = "pythinker_review.cli.security_scan:app" [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" [dependency-groups] dev = [ diff --git a/packages/pythinker-review/src/pythinker_review/output/sarif.py b/packages/pythinker-review/src/pythinker_review/output/sarif.py index c55bdc3c..78a83a81 100644 --- a/packages/pythinker-review/src/pythinker_review/output/sarif.py +++ b/packages/pythinker-review/src/pythinker_review/output/sarif.py @@ -68,7 +68,7 @@ def render_sarif(meta: RunMeta, findings: list[Finding]) -> str: "tool": { "driver": { "name": "pythinker-review", - "informationUri": "https://github.com/Pythoughts-labs/pythinker-code", + "informationUri": "https://github.com/Pythoughts-labs/pythinker-cli", "rules": list(rules_seen.values()), } }, diff --git a/packages/scoop-bucket/generate-manifest.py b/packages/scoop-bucket/generate-manifest.py index 3c186c56..e888278b 100644 --- a/packages/scoop-bucket/generate-manifest.py +++ b/packages/scoop-bucket/generate-manifest.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" diff --git a/packages/scoop-bucket/pythinker-code.json.tmpl b/packages/scoop-bucket/pythinker-code.json.tmpl index 0ba1e767..8861ae95 100644 --- a/packages/scoop-bucket/pythinker-code.json.tmpl +++ b/packages/scoop-bucket/pythinker-code.json.tmpl @@ -14,12 +14,12 @@ "PYTHINKER_MANAGED": "scoop" }, "checkver": { - "github": "https://github.com/Pythoughts-labs/pythinker-code" + "github": "https://github.com/Pythoughts-labs/pythinker-cli" }, "autoupdate": { "architecture": { "64bit": { - "url": "https://github.com/Pythoughts-labs/pythinker-code/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" + "url": "https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" } }, "hash": { diff --git a/packages/windows-installer/installer.iss b/packages/windows-installer/installer.iss index da4ae23e..eb71d236 100644 --- a/packages/windows-installer/installer.iss +++ b/packages/windows-installer/installer.iss @@ -11,8 +11,8 @@ AppName=Pythinker Code AppVersion={#AppVersion} AppPublisher=Pythinker AppPublisherURL=https://pythinker.com -AppSupportURL=https://github.com/Pythoughts-labs/pythinker-code/issues -AppUpdatesURL=https://github.com/Pythoughts-labs/pythinker-code/releases +AppSupportURL=https://github.com/Pythoughts-labs/pythinker-cli/issues +AppUpdatesURL=https://github.com/Pythoughts-labs/pythinker-cli/releases DefaultDirName={localappdata}\Programs\Pythinker DefaultGroupName=Pythinker DisableProgramGroupPage=yes diff --git a/pyproject.toml b/pyproject.toml index 9577af84..cbebc505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,11 +113,11 @@ pythinker = "pythinker_code.__main__:main" pythinker-code = "pythinker_code.__main__:main" [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/CHANGELOG.md" -Documentation = "https://github.com/Pythoughts-labs/pythinker-code#readme" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CHANGELOG.md" +Documentation = "https://github.com/Pythoughts-labs/pythinker-cli#readme" [tool.ruff] line-length = 100 diff --git a/scripts/install-native.sh b/scripts/install-native.sh index 21f0c70f..f7f0944d 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 146ded20..a118158b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/sdks/pythinker-sdk/pyproject.toml b/sdks/pythinker-sdk/pyproject.toml index 48abfe2a..bf38b3da 100644 --- a/sdks/pythinker-sdk/pyproject.toml +++ b/sdks/pythinker-sdk/pyproject.toml @@ -22,10 +22,10 @@ classifiers = [ dependencies = ["pythinker-core==1.2.0", "mcp>=1.23,<2", "httpx>=0.28.1"] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/sdks/pythinker-sdk/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/sdks/pythinker-sdk/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py index bb9a9779..92d0715b 100644 --- a/src/pythinker_code/feedback_repo.py +++ b/src/pythinker_code/feedback_repo.py @@ -1,6 +1,6 @@ from __future__ import annotations -DEFAULT_FEEDBACK_GITHUB_REPO = "Pythoughts-labs/pythinker-code" +DEFAULT_FEEDBACK_GITHUB_REPO = "Pythoughts-labs/pythinker-cli" _LEGACY_FEEDBACK_GITHUB_OWNER = "mohamed-elkholy95" _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = { diff --git a/src/pythinker_code/native.py b/src/pythinker_code/native.py index b8d9ec94..9ae8be61 100644 --- a/src/pythinker_code/native.py +++ b/src/pythinker_code/native.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" SENTINEL_FILENAME = ".pythinker-native" diff --git a/src/pythinker_code/skills/pythinker-code-help/SKILL.md b/src/pythinker_code/skills/pythinker-code-help/SKILL.md index 24e66102..f305aeaa 100644 --- a/src/pythinker_code/skills/pythinker-code-help/SKILL.md +++ b/src/pythinker_code/skills/pythinker-code-help/SKILL.md @@ -45,7 +45,7 @@ https://pythoughts-labs.github.io/pythinker-code/llms.txt ## Source Code -Repository: `https://github.com/Pythoughts-labs/pythinker-code` +Repository: `https://github.com/Pythoughts-labs/pythinker-cli` When to read source: diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 2e14d171..5ab7454d 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -733,7 +733,7 @@ def _fallback_to_issue(payload: dict[str, Any] | None = None) -> None: issue_url = ( build_feedback_issue_url(payload, soul.runtime.config.feedback.github_repo) if payload is not None and soul is not None - else "https://github.com/Pythoughts-labs/pythinker-code/issues/new/choose" + else "https://github.com/Pythoughts-labs/pythinker-cli/issues/new/choose" ) if open_url_in_browser(issue_url): console.print(f"[{_t_fb.success}]Opening GitHub feedback in your browser...[/]") @@ -860,7 +860,7 @@ async def report_error(app: Shell, args: str): _t_re = _get_tok_re() - ISSUE_URL = "https://github.com/Pythoughts-labs/pythinker-code/issues" + ISSUE_URL = "https://github.com/Pythoughts-labs/pythinker-cli/issues" def _fallback_to_issues() -> None: if not open_url_in_browser(ISSUE_URL): diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index fcd68195..ead5794e 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -52,7 +52,7 @@ from pythinker_code.utils.logging import logger from pythinker_code.utils.subprocess_env import get_clean_env -CHANGELOG_URL_EN = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/CHANGELOG.md" +CHANGELOG_URL_EN = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CHANGELOG.md" PYPI_VERSION_URL = "https://pypi.org/pypi/pythinker-code/{version}/json" HOMEBREW_FORMULA_URL = ( "https://raw.githubusercontent.com/Pythoughts-labs/homebrew-pythinker/" @@ -1735,7 +1735,7 @@ def _print(message: str) -> None: _print( f"[{_t.warning}]Auto-update disabled. " "Download the new installer manually from " - "https://github.com/Pythoughts-labs/pythinker-code/releases/latest[/]" + "https://github.com/Pythoughts-labs/pythinker-cli/releases/latest[/]" ) return UpdateResult.UPDATE_AVAILABLE if native_result is UpdateResult.FAILED: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index dbefd310..760a8752 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -103,7 +103,7 @@ def test_default_config_dump(): "api_key": None, "custom_headers": None, "github_client_id": "", - "github_repo": "Pythoughts-labs/pythinker-code", + "github_repo": "Pythoughts-labs/pythinker-cli", }, "hooks": [], "merge_all_available_skills": True, @@ -202,13 +202,13 @@ def test_load_config_migrates_legacy_feedback_repo_default(): old_owner = "mohamed-elkholy95" config = load_config_from_string(f'[feedback]\ngithub_repo = "{old_owner}/Pythinker-Code"\n') - assert config.feedback.github_repo == "Pythoughts-labs/pythinker-code" + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" def test_load_config_migrates_legacy_org_feedback_repo_default(): config = load_config_from_string('[feedback]\ngithub_repo = "TechMatrix-labs/pythinker-code"\n') - assert config.feedback.github_repo == "Pythoughts-labs/pythinker-code" + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" def test_agent_execution_profile_autonomous_sets_autonomy_defaults(): diff --git a/tests/core/test_simple_compaction.py b/tests/core/test_simple_compaction.py index 1382d06a..0a4e070a 100644 --- a/tests/core/test_simple_compaction.py +++ b/tests/core/test_simple_compaction.py @@ -249,8 +249,8 @@ def test_prepare_only_keeps_text_parts_in_compaction(): Non-text parts (media, think, etc.) are filtered out because the compaction API endpoint only supports text content. - Fixes: https://github.com/Pythoughts-labs/pythinker-code/issues/1395 - Fixes: https://github.com/Pythoughts-labs/pythinker-code/issues/1390 + Fixes: https://github.com/Pythoughts-labs/pythinker-cli/issues/1395 + Fixes: https://github.com/Pythoughts-labs/pythinker-cli/issues/1390 """ messages = [ Message( diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index 6a75fe70..ee181ca8 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -24,7 +24,7 @@ def test_windows_readme_documents_powershell_one_liner() -> None: assert "irm https://pythinker.com/install.ps1 \\| iex" in readme assert ( - "raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install.ps1" + "raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install.ps1" not in readme ) assert "-File $installer" not in readme @@ -38,7 +38,7 @@ def test_getting_started_uses_canonical_native_installer_url() -> None: assert "curl -fsSL https://pythinker.com/install.sh | bash" in guide assert "irm https://pythinker.com/install.ps1 | iex" in guide assert ( - "https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install.ps1" + "https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install.ps1" ) not in guide @@ -62,7 +62,7 @@ def test_readme_downloads_rpm_before_local_install() -> None: rpm = f"pythinker-code-{version}.x86_64.rpm" checksum = f"{rpm}.sha256" - release_url = f"https://github.com/Pythoughts-labs/pythinker-code/releases/download/v{version}" + release_url = f"https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v{version}" assert f"curl -LO {release_url}/{rpm}" in readme assert f"curl -LO {release_url}/{checksum}" in readme diff --git a/tests/test_native.py b/tests/test_native.py index 129a960a..a034ac30 100644 --- a/tests/test_native.py +++ b/tests/test_native.py @@ -55,7 +55,7 @@ def test_is_native_build_true_when_sentinel_present_without_frozen_flag(tmp_path def test_native_installer_release_url_latest(): url = native.native_installer_release_url(channel="latest") - assert url == ("https://api.github.com/repos/Pythoughts-labs/pythinker-code/releases/latest") + assert url == ("https://api.github.com/repos/Pythoughts-labs/pythinker-cli/releases/latest") def test_native_installer_release_url_stable(): diff --git a/tests/ui_and_conv/test_shell_feedback_slash.py b/tests/ui_and_conv/test_shell_feedback_slash.py index ec8a98ac..e644c6a3 100644 --- a/tests/ui_and_conv/test_shell_feedback_slash.py +++ b/tests/ui_and_conv/test_shell_feedback_slash.py @@ -50,7 +50,7 @@ async def test_opens_new_issue_url_when_no_soul(self, monkeypatch) -> None: open_mock.assert_called_once() url = open_mock.call_args.args[0] - assert "Pythoughts-labs/pythinker-code" in url + assert "Pythoughts-labs/pythinker-cli" in url assert "new" in url async def test_prints_success_when_browser_opens(self, monkeypatch) -> None: @@ -75,7 +75,7 @@ async def test_prints_url_when_browser_fails(self, monkeypatch) -> None: await _run_feedback(Mock(), "ux confusing prompt") output = " ".join(str(c) for c in print_mock.call_args_list) - assert "Pythoughts-labs/pythinker-code" in output + assert "Pythoughts-labs/pythinker-cli" in output async def test_invalid_args_without_soul_still_offer_github_fallback(self, monkeypatch) -> None: open_mock = Mock(return_value=True) @@ -146,7 +146,7 @@ async def test_submits_structured_payload(self, tmp_path: Path, monkeypatch) -> soul.runtime.session.subagents_dir = tmp_path / "subagents" soul.runtime.session.subagents_dir.mkdir() soul.runtime.role = "root" - soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-code" + soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-cli" soul.name = "default" soul.context.history = [ Message(role="user", content=[TextPart(text="please fix this")]), @@ -200,7 +200,7 @@ async def test_prints_follow_up_issue_url_when_endpoint_returns_no_link( soul = Mock(spec=PythinkerSoul) soul.runtime.session.id = "sess-123" - soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-code" + soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-cli" app = Mock() app.soul = soul payload = { @@ -225,7 +225,7 @@ async def test_prints_follow_up_issue_url_when_endpoint_returns_no_link( output = " ".join(str(c) for c in print_mock.call_args_list) assert "No report link was returned" in output - assert "github.com/Pythoughts-labs/pythinker-code" in output + assert "github.com/Pythoughts-labs/pythinker-cli" in output async def test_prompts_before_submitting_by_default(self, monkeypatch) -> None: from pythinker_code.soul.pythinkersoul import PythinkerSoul @@ -302,7 +302,7 @@ def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None: url = build_feedback_issue_url(payload, old_owner + "/Pythinker-Code") - assert "github.com/Pythoughts-labs/pythinker-code/issues/new" in url + assert "github.com/Pythoughts-labs/pythinker-cli/issues/new" in url assert old_owner not in url def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None: diff --git a/web/public/install.ps1 b/web/public/install.ps1 index 146ded20..a118158b 100644 --- a/web/public/install.ps1 +++ b/web/public/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/web/public/install.sh b/web/public/install.sh index 21f0c70f..f7f0944d 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' From aff168c6a15cc1de9d5e0aa195cac0fbb360e1b1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 18:29:04 -0400 Subject: [PATCH 2/6] feat(installer): add hosted Windows uninstaller one-liner Add scripts/uninstall.ps1, served at https://pythinker.com/uninstall.ps1 mirroring the installer bootstrap (irm ... | iex). It runs the registered Inno uninstaller silently, sweeps validated install dirs, PATH entries (user + system, kind-preserved), Start Menu shortcuts, uninstall registry keys (32/64-bit views), and stale installer temp dirs, then verifies the final machine state with fail-closed semantics and a structured summary. Safety: registry-provided paths are never deleted or executed without validation (leaf named Pythinker plus on-disk evidence, no reparse-point component, trusted unins.exe only); processes are stopped per-PID only when rooted in a validated install dir with StartTime/Path revalidation; the script never self-elevates and never calls exit, so piping through iex cannot close the host session. User data is kept unless PYTHINKER_PURGE_DATA=1. Ship byte-identical copies in docs/public and web/public with matching _headers cache entries, document the one-liner in the README Windows section and the getting-started guide, and cover sync/parsing/README assertions in test_installation_docs.py. --- CHANGELOG.md | 1 + README.md | 8 +- docs/en/guides/getting-started.md | 6 + docs/public/_headers | 4 + docs/public/uninstall.ps1 | 958 ++++++++++++++++++++++++++++++ scripts/uninstall.ps1 | 958 ++++++++++++++++++++++++++++++ tests/test_installation_docs.py | 42 ++ web/public/_headers | 4 + web/public/uninstall.ps1 | 958 ++++++++++++++++++++++++++++++ 9 files changed, 2937 insertions(+), 2 deletions(-) create mode 100644 docs/public/uninstall.ps1 create mode 100644 scripts/uninstall.ps1 create mode 100644 web/public/uninstall.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b68e5d..7b6d1eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Add `scripts/uninstall.ps1`, a Windows uninstaller served at `https://pythinker.com/uninstall.ps1` (`irm https://pythinker.com/uninstall.ps1 | iex`): runs the registered Inno uninstaller silently, sweeps validated install dirs/PATH entries/Start Menu shortcuts/uninstall registry keys/stale installer temp dirs, and verifies final state with fail-closed semantics. Registry-provided paths are never deleted or executed without validation (named `Pythinker` + on-disk evidence, no reparse points, trusted `unins.exe` only), processes are killed per-PID only from validated dirs, and the script never self-elevates or calls `exit`, so piping through `iex` cannot close the host. User data under `%USERPROFILE%\.pythinker` is kept unless `PYTHINKER_PURGE_DATA=1` is set. - Update all repository URLs and repo-slug constants to `Pythoughts-labs/pythinker-cli` after the GitHub repository rename (install scripts, updater, feedback default repo, docs, workflows, packaging). - Retry the post-update smoke check briefly before recording `VERIFICATION_FAILED`, absorbing the Homebrew launcher-relink race that falsely failed successful upgrades, and print the verification failure on screen instead of leaving "Updated successfully!" as the last word when verification genuinely fails. diff --git a/README.md b/README.md index 5b3ae028..03caab30 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,12 @@ installs to `%ProgramFiles%\Pythinker` and writes PATH to HKLM (requires admin). the newest installer, verifies SHA-256, and launches the Inno installer with visible progress (`/SILENT /NORESTART /CURRENTUSER /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS`). -**Uninstall:** Apps & Features → *Pythinker Code* → Uninstall reverts both -the files and the PATH edit. +**Uninstall:** `irm https://pythinker.com/uninstall.ps1 | iex` — runs the +registered uninstaller, then sweeps leftover files, PATH edits (user + system), +Start Menu shortcuts, and uninstall registry keys, and verifies the result. +Your config/sessions in `%USERPROFILE%\.pythinker` are kept unless you set +`$env:PYTHINKER_PURGE_DATA = "1"` first. Apps & Features → *Pythinker Code* → +Uninstall works too and reverts both the files and the PATH edit. > 🛡 **First-launch SmartScreen warning** — Until Authenticode secrets are > configured in CI for a release, the installer ships unsigned and Windows shows diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index e6d157ef..6ec99c98 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -70,6 +70,12 @@ Upgrade to the latest version: pythinker update ``` +Uninstall on Windows (native installer): + +```powershell +irm https://pythinker.com/uninstall.ps1 | iex +``` + Uninstall a curl-bash install: ```sh diff --git a/docs/public/_headers b/docs/public/_headers index 5711b8e8..3f77a812 100644 --- a/docs/public/_headers +++ b/docs/public/_headers @@ -6,5 +6,9 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 +/uninstall.ps1 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 + /releases/* Cache-Control: public, max-age=31536000, immutable diff --git a/docs/public/uninstall.ps1 b/docs/public/uninstall.ps1 new file mode 100644 index 00000000..035cd766 --- /dev/null +++ b/docs/public/uninstall.ps1 @@ -0,0 +1,958 @@ +# Pythinker Code — native Windows uninstaller. +# +# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: +# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. +# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + +# system, value kind preserved), Start Menu shortcuts, uninstall registry +# keys (both 32/64-bit views), stale installer temp dirs, session PATH. +# +# Session model: +# - Everything runs inside one anonymous child scope: no functions, variables, +# or preference settings leak into the caller's session when piped through +# `irm ... | iex`. Console encoding is restored on exit; TLS settings are +# never touched. The script never calls `exit` — failure surfaces as a +# thrown error, so iex cannot close the user's window while +# `powershell.exe -File` still gets a non-zero exit code. +# +# Safety model: +# - Registry-provided paths are NEVER deleted or executed blindly. A directory +# is only touched after Get-SafeInstallDirectory proves it is a plausible +# Pythinker install: absolute, not a filesystem root, not a critical +# directory, leaf named "Pythinker", containing no reparse-point component, +# and either the default location or containing pythinker.exe / unins000.exe. +# - Uninstaller executables must additionally be named unins.exe and live +# directly in a validated install dir. +# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): +# machine-scope work requires re-running the whole script elevated, which +# keeps a tampered user-writable file from becoming a privilege escalation. +# - Recursive deletion refuses any path that contains, or sits beneath, a +# reparse point (junction/symlink), and never descends into nested ones. +# - Processes are killed only when their executable path resolves inside a +# validated install dir; escalation is per-PID with StartTime+Path +# revalidation, never machine-wide by image name. +# - Registry uninstall keys are removed only for installations that were +# actually handled (files gone or pending reboot); keys for unvalidated +# installations are left in place and reported. +# +# Failure model: +# - Step failures are recorded as WARNINGS and the run continues. +# - A final verification phase inspects real machine state and FAILS CLOSED: +# anything it cannot confirm clean becomes an UNRESOLVED item, and the +# result succeeds only when zero items are unresolved. +# - Locked paths scheduled for deletion on next reboot are tracked separately. +# +# Usage (paste into PowerShell, or host and pipe like the installer): +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data (config, sessions, logs under $HOME\.pythinker): +# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) +# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking +# unset -> ask once when interactive; keep otherwise + +& { + $ErrorActionPreference = "Stop" + + # Save/restore console encoding so the caller's session is untouched. + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeData = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + # --- Color detection (RawUI access can throw in some hosts; probe defensively) + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + if ($null -ne $Host.UI.RawUI) { + $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } + else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + } + } catch { $useColor = $false } + } + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + # --- All mutable state lives in one reference object inside this child + # scope; functions read it via normal (dynamic) scope lookup. No $script: + # variables exist, so nothing can leak into an iex caller's session. + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + DefaultInstallDir = $null + } + + function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } + function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } + function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } + function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + + function Record-Removed($what) { $State.RemovedCount++; OK $what } + + function Format-Err($err) { + if ($null -eq $err) { return "" } + if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } + return [string]$err + } + + function Record-Warning($what, $err) { + $detail = $what + $message = Format-Err $err + if ($message) { $detail = "$what — $message" } + $State.Warnings.Add($detail) + Warn $detail + } + + function Record-Unresolved($what) { + $State.Unresolved.Add($what) + Warn $what + } + + # Isolated step runner: a throwing step becomes a warning, never an abort. + function Invoke-Step($Name, [scriptblock]$Action) { + try { return & $Action } + catch { Record-Warning $Name $_; return $null } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + function Test-IsAdmin { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { return $false } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } + return ($Host.UI.RawUI -ne $null) + } + + # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, + # canonicalizes rooted paths (. / ..), strips trailing separators. Relative + # tokens are returned un-canonicalized (never resolved against the cwd). + # Original registry tokens are never rewritten — this is only a match key. + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = $Value.Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + if ([IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + } + return $expanded.TrimEnd('\', '/') + } + + # True when the path itself or any existing ancestor is a reparse point + # (junction/symlink). Fails CLOSED when inspection is impossible. + function Test-PathHasReparseComponent($Path) { + $p = $Path + while ($p -and -not (Test-Path -LiteralPath $p)) { + $p = Split-Path -Parent $p + } + if (-not $p) { return $false } + try { + $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop + while ($current) { + if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } + $current = $current.Parent + } + return $false + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + } + + # Reparse-point directories inside a tree, without ever descending into + # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). + function Get-NestedReparsePoints($Root) { + $found = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } + if ($isDir) { $stack.Push($e) } + } + } + return $found + } + + # Full tree listing (files + dirs) that never descends into reparse-point + # directories; the links themselves are returned as leaf directories. + function Get-TreeSafe($Root) { + $files = New-Object System.Collections.Generic.List[string] + $dirs = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if (-not $isDir) { $files.Add($e); continue } + $dirs.Add($e) + if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf + $stack.Push($e) + } + } + return [pscustomobject]@{ Files = $files; Dirs = $dirs } + } + + # --- Path safety: the ONLY guard between a registry value and recursive + # deletion / execution. Returns the canonical dir or $null. + function Get-SafeInstallDirectory($Candidate) { + if (-not $Candidate) { return $null } + $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) + if (-not [IO.Path]::IsPathRooted($expanded)) { + Record-Warning "ignoring non-absolute install path: $Candidate" $null + return $null + } + try { $raw = [IO.Path]::GetFullPath($expanded) } + catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } + + $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') + $full = $raw.TrimEnd('\', '/') + if ($full -eq "" -or $full -ieq $root) { + Record-Warning "refusing filesystem root as install dir: $raw" $null + return $null + } + + # Never touch critical directories or any ancestor of them. + $critical = @( + [Environment]::GetFolderPath("Windows"), + [Environment]::GetFolderPath("ProgramFiles"), + [Environment]::GetFolderPath("ProgramFilesX86"), + [Environment]::GetFolderPath("UserProfile"), + [Environment]::GetFolderPath("CommonApplicationData"), + $env:SystemDrive + ) | Where-Object { $_ } + foreach ($c in $critical) { + $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') + if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing critical directory as install dir: $full" $null + return $null + } + } + + if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + Record-Warning "refusing directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing path with a reparse-point component: $full" $null + return $null + } + + # The default location is always plausible; custom locations must contain + # on-disk evidence of a real install. + if ($full -ieq $State.DefaultInstallDir) { return $full } + if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or + (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + return $full + } + Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + return $null + } + + # An uninstaller executable is trusted only when it looks like an Inno + # uninstaller (unins.exe) AND lives directly in a validated install dir. + function Test-TrustedUninstaller($Exe, $Dir) { + if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + try { + $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') + $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') + } catch { return $false } + return ($parent -ieq $dd) + } + + function Test-PathUnderDirs($ProcessPath, $Dirs) { + if (-not $ProcessPath) { return $false } + try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } + foreach ($d in $Dirs) { + $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') + if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + } + return $false + } + + # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = @() + foreach ($hive in @("CurrentUser", "LocalMachine")) { + foreach ($view in @("Registry64", "Registry32")) { + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + if ($key) { + $uninstall = $key.GetValue("UninstallString") + $quiet = $key.GetValue("QuietUninstallString") + $location = $key.GetValue("InstallLocation") + if ($uninstall -or $quiet) { + $entries += [pscustomobject]@{ + Hive = $hive; View = $view + UninstallString = $uninstall; QuietUninstallString = $quiet + InstallLocation = $location + } + } + } + # key absent = not installed at this scope/view; not an error. + } catch { + Record-Warning "could not inspect $hive\$view uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + return $entries + } + + function Get-UninstallerPath($entry) { + $raw = $entry.QuietUninstallString + if (-not $raw) { $raw = $entry.UninstallString } + if ($raw) { + $match = [regex]::Match($raw, '^"([^"]+)"') + if ($match.Success) { return $match.Groups[1].Value } + $match = [regex]::Match($raw, '^(.*?\.exe)') + if ($match.Success) { return $match.Groups[1].Value } + } + return $null + } + + # Each registry entry becomes an installation record: hive/view, validated + # dir (or $null), uninstaller exe, and whether that exe is trusted. + # HKCU views alias the same key (no WOW64 redirection there), so user-scope + # records are deduplicated across views. + function Get-InstallationRecords($Entries) { + $records = @() + $seen = @{} + foreach ($e in $Entries) { + $dedupe = if ($e.Hive -eq "CurrentUser") { + "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } else { + "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } + if ($seen.ContainsKey($dedupe)) { continue } + $seen[$dedupe] = $true + + $exe = Get-UninstallerPath $e + $dir = $null + $candidates = @($e.InstallLocation) + if ($exe) { $candidates += (Split-Path -Parent $exe) } + foreach ($c in $candidates) { + if (-not $c) { continue } + $dir = Get-SafeInstallDirectory $c + if ($dir) { break } + } + $trusted = $false + if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } + $records += [pscustomobject]@{ + Hive = $e.Hive; View = $e.View + Dir = $dir; Uninstaller = $exe; Trusted = $trusted + } + } + return $records + } + + # --- 2. Stop processes, but only ones rooted in a validated install dir. + function Stop-PythinkerProcesses($Dirs) { + $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) + if ($procs.Count -eq 0) { return } + + foreach ($p in $procs) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if (-not $procPath) { + Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + continue + } + if (-not (Test-PathUnderDirs $procPath $Dirs)) { + Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + continue + } + $start = $null + try { $start = $p.StartTime } catch { $start = $null } + + Step "Stopping $($p.ProcessName) (PID $($p.Id))" + Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { + Stop-Process -Id $p.Id -Force -ErrorAction Stop + } | Out-Null + + $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue + if (-not $survivor) { continue } + + # Revalidate identity before per-PID escalation (PID reuse race). + $sameStart = $false + if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } + $survivorPath = $null + try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } + $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) + if (-not ($sameStart -and $samePath)) { + Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + continue + } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if ($taskkill) { + Invoke-Step "taskkill failed for PID $($p.Id)" { + $out = & taskkill.exe /F /T /PID $p.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$out" } + } | Out-Null + } + } + } + + # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a + # registry-selected executable: machine-scope runs require an elevated shell. + function Invoke-InnoUninstaller($Exe, $Scope) { + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { + Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null + return + } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { + Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null + return + } + $parent = Split-Path -Parent $Exe + if (-not (Get-SafeInstallDirectory $parent)) { + Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null + return + } + if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null + return + } + + Step "Running Pythinker uninstaller ($Scope scope)" + $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + try { + $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null + return + } + OK "Uninstaller completed" + } catch { + Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + } + } + + # --- 4. Removal helpers + + # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for + # locked paths. Idempotent across repeated runs in the same session. + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop + } catch { } + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteTree($Path) { + if (-not (Initialize-PendingDelete)) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + $tree = Get-TreeSafe $Path + foreach ($f in $tree.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + # Deepest directories first so they are empty when their turn comes. + foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + return $ok + } + + # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, + # an absolute refusal to touch a filesystem root, and fail-closed reparse + # protection (never recurse through junctions/symlinks). + function Remove-PathRobust($Path, $What) { + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } + try { + $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') + if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { + Record-Unresolved "refusing to remove filesystem root: $Path" + return + } + } catch { Record-Unresolved "refusing malformed path: $Path"; return } + + if (Test-PathHasReparseComponent $Path) { + Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + return + } + $nested = @(Get-NestedReparsePoints $Path) + if ($nested.Count -gt 0) { + Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + return + } + + $lastErr = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + } catch { + $lastErr = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + if (Register-PendingDeleteTree $Path) { + $State.PendingReboot.Add($Path) + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + $hint = $lastErr + if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + Record-Warning "could not remove $What" $hint + } + + # Remove one directory from a registry PATH value via the .NET registry API: + # missing value = NotFound (not an error), original value kind preserved, + # non-matching entries kept verbatim. Comparison expands env vars + quotes. + function Remove-PathEntry($Dir, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return "NotFound" } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal + $kind = $key.GetValueKind("Path") + $key.Dispose(); $key = $null + + $target = Get-NormalizedPathToken $Dir + $matched = $false + $kept = @( + foreach ($e in ([string]$current -split ';')) { + if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + } + ) + if (-not $matched) { return "NotFound" } + $newPath = $kept -join ';' + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + return "Failed" + } + $key = $base.OpenSubKey($subkey, $true) + if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } + $key.SetValue("Path", $newPath, $kind) + Record-Removed "removed $Dir from $label" + return "Removed" + } catch { + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not update $label" $_ + } + return "Failed" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # True when a registry PATH value still contains any of $Dirs; $null when it + # could not be determined (caller must treat $null as UNRESOLVED). + function Test-PathEntryPresent($Dirs, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) + foreach ($e in ([string]$current -split ';')) { + $probe = Get-NormalizedPathToken $e + foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # Strip the install dirs from this session's PATH so the current window is + # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. + function Remove-SessionPathEntries($Dirs) { + try { + $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + if ($targets.Count -eq 0) { return } + $kept = @($env:PATH -split ';' | Where-Object { + $probe = Get-NormalizedPathToken $_ + ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 + }) + $newPath = $kept -join ';' + if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } + } catch { Record-Warning "could not clean this session's PATH" $_ } + } + + # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. + function Send-EnvironmentBroadcast { + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) + } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + } + + # Delete registry keys ONLY for installations that were actually handled: + # validated dir and (files gone or pending reboot). Keys for unvalidated or + # unfinished installations stay in place and are reported as unresolved. + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($r in $Records) { + if (-not $r.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + continue + } + if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + continue + } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) + $key = $base.OpenSubKey($uninstallPath) + if ($null -eq $key) { continue } # absent = already clean + $key.Dispose(); $key = $null + try { + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" + } catch { + if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null + } else { + Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ + } + } + } catch { + Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # Stale installer bootstrap temp dirs left by interrupted installs. Removed + # only when ALL ownership signals hold: strict name shape (GUID suffix), + # older than 1 hour, no setup process running, and contents limited to the + # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + return + } + $cutoff = (Get-Date).AddHours(-1) + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) + foreach ($t in $candidates) { + $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) + $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) + if ($foreign.Count -gt 0) { + Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + continue + } + Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + } + } + + # --- 5. Optional user-data purge (verified by Test-FinalState) + function Invoke-DataPurge { + if (-not (Test-Path -LiteralPath $DataDir)) { return } + + $purge = $false + if ($PurgeData -eq "1") { + $purge = $true + } elseif ($PurgeData -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { $purge = $false } + } + + if ($purge) { + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed + # clean becomes unresolved. The result succeeds only at zero unresolved. + function Test-FinalState($Dirs, $Records) { + foreach ($dir in $Dirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "install directory still present: $dir" + } + } + + foreach ($hive in @("CurrentUser", "LocalMachine")) { + $present = Test-PathEntryPresent $Dirs $hive + $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + if ($null -eq $present) { + Record-Unresolved "could not verify $label state" + } elseif ($present) { + $msg = "$label still contains a Pythinker entry" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + # HKCU has no WOW64 redirection here, so one view is authoritative there. + $combos = @( + @("CurrentUser", "Registry64"), + @("LocalMachine", "Registry64"), + @("LocalMachine", "Registry32") + ) + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in $combos) { + $hive = $combo[0]; $view = $combo[1] + $base = $null; $key = $null + $exists = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + $exists = ($null -ne $key) + } catch { + Record-Unresolved "could not verify $hive\$view uninstall registry state" + continue + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + if ($exists) { + $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) + if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place + $msg = "uninstall registry entry still present ($hive\$view)" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "Start Menu shortcuts still present: $dir" + } + } + + foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { + Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + } + } + + if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { + Record-Unresolved "requested user-data purge did not complete: $DataDir" + } + } + + # --- Main (function-wrapped: returns a structured result, never calls exit) + function Invoke-PythinkerUninstall { + # Last-resort safety net for anything no step caught. Scoped INSIDE this + # function so it can never swallow the top-level failure signal below. + trap { Record-Warning "unexpected error" $_; continue } + + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) + if ($null -eq $entries) { $entries = @() } + $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) + + $installDirs = New-Object System.Collections.Generic.List[string] + $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir + if ($defaultSafe) { + $installDirs.Add($defaultSafe) + } else { + Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" + } + foreach ($r in $records) { + if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + foreach ($r in $records) { + if (-not $r.Uninstaller) { continue } + if (-not $r.Trusted) { + Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null + continue + } + Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null + } + + # Sweep everything, whether or not an uninstaller ran (idempotent). + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + foreach ($dir in $installDirs) { + Remove-PathRobust $dir "install directory $dir" + Invoke-Step "PATH cleanup failed for $dir" { + [void](Remove-PathEntry $dir "CurrentUser") + [void](Remove-PathEntry $dir "LocalMachine") + } | Out-Null + } + Remove-SessionPathEntries $installDirs + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + # Final verification must never degrade to a warning: unknown = unresolved. + Step "Verifying final state" + try { + Test-FinalState $installDirs $records + } catch { + Record-Unresolved "final verification did not complete — $(Format-Err $_)" + } + + # --- Summary + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" + foreach ($w in $State.Warnings) { Dim " • $w" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" + foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + # The function's trap should make this unreachable, but a swallowed + # failure must never look like success. + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error + # and the host stays open; `powershell.exe -File` exits non-zero. + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +} diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 00000000..035cd766 --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,958 @@ +# Pythinker Code — native Windows uninstaller. +# +# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: +# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. +# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + +# system, value kind preserved), Start Menu shortcuts, uninstall registry +# keys (both 32/64-bit views), stale installer temp dirs, session PATH. +# +# Session model: +# - Everything runs inside one anonymous child scope: no functions, variables, +# or preference settings leak into the caller's session when piped through +# `irm ... | iex`. Console encoding is restored on exit; TLS settings are +# never touched. The script never calls `exit` — failure surfaces as a +# thrown error, so iex cannot close the user's window while +# `powershell.exe -File` still gets a non-zero exit code. +# +# Safety model: +# - Registry-provided paths are NEVER deleted or executed blindly. A directory +# is only touched after Get-SafeInstallDirectory proves it is a plausible +# Pythinker install: absolute, not a filesystem root, not a critical +# directory, leaf named "Pythinker", containing no reparse-point component, +# and either the default location or containing pythinker.exe / unins000.exe. +# - Uninstaller executables must additionally be named unins.exe and live +# directly in a validated install dir. +# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): +# machine-scope work requires re-running the whole script elevated, which +# keeps a tampered user-writable file from becoming a privilege escalation. +# - Recursive deletion refuses any path that contains, or sits beneath, a +# reparse point (junction/symlink), and never descends into nested ones. +# - Processes are killed only when their executable path resolves inside a +# validated install dir; escalation is per-PID with StartTime+Path +# revalidation, never machine-wide by image name. +# - Registry uninstall keys are removed only for installations that were +# actually handled (files gone or pending reboot); keys for unvalidated +# installations are left in place and reported. +# +# Failure model: +# - Step failures are recorded as WARNINGS and the run continues. +# - A final verification phase inspects real machine state and FAILS CLOSED: +# anything it cannot confirm clean becomes an UNRESOLVED item, and the +# result succeeds only when zero items are unresolved. +# - Locked paths scheduled for deletion on next reboot are tracked separately. +# +# Usage (paste into PowerShell, or host and pipe like the installer): +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data (config, sessions, logs under $HOME\.pythinker): +# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) +# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking +# unset -> ask once when interactive; keep otherwise + +& { + $ErrorActionPreference = "Stop" + + # Save/restore console encoding so the caller's session is untouched. + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeData = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + # --- Color detection (RawUI access can throw in some hosts; probe defensively) + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + if ($null -ne $Host.UI.RawUI) { + $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } + else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + } + } catch { $useColor = $false } + } + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + # --- All mutable state lives in one reference object inside this child + # scope; functions read it via normal (dynamic) scope lookup. No $script: + # variables exist, so nothing can leak into an iex caller's session. + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + DefaultInstallDir = $null + } + + function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } + function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } + function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } + function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + + function Record-Removed($what) { $State.RemovedCount++; OK $what } + + function Format-Err($err) { + if ($null -eq $err) { return "" } + if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } + return [string]$err + } + + function Record-Warning($what, $err) { + $detail = $what + $message = Format-Err $err + if ($message) { $detail = "$what — $message" } + $State.Warnings.Add($detail) + Warn $detail + } + + function Record-Unresolved($what) { + $State.Unresolved.Add($what) + Warn $what + } + + # Isolated step runner: a throwing step becomes a warning, never an abort. + function Invoke-Step($Name, [scriptblock]$Action) { + try { return & $Action } + catch { Record-Warning $Name $_; return $null } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + function Test-IsAdmin { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { return $false } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } + return ($Host.UI.RawUI -ne $null) + } + + # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, + # canonicalizes rooted paths (. / ..), strips trailing separators. Relative + # tokens are returned un-canonicalized (never resolved against the cwd). + # Original registry tokens are never rewritten — this is only a match key. + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = $Value.Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + if ([IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + } + return $expanded.TrimEnd('\', '/') + } + + # True when the path itself or any existing ancestor is a reparse point + # (junction/symlink). Fails CLOSED when inspection is impossible. + function Test-PathHasReparseComponent($Path) { + $p = $Path + while ($p -and -not (Test-Path -LiteralPath $p)) { + $p = Split-Path -Parent $p + } + if (-not $p) { return $false } + try { + $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop + while ($current) { + if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } + $current = $current.Parent + } + return $false + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + } + + # Reparse-point directories inside a tree, without ever descending into + # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). + function Get-NestedReparsePoints($Root) { + $found = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } + if ($isDir) { $stack.Push($e) } + } + } + return $found + } + + # Full tree listing (files + dirs) that never descends into reparse-point + # directories; the links themselves are returned as leaf directories. + function Get-TreeSafe($Root) { + $files = New-Object System.Collections.Generic.List[string] + $dirs = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if (-not $isDir) { $files.Add($e); continue } + $dirs.Add($e) + if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf + $stack.Push($e) + } + } + return [pscustomobject]@{ Files = $files; Dirs = $dirs } + } + + # --- Path safety: the ONLY guard between a registry value and recursive + # deletion / execution. Returns the canonical dir or $null. + function Get-SafeInstallDirectory($Candidate) { + if (-not $Candidate) { return $null } + $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) + if (-not [IO.Path]::IsPathRooted($expanded)) { + Record-Warning "ignoring non-absolute install path: $Candidate" $null + return $null + } + try { $raw = [IO.Path]::GetFullPath($expanded) } + catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } + + $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') + $full = $raw.TrimEnd('\', '/') + if ($full -eq "" -or $full -ieq $root) { + Record-Warning "refusing filesystem root as install dir: $raw" $null + return $null + } + + # Never touch critical directories or any ancestor of them. + $critical = @( + [Environment]::GetFolderPath("Windows"), + [Environment]::GetFolderPath("ProgramFiles"), + [Environment]::GetFolderPath("ProgramFilesX86"), + [Environment]::GetFolderPath("UserProfile"), + [Environment]::GetFolderPath("CommonApplicationData"), + $env:SystemDrive + ) | Where-Object { $_ } + foreach ($c in $critical) { + $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') + if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing critical directory as install dir: $full" $null + return $null + } + } + + if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + Record-Warning "refusing directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing path with a reparse-point component: $full" $null + return $null + } + + # The default location is always plausible; custom locations must contain + # on-disk evidence of a real install. + if ($full -ieq $State.DefaultInstallDir) { return $full } + if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or + (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + return $full + } + Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + return $null + } + + # An uninstaller executable is trusted only when it looks like an Inno + # uninstaller (unins.exe) AND lives directly in a validated install dir. + function Test-TrustedUninstaller($Exe, $Dir) { + if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + try { + $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') + $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') + } catch { return $false } + return ($parent -ieq $dd) + } + + function Test-PathUnderDirs($ProcessPath, $Dirs) { + if (-not $ProcessPath) { return $false } + try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } + foreach ($d in $Dirs) { + $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') + if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + } + return $false + } + + # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = @() + foreach ($hive in @("CurrentUser", "LocalMachine")) { + foreach ($view in @("Registry64", "Registry32")) { + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + if ($key) { + $uninstall = $key.GetValue("UninstallString") + $quiet = $key.GetValue("QuietUninstallString") + $location = $key.GetValue("InstallLocation") + if ($uninstall -or $quiet) { + $entries += [pscustomobject]@{ + Hive = $hive; View = $view + UninstallString = $uninstall; QuietUninstallString = $quiet + InstallLocation = $location + } + } + } + # key absent = not installed at this scope/view; not an error. + } catch { + Record-Warning "could not inspect $hive\$view uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + return $entries + } + + function Get-UninstallerPath($entry) { + $raw = $entry.QuietUninstallString + if (-not $raw) { $raw = $entry.UninstallString } + if ($raw) { + $match = [regex]::Match($raw, '^"([^"]+)"') + if ($match.Success) { return $match.Groups[1].Value } + $match = [regex]::Match($raw, '^(.*?\.exe)') + if ($match.Success) { return $match.Groups[1].Value } + } + return $null + } + + # Each registry entry becomes an installation record: hive/view, validated + # dir (or $null), uninstaller exe, and whether that exe is trusted. + # HKCU views alias the same key (no WOW64 redirection there), so user-scope + # records are deduplicated across views. + function Get-InstallationRecords($Entries) { + $records = @() + $seen = @{} + foreach ($e in $Entries) { + $dedupe = if ($e.Hive -eq "CurrentUser") { + "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } else { + "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } + if ($seen.ContainsKey($dedupe)) { continue } + $seen[$dedupe] = $true + + $exe = Get-UninstallerPath $e + $dir = $null + $candidates = @($e.InstallLocation) + if ($exe) { $candidates += (Split-Path -Parent $exe) } + foreach ($c in $candidates) { + if (-not $c) { continue } + $dir = Get-SafeInstallDirectory $c + if ($dir) { break } + } + $trusted = $false + if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } + $records += [pscustomobject]@{ + Hive = $e.Hive; View = $e.View + Dir = $dir; Uninstaller = $exe; Trusted = $trusted + } + } + return $records + } + + # --- 2. Stop processes, but only ones rooted in a validated install dir. + function Stop-PythinkerProcesses($Dirs) { + $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) + if ($procs.Count -eq 0) { return } + + foreach ($p in $procs) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if (-not $procPath) { + Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + continue + } + if (-not (Test-PathUnderDirs $procPath $Dirs)) { + Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + continue + } + $start = $null + try { $start = $p.StartTime } catch { $start = $null } + + Step "Stopping $($p.ProcessName) (PID $($p.Id))" + Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { + Stop-Process -Id $p.Id -Force -ErrorAction Stop + } | Out-Null + + $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue + if (-not $survivor) { continue } + + # Revalidate identity before per-PID escalation (PID reuse race). + $sameStart = $false + if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } + $survivorPath = $null + try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } + $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) + if (-not ($sameStart -and $samePath)) { + Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + continue + } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if ($taskkill) { + Invoke-Step "taskkill failed for PID $($p.Id)" { + $out = & taskkill.exe /F /T /PID $p.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$out" } + } | Out-Null + } + } + } + + # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a + # registry-selected executable: machine-scope runs require an elevated shell. + function Invoke-InnoUninstaller($Exe, $Scope) { + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { + Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null + return + } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { + Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null + return + } + $parent = Split-Path -Parent $Exe + if (-not (Get-SafeInstallDirectory $parent)) { + Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null + return + } + if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null + return + } + + Step "Running Pythinker uninstaller ($Scope scope)" + $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + try { + $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null + return + } + OK "Uninstaller completed" + } catch { + Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + } + } + + # --- 4. Removal helpers + + # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for + # locked paths. Idempotent across repeated runs in the same session. + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop + } catch { } + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteTree($Path) { + if (-not (Initialize-PendingDelete)) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + $tree = Get-TreeSafe $Path + foreach ($f in $tree.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + # Deepest directories first so they are empty when their turn comes. + foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + return $ok + } + + # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, + # an absolute refusal to touch a filesystem root, and fail-closed reparse + # protection (never recurse through junctions/symlinks). + function Remove-PathRobust($Path, $What) { + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } + try { + $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') + if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { + Record-Unresolved "refusing to remove filesystem root: $Path" + return + } + } catch { Record-Unresolved "refusing malformed path: $Path"; return } + + if (Test-PathHasReparseComponent $Path) { + Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + return + } + $nested = @(Get-NestedReparsePoints $Path) + if ($nested.Count -gt 0) { + Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + return + } + + $lastErr = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + } catch { + $lastErr = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + if (Register-PendingDeleteTree $Path) { + $State.PendingReboot.Add($Path) + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + $hint = $lastErr + if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + Record-Warning "could not remove $What" $hint + } + + # Remove one directory from a registry PATH value via the .NET registry API: + # missing value = NotFound (not an error), original value kind preserved, + # non-matching entries kept verbatim. Comparison expands env vars + quotes. + function Remove-PathEntry($Dir, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return "NotFound" } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal + $kind = $key.GetValueKind("Path") + $key.Dispose(); $key = $null + + $target = Get-NormalizedPathToken $Dir + $matched = $false + $kept = @( + foreach ($e in ([string]$current -split ';')) { + if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + } + ) + if (-not $matched) { return "NotFound" } + $newPath = $kept -join ';' + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + return "Failed" + } + $key = $base.OpenSubKey($subkey, $true) + if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } + $key.SetValue("Path", $newPath, $kind) + Record-Removed "removed $Dir from $label" + return "Removed" + } catch { + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not update $label" $_ + } + return "Failed" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # True when a registry PATH value still contains any of $Dirs; $null when it + # could not be determined (caller must treat $null as UNRESOLVED). + function Test-PathEntryPresent($Dirs, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) + foreach ($e in ([string]$current -split ';')) { + $probe = Get-NormalizedPathToken $e + foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # Strip the install dirs from this session's PATH so the current window is + # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. + function Remove-SessionPathEntries($Dirs) { + try { + $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + if ($targets.Count -eq 0) { return } + $kept = @($env:PATH -split ';' | Where-Object { + $probe = Get-NormalizedPathToken $_ + ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 + }) + $newPath = $kept -join ';' + if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } + } catch { Record-Warning "could not clean this session's PATH" $_ } + } + + # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. + function Send-EnvironmentBroadcast { + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) + } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + } + + # Delete registry keys ONLY for installations that were actually handled: + # validated dir and (files gone or pending reboot). Keys for unvalidated or + # unfinished installations stay in place and are reported as unresolved. + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($r in $Records) { + if (-not $r.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + continue + } + if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + continue + } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) + $key = $base.OpenSubKey($uninstallPath) + if ($null -eq $key) { continue } # absent = already clean + $key.Dispose(); $key = $null + try { + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" + } catch { + if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null + } else { + Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ + } + } + } catch { + Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # Stale installer bootstrap temp dirs left by interrupted installs. Removed + # only when ALL ownership signals hold: strict name shape (GUID suffix), + # older than 1 hour, no setup process running, and contents limited to the + # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + return + } + $cutoff = (Get-Date).AddHours(-1) + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) + foreach ($t in $candidates) { + $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) + $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) + if ($foreign.Count -gt 0) { + Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + continue + } + Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + } + } + + # --- 5. Optional user-data purge (verified by Test-FinalState) + function Invoke-DataPurge { + if (-not (Test-Path -LiteralPath $DataDir)) { return } + + $purge = $false + if ($PurgeData -eq "1") { + $purge = $true + } elseif ($PurgeData -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { $purge = $false } + } + + if ($purge) { + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed + # clean becomes unresolved. The result succeeds only at zero unresolved. + function Test-FinalState($Dirs, $Records) { + foreach ($dir in $Dirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "install directory still present: $dir" + } + } + + foreach ($hive in @("CurrentUser", "LocalMachine")) { + $present = Test-PathEntryPresent $Dirs $hive + $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + if ($null -eq $present) { + Record-Unresolved "could not verify $label state" + } elseif ($present) { + $msg = "$label still contains a Pythinker entry" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + # HKCU has no WOW64 redirection here, so one view is authoritative there. + $combos = @( + @("CurrentUser", "Registry64"), + @("LocalMachine", "Registry64"), + @("LocalMachine", "Registry32") + ) + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in $combos) { + $hive = $combo[0]; $view = $combo[1] + $base = $null; $key = $null + $exists = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + $exists = ($null -ne $key) + } catch { + Record-Unresolved "could not verify $hive\$view uninstall registry state" + continue + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + if ($exists) { + $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) + if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place + $msg = "uninstall registry entry still present ($hive\$view)" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "Start Menu shortcuts still present: $dir" + } + } + + foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { + Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + } + } + + if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { + Record-Unresolved "requested user-data purge did not complete: $DataDir" + } + } + + # --- Main (function-wrapped: returns a structured result, never calls exit) + function Invoke-PythinkerUninstall { + # Last-resort safety net for anything no step caught. Scoped INSIDE this + # function so it can never swallow the top-level failure signal below. + trap { Record-Warning "unexpected error" $_; continue } + + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) + if ($null -eq $entries) { $entries = @() } + $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) + + $installDirs = New-Object System.Collections.Generic.List[string] + $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir + if ($defaultSafe) { + $installDirs.Add($defaultSafe) + } else { + Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" + } + foreach ($r in $records) { + if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + foreach ($r in $records) { + if (-not $r.Uninstaller) { continue } + if (-not $r.Trusted) { + Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null + continue + } + Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null + } + + # Sweep everything, whether or not an uninstaller ran (idempotent). + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + foreach ($dir in $installDirs) { + Remove-PathRobust $dir "install directory $dir" + Invoke-Step "PATH cleanup failed for $dir" { + [void](Remove-PathEntry $dir "CurrentUser") + [void](Remove-PathEntry $dir "LocalMachine") + } | Out-Null + } + Remove-SessionPathEntries $installDirs + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + # Final verification must never degrade to a warning: unknown = unresolved. + Step "Verifying final state" + try { + Test-FinalState $installDirs $records + } catch { + Record-Unresolved "final verification did not complete — $(Format-Err $_)" + } + + # --- Summary + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" + foreach ($w in $State.Warnings) { Dim " • $w" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" + foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + # The function's trap should make this unreachable, but a swallowed + # failure must never look like success. + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error + # and the host stays open; `powershell.exe -File` exits non-zero. + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +} diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index ee181ca8..30a99387 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -152,11 +152,14 @@ def test_native_shell_installers_are_parseable_when_bash_is_available() -> None: def test_public_install_scripts_match_native_sources_of_truth() -> None: native_sh = (ROOT / "scripts" / "install-native.sh").read_bytes() native_ps1 = (ROOT / "scripts" / "install.ps1").read_bytes() + native_uninstall_ps1 = (ROOT / "scripts" / "uninstall.ps1").read_bytes() assert (ROOT / "docs" / "public" / "install.sh").read_bytes() == native_sh assert (ROOT / "web" / "public" / "install.sh").read_bytes() == native_sh assert (ROOT / "docs" / "public" / "install.ps1").read_bytes() == native_ps1 assert (ROOT / "web" / "public" / "install.ps1").read_bytes() == native_ps1 + assert (ROOT / "docs" / "public" / "uninstall.ps1").read_bytes() == native_uninstall_ps1 + assert (ROOT / "web" / "public" / "uninstall.ps1").read_bytes() == native_uninstall_ps1 expected_sh_headers = ( "/install.sh\n" @@ -168,10 +171,49 @@ def test_public_install_scripts_match_native_sources_of_truth() -> None: " Content-Type: text/plain; charset=utf-8\n" " Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400\n" ) + expected_uninstall_ps1_headers = ( + "/uninstall.ps1\n" + " Content-Type: text/plain; charset=utf-8\n" + " Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400\n" + ) assert expected_sh_headers in (ROOT / "docs" / "public" / "_headers").read_text() assert expected_sh_headers in (ROOT / "web" / "public" / "_headers").read_text() assert expected_ps1_headers in (ROOT / "docs" / "public" / "_headers").read_text() assert expected_ps1_headers in (ROOT / "web" / "public" / "_headers").read_text() + assert expected_uninstall_ps1_headers in (ROOT / "docs" / "public" / "_headers").read_text() + assert expected_uninstall_ps1_headers in (ROOT / "web" / "public" / "_headers").read_text() + + +def test_windows_readme_documents_uninstall_one_liner() -> None: + readme = (ROOT / "README.md").read_text() + guide = (ROOT / "docs" / "en" / "guides" / "getting-started.md").read_text() + + assert "irm https://pythinker.com/uninstall.ps1 | iex" in readme + assert "irm https://pythinker.com/uninstall.ps1 | iex" in guide + + +def test_native_powershell_uninstaller_is_parseable_when_pwsh_is_available() -> None: + pwsh = shutil.which("pwsh") + if pwsh is None: + return + uninstaller = (ROOT / "scripts" / "uninstall.ps1").resolve() + result = subprocess.run( + [ + pwsh, + "-NoProfile", + "-Command", + ( + "$errs = $null;" + f"[System.Management.Automation.Language.Parser]::ParseFile('{uninstaller}'," + " [ref]$null, [ref]$errs) | Out-Null;" + " if ($errs) { $errs | ForEach-Object { $_.Message }; exit 1 }" + ), + ], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr def test_installation_docs_do_not_use_placeholder_package_artifacts() -> None: diff --git a/web/public/_headers b/web/public/_headers index 5711b8e8..3f77a812 100644 --- a/web/public/_headers +++ b/web/public/_headers @@ -6,5 +6,9 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 +/uninstall.ps1 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 + /releases/* Cache-Control: public, max-age=31536000, immutable diff --git a/web/public/uninstall.ps1 b/web/public/uninstall.ps1 new file mode 100644 index 00000000..035cd766 --- /dev/null +++ b/web/public/uninstall.ps1 @@ -0,0 +1,958 @@ +# Pythinker Code — native Windows uninstaller. +# +# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: +# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. +# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + +# system, value kind preserved), Start Menu shortcuts, uninstall registry +# keys (both 32/64-bit views), stale installer temp dirs, session PATH. +# +# Session model: +# - Everything runs inside one anonymous child scope: no functions, variables, +# or preference settings leak into the caller's session when piped through +# `irm ... | iex`. Console encoding is restored on exit; TLS settings are +# never touched. The script never calls `exit` — failure surfaces as a +# thrown error, so iex cannot close the user's window while +# `powershell.exe -File` still gets a non-zero exit code. +# +# Safety model: +# - Registry-provided paths are NEVER deleted or executed blindly. A directory +# is only touched after Get-SafeInstallDirectory proves it is a plausible +# Pythinker install: absolute, not a filesystem root, not a critical +# directory, leaf named "Pythinker", containing no reparse-point component, +# and either the default location or containing pythinker.exe / unins000.exe. +# - Uninstaller executables must additionally be named unins.exe and live +# directly in a validated install dir. +# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): +# machine-scope work requires re-running the whole script elevated, which +# keeps a tampered user-writable file from becoming a privilege escalation. +# - Recursive deletion refuses any path that contains, or sits beneath, a +# reparse point (junction/symlink), and never descends into nested ones. +# - Processes are killed only when their executable path resolves inside a +# validated install dir; escalation is per-PID with StartTime+Path +# revalidation, never machine-wide by image name. +# - Registry uninstall keys are removed only for installations that were +# actually handled (files gone or pending reboot); keys for unvalidated +# installations are left in place and reported. +# +# Failure model: +# - Step failures are recorded as WARNINGS and the run continues. +# - A final verification phase inspects real machine state and FAILS CLOSED: +# anything it cannot confirm clean becomes an UNRESOLVED item, and the +# result succeeds only when zero items are unresolved. +# - Locked paths scheduled for deletion on next reboot are tracked separately. +# +# Usage (paste into PowerShell, or host and pipe like the installer): +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data (config, sessions, logs under $HOME\.pythinker): +# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) +# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking +# unset -> ask once when interactive; keep otherwise + +& { + $ErrorActionPreference = "Stop" + + # Save/restore console encoding so the caller's session is untouched. + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeData = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + # --- Color detection (RawUI access can throw in some hosts; probe defensively) + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + if ($null -ne $Host.UI.RawUI) { + $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } + else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + } + } catch { $useColor = $false } + } + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + # --- All mutable state lives in one reference object inside this child + # scope; functions read it via normal (dynamic) scope lookup. No $script: + # variables exist, so nothing can leak into an iex caller's session. + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + DefaultInstallDir = $null + } + + function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } + function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } + function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } + function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + + function Record-Removed($what) { $State.RemovedCount++; OK $what } + + function Format-Err($err) { + if ($null -eq $err) { return "" } + if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } + return [string]$err + } + + function Record-Warning($what, $err) { + $detail = $what + $message = Format-Err $err + if ($message) { $detail = "$what — $message" } + $State.Warnings.Add($detail) + Warn $detail + } + + function Record-Unresolved($what) { + $State.Unresolved.Add($what) + Warn $what + } + + # Isolated step runner: a throwing step becomes a warning, never an abort. + function Invoke-Step($Name, [scriptblock]$Action) { + try { return & $Action } + catch { Record-Warning $Name $_; return $null } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + function Test-IsAdmin { + try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { return $false } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } + return ($Host.UI.RawUI -ne $null) + } + + # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, + # canonicalizes rooted paths (. / ..), strips trailing separators. Relative + # tokens are returned un-canonicalized (never resolved against the cwd). + # Original registry tokens are never rewritten — this is only a match key. + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = $Value.Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + if ([IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + } + return $expanded.TrimEnd('\', '/') + } + + # True when the path itself or any existing ancestor is a reparse point + # (junction/symlink). Fails CLOSED when inspection is impossible. + function Test-PathHasReparseComponent($Path) { + $p = $Path + while ($p -and -not (Test-Path -LiteralPath $p)) { + $p = Split-Path -Parent $p + } + if (-not $p) { return $false } + try { + $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop + while ($current) { + if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } + $current = $current.Parent + } + return $false + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + } + + # Reparse-point directories inside a tree, without ever descending into + # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). + function Get-NestedReparsePoints($Root) { + $found = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } + if ($isDir) { $stack.Push($e) } + } + } + return $found + } + + # Full tree listing (files + dirs) that never descends into reparse-point + # directories; the links themselves are returned as leaf directories. + function Get-TreeSafe($Root) { + $files = New-Object System.Collections.Generic.List[string] + $dirs = New-Object System.Collections.Generic.List[string] + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($Root) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + $entries = $null + try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } + foreach ($e in $entries) { + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if (-not $isDir) { $files.Add($e); continue } + $dirs.Add($e) + if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf + $stack.Push($e) + } + } + return [pscustomobject]@{ Files = $files; Dirs = $dirs } + } + + # --- Path safety: the ONLY guard between a registry value and recursive + # deletion / execution. Returns the canonical dir or $null. + function Get-SafeInstallDirectory($Candidate) { + if (-not $Candidate) { return $null } + $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) + if (-not [IO.Path]::IsPathRooted($expanded)) { + Record-Warning "ignoring non-absolute install path: $Candidate" $null + return $null + } + try { $raw = [IO.Path]::GetFullPath($expanded) } + catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } + + $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') + $full = $raw.TrimEnd('\', '/') + if ($full -eq "" -or $full -ieq $root) { + Record-Warning "refusing filesystem root as install dir: $raw" $null + return $null + } + + # Never touch critical directories or any ancestor of them. + $critical = @( + [Environment]::GetFolderPath("Windows"), + [Environment]::GetFolderPath("ProgramFiles"), + [Environment]::GetFolderPath("ProgramFilesX86"), + [Environment]::GetFolderPath("UserProfile"), + [Environment]::GetFolderPath("CommonApplicationData"), + $env:SystemDrive + ) | Where-Object { $_ } + foreach ($c in $critical) { + $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') + if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing critical directory as install dir: $full" $null + return $null + } + } + + if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + Record-Warning "refusing directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing path with a reparse-point component: $full" $null + return $null + } + + # The default location is always plausible; custom locations must contain + # on-disk evidence of a real install. + if ($full -ieq $State.DefaultInstallDir) { return $full } + if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or + (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + return $full + } + Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + return $null + } + + # An uninstaller executable is trusted only when it looks like an Inno + # uninstaller (unins.exe) AND lives directly in a validated install dir. + function Test-TrustedUninstaller($Exe, $Dir) { + if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + try { + $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') + $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') + } catch { return $false } + return ($parent -ieq $dd) + } + + function Test-PathUnderDirs($ProcessPath, $Dirs) { + if (-not $ProcessPath) { return $false } + try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } + foreach ($d in $Dirs) { + $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') + if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + } + return $false + } + + # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = @() + foreach ($hive in @("CurrentUser", "LocalMachine")) { + foreach ($view in @("Registry64", "Registry32")) { + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + if ($key) { + $uninstall = $key.GetValue("UninstallString") + $quiet = $key.GetValue("QuietUninstallString") + $location = $key.GetValue("InstallLocation") + if ($uninstall -or $quiet) { + $entries += [pscustomobject]@{ + Hive = $hive; View = $view + UninstallString = $uninstall; QuietUninstallString = $quiet + InstallLocation = $location + } + } + } + # key absent = not installed at this scope/view; not an error. + } catch { + Record-Warning "could not inspect $hive\$view uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + return $entries + } + + function Get-UninstallerPath($entry) { + $raw = $entry.QuietUninstallString + if (-not $raw) { $raw = $entry.UninstallString } + if ($raw) { + $match = [regex]::Match($raw, '^"([^"]+)"') + if ($match.Success) { return $match.Groups[1].Value } + $match = [regex]::Match($raw, '^(.*?\.exe)') + if ($match.Success) { return $match.Groups[1].Value } + } + return $null + } + + # Each registry entry becomes an installation record: hive/view, validated + # dir (or $null), uninstaller exe, and whether that exe is trusted. + # HKCU views alias the same key (no WOW64 redirection there), so user-scope + # records are deduplicated across views. + function Get-InstallationRecords($Entries) { + $records = @() + $seen = @{} + foreach ($e in $Entries) { + $dedupe = if ($e.Hive -eq "CurrentUser") { + "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } else { + "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + } + if ($seen.ContainsKey($dedupe)) { continue } + $seen[$dedupe] = $true + + $exe = Get-UninstallerPath $e + $dir = $null + $candidates = @($e.InstallLocation) + if ($exe) { $candidates += (Split-Path -Parent $exe) } + foreach ($c in $candidates) { + if (-not $c) { continue } + $dir = Get-SafeInstallDirectory $c + if ($dir) { break } + } + $trusted = $false + if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } + $records += [pscustomobject]@{ + Hive = $e.Hive; View = $e.View + Dir = $dir; Uninstaller = $exe; Trusted = $trusted + } + } + return $records + } + + # --- 2. Stop processes, but only ones rooted in a validated install dir. + function Stop-PythinkerProcesses($Dirs) { + $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) + if ($procs.Count -eq 0) { return } + + foreach ($p in $procs) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if (-not $procPath) { + Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + continue + } + if (-not (Test-PathUnderDirs $procPath $Dirs)) { + Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + continue + } + $start = $null + try { $start = $p.StartTime } catch { $start = $null } + + Step "Stopping $($p.ProcessName) (PID $($p.Id))" + Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { + Stop-Process -Id $p.Id -Force -ErrorAction Stop + } | Out-Null + + $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue + if (-not $survivor) { continue } + + # Revalidate identity before per-PID escalation (PID reuse race). + $sameStart = $false + if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } + $survivorPath = $null + try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } + $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) + if (-not ($sameStart -and $samePath)) { + Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + continue + } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if ($taskkill) { + Invoke-Step "taskkill failed for PID $($p.Id)" { + $out = & taskkill.exe /F /T /PID $p.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$out" } + } | Out-Null + } + } + } + + # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a + # registry-selected executable: machine-scope runs require an elevated shell. + function Invoke-InnoUninstaller($Exe, $Scope) { + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { + Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null + return + } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { + Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null + return + } + $parent = Split-Path -Parent $Exe + if (-not (Get-SafeInstallDirectory $parent)) { + Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null + return + } + if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null + return + } + + Step "Running Pythinker uninstaller ($Scope scope)" + $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + try { + $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null + return + } + OK "Uninstaller completed" + } catch { + Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + } + } + + # --- 4. Removal helpers + + # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for + # locked paths. Idempotent across repeated runs in the same session. + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop + } catch { } + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteTree($Path) { + if (-not (Initialize-PendingDelete)) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + $tree = Get-TreeSafe $Path + foreach ($f in $tree.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + # Deepest directories first so they are empty when their turn comes. + foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + } + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { $ok = $false } + return $ok + } + + # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, + # an absolute refusal to touch a filesystem root, and fail-closed reparse + # protection (never recurse through junctions/symlinks). + function Remove-PathRobust($Path, $What) { + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } + try { + $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') + if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { + Record-Unresolved "refusing to remove filesystem root: $Path" + return + } + } catch { Record-Unresolved "refusing malformed path: $Path"; return } + + if (Test-PathHasReparseComponent $Path) { + Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + return + } + $nested = @(Get-NestedReparsePoints $Path) + if ($nested.Count -gt 0) { + Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + return + } + + $lastErr = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + } catch { + $lastErr = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + if (Register-PendingDeleteTree $Path) { + $State.PendingReboot.Add($Path) + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + $hint = $lastErr + if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + Record-Warning "could not remove $What" $hint + } + + # Remove one directory from a registry PATH value via the .NET registry API: + # missing value = NotFound (not an error), original value kind preserved, + # non-matching entries kept verbatim. Comparison expands env vars + quotes. + function Remove-PathEntry($Dir, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return "NotFound" } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal + $kind = $key.GetValueKind("Path") + $key.Dispose(); $key = $null + + $target = Get-NormalizedPathToken $Dir + $matched = $false + $kept = @( + foreach ($e in ([string]$current -split ';')) { + if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + } + ) + if (-not $matched) { return "NotFound" } + $newPath = $kept -join ';' + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + return "Failed" + } + $key = $base.OpenSubKey($subkey, $true) + if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } + $key.SetValue("Path", $newPath, $kind) + Record-Removed "removed $Dir from $label" + return "Removed" + } catch { + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not update $label" $_ + } + return "Failed" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # True when a registry PATH value still contains any of $Dirs; $null when it + # could not be determined (caller must treat $null as UNRESOLVED). + function Test-PathEntryPresent($Dirs, $Hive) { + $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) + foreach ($e in ([string]$current -split ';')) { + $probe = Get-NormalizedPathToken $e + foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + # Strip the install dirs from this session's PATH so the current window is + # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. + function Remove-SessionPathEntries($Dirs) { + try { + $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + if ($targets.Count -eq 0) { return } + $kept = @($env:PATH -split ';' | Where-Object { + $probe = Get-NormalizedPathToken $_ + ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 + }) + $newPath = $kept -join ';' + if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } + } catch { Record-Warning "could not clean this session's PATH" $_ } + } + + # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. + function Send-EnvironmentBroadcast { + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) + } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + } + + # Delete registry keys ONLY for installations that were actually handled: + # validated dir and (files gone or pending reboot). Keys for unvalidated or + # unfinished installations stay in place and are reported as unresolved. + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($r in $Records) { + if (-not $r.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + continue + } + if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + continue + } + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) + $key = $base.OpenSubKey($uninstallPath) + if ($null -eq $key) { continue } # absent = already clean + $key.Dispose(); $key = $null + try { + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" + } catch { + if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null + } else { + Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ + } + } + } catch { + Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # Stale installer bootstrap temp dirs left by interrupted installs. Removed + # only when ALL ownership signals hold: strict name shape (GUID suffix), + # older than 1 hour, no setup process running, and contents limited to the + # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + return + } + $cutoff = (Get-Date).AddHours(-1) + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) + foreach ($t in $candidates) { + $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) + $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) + if ($foreign.Count -gt 0) { + Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + continue + } + Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + } + } + + # --- 5. Optional user-data purge (verified by Test-FinalState) + function Invoke-DataPurge { + if (-not (Test-Path -LiteralPath $DataDir)) { return } + + $purge = $false + if ($PurgeData -eq "1") { + $purge = $true + } elseif ($PurgeData -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { $purge = $false } + } + + if ($purge) { + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed + # clean becomes unresolved. The result succeeds only at zero unresolved. + function Test-FinalState($Dirs, $Records) { + foreach ($dir in $Dirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "install directory still present: $dir" + } + } + + foreach ($hive in @("CurrentUser", "LocalMachine")) { + $present = Test-PathEntryPresent $Dirs $hive + $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + if ($null -eq $present) { + Record-Unresolved "could not verify $label state" + } elseif ($present) { + $msg = "$label still contains a Pythinker entry" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + # HKCU has no WOW64 redirection here, so one view is authoritative there. + $combos = @( + @("CurrentUser", "Registry64"), + @("LocalMachine", "Registry64"), + @("LocalMachine", "Registry32") + ) + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in $combos) { + $hive = $combo[0]; $view = $combo[1] + $base = $null; $key = $null + $exists = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + $key = $base.OpenSubKey($uninstallPath) + $exists = ($null -ne $key) + } catch { + Record-Unresolved "could not verify $hive\$view uninstall registry state" + continue + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + if ($exists) { + $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) + if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place + $msg = "uninstall registry entry still present ($hive\$view)" + if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } + Record-Unresolved $msg + } + } + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { + if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { + Record-Unresolved "Start Menu shortcuts still present: $dir" + } + } + + foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { + $procPath = $null + try { $procPath = $p.Path } catch { $procPath = $null } + if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { + Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + } + } + + if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { + Record-Unresolved "requested user-data purge did not complete: $DataDir" + } + } + + # --- Main (function-wrapped: returns a structured result, never calls exit) + function Invoke-PythinkerUninstall { + # Last-resort safety net for anything no step caught. Scoped INSIDE this + # function so it can never swallow the top-level failure signal below. + trap { Record-Warning "unexpected error" $_; continue } + + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) + if ($null -eq $entries) { $entries = @() } + $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) + + $installDirs = New-Object System.Collections.Generic.List[string] + $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir + if ($defaultSafe) { + $installDirs.Add($defaultSafe) + } else { + Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" + } + foreach ($r in $records) { + if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + foreach ($r in $records) { + if (-not $r.Uninstaller) { continue } + if (-not $r.Trusted) { + Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null + continue + } + Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null + } + + # Sweep everything, whether or not an uninstaller ran (idempotent). + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + foreach ($dir in $installDirs) { + Remove-PathRobust $dir "install directory $dir" + Invoke-Step "PATH cleanup failed for $dir" { + [void](Remove-PathEntry $dir "CurrentUser") + [void](Remove-PathEntry $dir "LocalMachine") + } | Out-Null + } + Remove-SessionPathEntries $installDirs + + $startDirs = @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), + (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + ) + foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + # Final verification must never degrade to a warning: unknown = unresolved. + Step "Verifying final state" + try { + Test-FinalState $installDirs $records + } catch { + Record-Unresolved "final verification did not complete — $(Format-Err $_)" + } + + # --- Summary + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" + foreach ($w in $State.Warnings) { Dim " • $w" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" + foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + # The function's trap should make this unreachable, but a swallowed + # failure must never look like success. + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error + # and the host stays open; `powershell.exe -File` exits non-zero. + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +} From e80b2c3434d4b91b7e20645954522405d894a73a Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 19:05:06 -0400 Subject: [PATCH 3/6] refactor(installer): harden Windows uninstaller deletion and execution policy Replace the removal engine with a snapshot-based design (Get-SafeTreeSnapshot + Remove-SafeSnapshotNow): complete-or-fail-closed tree inspection, per-entry non-recursive deletes that never rely on PS 5.1 reparse traversal, and re-inspection before reboot scheduling. Tighten policy: recursive sweeps run only against known install dirs (LOCALAPPDATA + ProgramFiles x2); registry-selected custom installs are never swept or executed automatically - they get exact PATH-token removal and a manual-action report. Inno uninstallers execute only non-elevated, user-scope, from a known dir, as a trusted unins.exe; no registry selected binary ever runs with an elevated token. Also: canonicalize all registry-provided paths (reject roots, UNC/device, drive-relative, reparse components), re-read PATH under the writable handle before SetValue, broadcast WM_SETTINGCHANGE only on actual change, prove installer-temp ownership via AppId marker or verified SHA-256, handle 32-bit registry views natively, dedupe warnings, and bound the uninstaller run with a 10-minute timeout. Uses DirectorySeparatorChar in path-prefix checks (identical to '\' on Windows; keeps the logic testable cross-platform). --- CHANGELOG.md | 2 +- docs/public/uninstall.ps1 | 1765 +++++++++++++++++++++++++------------ scripts/uninstall.ps1 | 1765 +++++++++++++++++++++++++------------ web/public/uninstall.ps1 | 1765 +++++++++++++++++++++++++------------ 4 files changed, 3556 insertions(+), 1741 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6d1eba..cc2c779c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased -- Add `scripts/uninstall.ps1`, a Windows uninstaller served at `https://pythinker.com/uninstall.ps1` (`irm https://pythinker.com/uninstall.ps1 | iex`): runs the registered Inno uninstaller silently, sweeps validated install dirs/PATH entries/Start Menu shortcuts/uninstall registry keys/stale installer temp dirs, and verifies final state with fail-closed semantics. Registry-provided paths are never deleted or executed without validation (named `Pythinker` + on-disk evidence, no reparse points, trusted `unins.exe` only), processes are killed per-PID only from validated dirs, and the script never self-elevates or calls `exit`, so piping through `iex` cannot close the host. User data under `%USERPROFILE%\.pythinker` is kept unless `PYTHINKER_PURGE_DATA=1` is set. +- Add `scripts/uninstall.ps1`, a Windows uninstaller served at `https://pythinker.com/uninstall.ps1` (`irm https://pythinker.com/uninstall.ps1 | iex`): runs a trusted Inno uninstaller (non-elevated, user-scope, known directory only), then sweeps PATH entries (user + system, kind-preserved), Start Menu shortcuts, uninstall registry keys (native 32/64-bit views), and owned stale installer temp dirs, and verifies final state with fail-closed semantics. Recursive deletion is snapshot-based and limited to known install dirs — registry-selected custom installs are never swept or executed automatically (they are reported for manual action) — registry-provided paths are canonicalized and rejected on roots/UNC/device/drive-relative/reparse-point components, processes are killed per-PID only from validated dirs with StartTime/path revalidation, and the script never self-elevates or calls `exit`, so piping through `iex` cannot close the host. User data under `%USERPROFILE%\.pythinker` is kept unless `PYTHINKER_PURGE_DATA=1` is set. - Update all repository URLs and repo-slug constants to `Pythoughts-labs/pythinker-cli` after the GitHub repository rename (install scripts, updater, feedback default repo, docs, workflows, packaging). - Retry the post-update smoke check briefly before recording `VERIFICATION_FAILED`, absorbing the Homebrew launcher-relink race that falsely failed successful upgrades, and print the verification failure on screen instead of leaving "Updated successfully!" as the last word when verification genuinely fails. diff --git a/docs/public/uninstall.ps1 b/docs/public/uninstall.ps1 index 035cd766..7c595ac9 100644 --- a/docs/public/uninstall.ps1 +++ b/docs/public/uninstall.ps1 @@ -1,58 +1,30 @@ # Pythinker Code — native Windows uninstaller. # -# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: -# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. -# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + -# system, value kind preserved), Start Menu shortcuts, uninstall registry -# keys (both 32/64-bit views), stale installer temp dirs, session PATH. -# -# Session model: -# - Everything runs inside one anonymous child scope: no functions, variables, -# or preference settings leak into the caller's session when piped through -# `irm ... | iex`. Console encoding is restored on exit; TLS settings are -# never touched. The script never calls `exit` — failure surfaces as a -# thrown error, so iex cannot close the user's window while -# `powershell.exe -File` still gets a non-zero exit code. -# -# Safety model: -# - Registry-provided paths are NEVER deleted or executed blindly. A directory -# is only touched after Get-SafeInstallDirectory proves it is a plausible -# Pythinker install: absolute, not a filesystem root, not a critical -# directory, leaf named "Pythinker", containing no reparse-point component, -# and either the default location or containing pythinker.exe / unins000.exe. -# - Uninstaller executables must additionally be named unins.exe and live -# directly in a validated install dir. -# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): -# machine-scope work requires re-running the whole script elevated, which -# keeps a tampered user-writable file from becoming a privilege escalation. -# - Recursive deletion refuses any path that contains, or sits beneath, a -# reparse point (junction/symlink), and never descends into nested ones. -# - Processes are killed only when their executable path resolves inside a -# validated install dir; escalation is per-PID with StartTime+Path -# revalidation, never machine-wide by image name. -# - Registry uninstall keys are removed only for installations that were -# actually handled (files gone or pending reboot); keys for unvalidated -# installations are left in place and reported. -# -# Failure model: -# - Step failures are recorded as WARNINGS and the run continues. -# - A final verification phase inspects real machine state and FAILS CLOSED: -# anything it cannot confirm clean becomes an UNRESOLVED item, and the -# result succeeds only when zero items are unresolved. -# - Locked paths scheduled for deletion on next reboot are tracked separately. -# -# Usage (paste into PowerShell, or host and pipe like the installer): +# Usage: # irm https://pythinker.com/uninstall.ps1 | iex # -# User data (config, sessions, logs under $HOME\.pythinker): -# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) -# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking -# unset -> ask once when interactive; keep otherwise +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. & { $ErrorActionPreference = "Stop" - # Save/restore console encoding so the caller's session is untouched. $originalEncoding = $null try { $originalEncoding = [Console]::OutputEncoding @@ -60,22 +32,51 @@ } catch {} try { - $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" - $PurgeData = $env:PYTHINKER_PURGE_DATA - $NoColor = $env:NO_COLOR + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- - # --- Color detection (RawUI access can throw in some hosts; probe defensively) $ESC = [char]27 $useColor = $false if (-not $NoColor) { try { - if ($null -ne $Host.UI.RawUI) { - $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] - if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } - else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true } - } catch { $useColor = $false } + } catch { + $useColor = $false + } } + if ($useColor) { $NAVY = "$ESC[38;5;24m" $FACE = "$ESC[38;5;255m" @@ -88,47 +89,63 @@ $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" } - # --- All mutable state lives in one reference object inside this child - # scope; functions read it via normal (dynamic) scope lookup. No $script: - # variables exist, so nothing can leak into an iex caller's session. - $State = [pscustomobject]@{ - RemovedCount = 0 - Warnings = New-Object System.Collections.Generic.List[string] - Unresolved = New-Object System.Collections.Generic.List[string] - PendingReboot = New-Object System.Collections.Generic.List[string] - DefaultInstallDir = $null + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject } - function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } - function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } - function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } - function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } - function Record-Removed($what) { $State.RemovedCount++; OK $what } + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } - function Format-Err($err) { - if ($null -eq $err) { return "" } - if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } - return [string]$err + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What } - function Record-Warning($what, $err) { - $detail = $what - $message = Format-Err $err - if ($message) { $detail = "$what — $message" } - $State.Warnings.Add($detail) - Warn $detail + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } } - function Record-Unresolved($what) { - $State.Unresolved.Add($what) - Warn $what + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } } - # Isolated step runner: a throwing step becomes a warning, never an abort. - function Invoke-Step($Name, [scriptblock]$Action) { - try { return & $Action } - catch { Record-Warning $Name $_; return $null } + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } } function Write-Header { @@ -143,491 +160,927 @@ Write-Host "" } - if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - throw "This uninstaller is for Windows." - } - - $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" - $DataDir = Join-Path $HOME ".pythinker" - $TempRoot = [System.IO.Path]::GetTempPath() + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- function Test-IsAdmin { try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { return $false } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } } function Test-Interactive { if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } - try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } - return ($Host.UI.RawUI -ne $null) + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } } - # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, - # canonicalizes rooted paths (. / ..), strips trailing separators. Relative - # tokens are returned un-canonicalized (never resolved against the cwd). - # Original registry tokens are never rewritten — this is only a match key. function Get-NormalizedPathToken($Value) { if ($null -eq $Value) { return "" } - $clean = $Value.Trim().Trim('"') + $clean = ([string]$Value).Trim().Trim('"') if ($clean -eq "") { return "" } $expanded = [Environment]::ExpandEnvironmentVariables($clean) - if ([IO.Path]::IsPathRooted($expanded)) { - try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} } return $expanded.TrimEnd('\', '/') } - # True when the path itself or any existing ancestor is a reparse point - # (junction/symlink). Fails CLOSED when inspection is impossible. - function Test-PathHasReparseComponent($Path) { - $p = $Path - while ($p -and -not (Test-Path -LiteralPath $p)) { - $p = Split-Path -Parent $p - } - if (-not $p) { return $false } - try { - $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop - while ($current) { - if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } - $current = $current.Parent + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true } - return $false - } catch { - Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ - return $true } + return $false } - # Reparse-point directories inside a tree, without ever descending into - # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). - function Get-NestedReparsePoints($Root) { - $found = New-Object System.Collections.Generic.List[string] - $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) - while ($stack.Count -gt 0) { - $dir = $stack.Pop() - $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } - if ($isDir) { $stack.Push($e) } + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent } - return $found + + return $false } - # Full tree listing (files + dirs) that never descends into reparse-point - # directories; the links themselves are returned as leaf directories. - function Get-TreeSafe($Root) { + function Get-SafeTreeSnapshot($Root) { $files = New-Object System.Collections.Generic.List[string] - $dirs = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) + $stack.Push($fullRoot) + while ($stack.Count -gt 0) { - $dir = $stack.Pop() + $directory = $stack.Pop() $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if (-not $isDir) { $files.Add($e); continue } - $dirs.Add($e) - if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf - $stack.Push($e) + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } } } - return [pscustomobject]@{ Files = $files; Dirs = $dirs } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } } - # --- Path safety: the ONLY guard between a registry value and recursive - # deletion / execution. Returns the canonical dir or $null. function Get-SafeInstallDirectory($Candidate) { - if (-not $Candidate) { return $null } - $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) - if (-not [IO.Path]::IsPathRooted($expanded)) { - Record-Warning "ignoring non-absolute install path: $Candidate" $null + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null return $null } - try { $raw = [IO.Path]::GetFullPath($expanded) } - catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } - $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') - $full = $raw.TrimEnd('\', '/') - if ($full -eq "" -or $full -ieq $root) { - Record-Warning "refusing filesystem root as install dir: $raw" $null + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null return $null } - # Never touch critical directories or any ancestor of them. - $critical = @( - [Environment]::GetFolderPath("Windows"), - [Environment]::GetFolderPath("ProgramFiles"), - [Environment]::GetFolderPath("ProgramFilesX86"), - [Environment]::GetFolderPath("UserProfile"), - [Environment]::GetFolderPath("CommonApplicationData"), - $env:SystemDrive - ) | Where-Object { $_ } - foreach ($c in $critical) { - $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') - if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { - Record-Warning "refusing critical directory as install dir: $full" $null - return $null - } + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null } if ([IO.Path]::GetFileName($full) -ine "Pythinker") { - Record-Warning "refusing directory not named 'Pythinker': $full" $null + Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } if (Test-PathHasReparseComponent $full) { - Record-Warning "refusing path with a reparse-point component: $full" $null + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null return $null } - # The default location is always plausible; custom locations must contain - # on-disk evidence of a real install. - if ($full -ieq $State.DefaultInstallDir) { return $full } - if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or - (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. return $full } - Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null return $null } - # An uninstaller executable is trusted only when it looks like an Inno - # uninstaller (unins.exe) AND lives directly in a validated install dir. - function Test-TrustedUninstaller($Exe, $Dir) { - if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + try { - $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') - $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') - } catch { return $false } - return ($parent -ieq $dd) + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) } - function Test-PathUnderDirs($ProcessPath, $Dirs) { - if (-not $ProcessPath) { return $false } - try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } - foreach ($d in $Dirs) { - $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') - if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 } - return $false + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine } - # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) function Find-UninstallEntries { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - $entries = @() - foreach ($hive in @("CurrentUser", "LocalMachine")) { - foreach ($view in @("Registry64", "Registry32")) { - $base = $null; $key = $null - try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - if ($key) { - $uninstall = $key.GetValue("UninstallString") - $quiet = $key.GetValue("QuietUninstallString") - $location = $key.GetValue("InstallLocation") - if ($uninstall -or $quiet) { - $entries += [pscustomobject]@{ - Hive = $hive; View = $view - UninstallString = $uninstall; QuietUninstallString = $quiet - InstallLocation = $location - } - } - } - # key absent = not installed at this scope/view; not an error. - } catch { - Record-Warning "could not inspect $hive\$view uninstall registry" $_ - } finally { - if ($key) { $key.Dispose() } - if ($base) { $base.Dispose() } - } + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } } } + return $entries } - function Get-UninstallerPath($entry) { - $raw = $entry.QuietUninstallString - if (-not $raw) { $raw = $entry.UninstallString } - if ($raw) { - $match = [regex]::Match($raw, '^"([^"]+)"') - if ($match.Success) { return $match.Groups[1].Value } - $match = [regex]::Match($raw, '^(.*?\.exe)') - if ($match.Success) { return $match.Groups[1].Value } + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) } + return $null } - # Each registry entry becomes an installation record: hive/view, validated - # dir (or $null), uninstaller exe, and whether that exe is trusted. - # HKCU views alias the same key (no WOW64 redirection there), so user-scope - # records are deduplicated across views. function Get-InstallationRecords($Entries) { - $records = @() - $seen = @{} - foreach ($e in $Entries) { - $dedupe = if ($e.Hive -eq "CurrentUser") { - "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" - } else { - "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } } - if ($seen.ContainsKey($dedupe)) { continue } - $seen[$dedupe] = $true - $exe = Get-UninstallerPath $e $dir = $null - $candidates = @($e.InstallLocation) - if ($exe) { $candidates += (Split-Path -Parent $exe) } - foreach ($c in $candidates) { - if (-not $c) { continue } - $dir = Get-SafeInstallDirectory $c - if ($dir) { break } + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null } + $trusted = $false - if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } - $records += [pscustomobject]@{ - Hive = $e.Hive; View = $e.View - Dir = $dir; Uninstaller = $exe; Trusted = $trusted + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) } + return $records } - # --- 2. Stop processes, but only ones rooted in a validated install dir. - function Stop-PythinkerProcesses($Dirs) { - $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) - if ($procs.Count -eq 0) { return } + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- - foreach ($p in $procs) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if (-not $procPath) { - Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null continue } - if (-not (Test-PathUnderDirs $procPath $Dirs)) { - Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" continue } - $start = $null - try { $start = $p.StartTime } catch { $start = $null } - Step "Stopping $($p.ProcessName) (PID $($p.Id))" - Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { - Stop-Process -Id $p.Id -Force -ErrorAction Stop - } | Out-Null + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } - $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue - if (-not $survivor) { continue } + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } - # Revalidate identity before per-PID escalation (PID reuse race). + $survivorPath = Get-ProcessExecutablePath $survivor $sameStart = $false - if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } - $survivorPath = $null - try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } - $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) - if (-not ($sameStart -and $samePath)) { - Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError continue } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue - if ($taskkill) { - Invoke-Step "taskkill failed for PID $($p.Id)" { - $out = & taskkill.exe /F /T /PID $p.Id 2>&1 - if ($LASTEXITCODE -ne 0) { throw "$out" } - } | Out-Null + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ } } } - # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a - # registry-selected executable: machine-scope runs require an elevated shell. - function Invoke-InnoUninstaller($Exe, $Scope) { - if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { - Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null - return - } - if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { - Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null - return + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false } - $parent = Split-Path -Parent $Exe - if (-not (Get-SafeInstallDirectory $parent)) { - Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null - return + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false } - if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null - return + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false } - Step "Running Pythinker uninstaller ($Scope scope)" - $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null try { - $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + if ($process.ExitCode -ne 0) { - Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null - return + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false } + OK "Uninstaller completed" + return $true } catch { - Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } } } - # --- 4. Removal helpers + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- - # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for - # locked paths. Idempotent across repeated runs in the same session. function Initialize-PendingDelete { if ("Win32.PendingDelete" -as [type]) { return $true } try { - $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' - Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop - } catch { } + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} return ($null -ne ("Win32.PendingDelete" -as [type])) } - function Register-PendingDeleteTree($Path) { + function Register-PendingDeleteSnapshot($Path, $Snapshot) { if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 $ok = $true - $tree = Get-TreeSafe $Path - foreach ($f in $tree.Files) { + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } - # Deepest directories first so they are empty when their turn comes. - foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } + try { - if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + return $ok } - # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, - # an absolute refusal to touch a filesystem root, and fail-closed reparse - # protection (never recurse through junctions/symlinks). - function Remove-PathRobust($Path, $What) { - if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } - try { - $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') - if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { - Record-Unresolved "refusing to remove filesystem root: $Path" - return + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop } - } catch { Record-Unresolved "refusing malformed path: $Path"; return } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } - if (Test-PathHasReparseComponent $Path) { - Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" return } - $nested = @(Get-NestedReparsePoints $Path) - if ($nested.Count -gt 0) { - Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" return } - $lastErr = $null + $lastError = $null for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + try { - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop - if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." } catch { - $lastErr = $_ + $lastError = $_ if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } } } - if (Register-PendingDeleteTree $Path) { - $State.PendingReboot.Add($Path) + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full OK "$What — locked now; scheduled for deletion on next reboot" return } - $hint = $lastErr - if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } Record-Warning "could not remove $What" $hint } - # Remove one directory from a registry PATH value via the .NET registry API: - # missing value = NotFound (not an error), original value kind preserved, - # non-matching entries kept verbatim. Comparison expands env vars + quotes. - function Remove-PathEntry($Dir, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - $base = $null; $key = $null + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") - $key = $base.OpenSubKey($subkey, $false) - if ($null -eq $key) { return "NotFound" } - $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) - if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal - $kind = $key.GetValueKind("Path") - $key.Dispose(); $key = $null + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } - $target = Get-NormalizedPathToken $Dir $matched = $false - $kept = @( - foreach ($e in ([string]$current -split ';')) { - if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break } - ) + } if (-not $matched) { return "NotFound" } - $newPath = $kept -join ';' + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null return "Failed" } - $key = $base.OpenSubKey($subkey, $true) - if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } - $key.SetValue("Path", $newPath, $kind) - Record-Removed "removed $Dir from $label" + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" return "Removed" } catch { - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ - } else { - Record-Warning "could not update $label" $_ - } + Record-Warning "could not update $label" $_ return "Failed" } finally { - if ($key) { $key.Dispose() } + if ($writeKey) { $writeKey.Dispose() } if ($base) { $base.Dispose() } } } - # True when a registry PATH value still contains any of $Dirs; $null when it - # could not be determined (caller must treat $null as UNRESOLVED). - function Test-PathEntryPresent($Dirs, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } - $base = $null; $key = $null + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) $key = $base.OpenSubKey($subkey, $false) if ($null -eq $key) { return $false } $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($null -eq $current) { return $false } - $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) - foreach ($e in ([string]$current -split ';')) { - $probe = Get-NormalizedPathToken $e - foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } } return $false } catch { @@ -638,65 +1091,103 @@ } } - # Strip the install dirs from this session's PATH so the current window is - # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. - function Remove-SessionPathEntries($Dirs) { + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } try { - $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } if ($targets.Count -eq 0) { return } - $kept = @($env:PATH -split ';' | Where-Object { - $probe = Get-NormalizedPathToken $_ - ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 - }) - $newPath = $kept -join ';' - if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } - } catch { Record-Warning "could not clean this session's PATH" $_ } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } } - # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } try { if (-not ("Win32.UninstallNativeMethods" -as [type])) { - $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' - Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop } $result = [UIntPtr]::Zero - [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) - } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } } - # Delete registry keys ONLY for installations that were actually handled: - # validated dir and (files gone or pending reboot). Keys for unvalidated or - # unfinished installations stay in place and are reported as unresolved. + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + function Remove-HandledRegistryEntries($Records) { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($r in $Records) { - if (-not $r.Dir) { - Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" continue } - if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { - Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" continue } - $base = $null; $key = $null + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) - $key = $base.OpenSubKey($uninstallPath) - if ($null -eq $key) { continue } # absent = already clean - $key.Dispose(); $key = $null - try { - $base.DeleteSubKeyTree($uninstallPath, $false) - Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" - } catch { - if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null - } else { - Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ - } - } + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" } catch { - Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } @@ -704,38 +1195,106 @@ } } - # Stale installer bootstrap temp dirs left by interrupted installs. Removed - # only when ALL ownership signals hold: strict name shape (GUID suffix), - # older than 1 hour, no setup process running, and contents limited to the - # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + function Remove-StaleInstallerTempDirs { $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 if ($setupRunning) { - Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + Dim "a Pythinker setup is running; leaving installer temp directories untouched" return } - $cutoff = (Get-Date).AddHours(-1) - $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) - foreach ($t in $candidates) { - $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) - $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) - if ($foreign.Count -gt 0) { - Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" continue } - Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" } } - # --- 5. Optional user-data purge (verified by Test-FinalState) + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + function Invoke-DataPurge { - if (-not (Test-Path -LiteralPath $DataDir)) { return } + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } $purge = $false - if ($PurgeData -eq "1") { + if ($PurgeDataSetting -eq "1") { $purge = $true - } elseif ($PurgeData -eq "0") { + } elseif ($PurgeDataSetting -eq "0") { $purge = $false } elseif (Test-Interactive) { Write-Host "" @@ -743,10 +1302,13 @@ try { $answer = Read-Host " Delete it too? [y/N]" $purge = ($answer -match '^(?i)y(es)?$') - } catch { $purge = $false } + } catch { + $purge = $false + } } if ($purge) { + $State.PurgeDataRequested = $true Remove-PathRobust $DataDir "user data $DataDir" } else { Write-Host "" @@ -754,179 +1316,226 @@ } } - # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed - # clean becomes unresolved. The result succeeds only at zero unresolved. - function Test-FinalState($Dirs, $Records) { - foreach ($dir in $Dirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "install directory still present: $dir" - } + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" } + } - foreach ($hive in @("CurrentUser", "LocalMachine")) { - $present = Test-PathEntryPresent $Dirs $hive - $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - if ($null -eq $present) { - Record-Unresolved "could not verify $label state" - } elseif ($present) { - $msg = "$label still contains a Pythinker entry" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message } - # HKCU has no WOW64 redirection here, so one view is authoritative there. - $combos = @( - @("CurrentUser", "Registry64"), - @("LocalMachine", "Registry64"), - @("LocalMachine", "Registry32") - ) $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($combo in $combos) { - $hive = $combo[0]; $view = $combo[1] - $base = $null; $key = $null - $exists = $null + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - $exists = ($null -ne $key) + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } } catch { - Record-Unresolved "could not verify $hive\$view uninstall registry state" - continue + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } } - if ($exists) { - $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) - if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place - $msg = "uninstall registry entry still present ($hive\$view)" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "Start Menu shortcuts still present: $dir" - } + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" } - foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { - Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" } } - if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { - Record-Unresolved "requested user-data purge did not complete: $DataDir" + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } } } - # --- Main (function-wrapped: returns a structured result, never calls exit) - function Invoke-PythinkerUninstall { - # Last-resort safety net for anything no step caught. Scoped INSIDE this - # function so it can never swallow the top-level failure signal below. - trap { Record-Warning "unexpected error" $_; continue } + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + function Invoke-PythinkerUninstall { Write-Header Step "Uninstalling Pythinker Code" - $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) - if ($null -eq $entries) { $entries = @() } - $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) - + $entries = @() + $records = @() $installDirs = New-Object System.Collections.Generic.List[string] - $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir - if ($defaultSafe) { - $installDirs.Add($defaultSafe) - } else { - Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" - } - foreach ($r in $records) { - if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } - } + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] - if ($records.Count -eq 0) { - Dim "No registered Pythinker uninstaller found — running manual cleanup only" - } + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } - Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } - foreach ($r in $records) { - if (-not $r.Uninstaller) { continue } - if (-not $r.Trusted) { - Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null - continue + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" } - Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null - } - # Sweep everything, whether or not an uninstaller ran (idempotent). - Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null - foreach ($dir in $installDirs) { - Remove-PathRobust $dir "install directory $dir" - Invoke-Step "PATH cleanup failed for $dir" { - [void](Remove-PathEntry $dir "CurrentUser") - [void](Remove-PathEntry $dir "LocalMachine") - } | Out-Null - } - Remove-SessionPathEntries $installDirs + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" - Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null - Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } - Send-EnvironmentBroadcast - Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs - # Final verification must never degrade to a warning: unknown = unresolved. - Step "Verifying final state" - try { - Test-FinalState $installDirs $records + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } } catch { - Record-Unresolved "final verification did not complete — $(Format-Err $_)" + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" } - # --- Summary Write-Host "" Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" if ($State.PendingReboot.Count -gt 0) { Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" - foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } } if ($State.Warnings.Count -gt 0) { - Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" - foreach ($w in $State.Warnings) { Dim " • $w" } + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } } Write-Host "" if ($State.Unresolved.Count -gt 0) { - Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" - foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } Write-Host "" Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." Write-Host "" } elseif ($State.PendingReboot.Count -gt 0) { - Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." Write-Host "" } else { - Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." Write-Host "" } @@ -941,14 +1550,10 @@ $result = Invoke-PythinkerUninstall if ($null -eq $result) { - # The function's trap should make this unreachable, but a swallowed - # failure must never look like success. throw "Pythinker uninstall did not produce a result." } if (-not $result.Success) { - # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error - # and the host stays open; `powershell.exe -File` exits non-zero. - throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." } } finally { if ($originalEncoding) { diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 035cd766..7c595ac9 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -1,58 +1,30 @@ # Pythinker Code — native Windows uninstaller. # -# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: -# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. -# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + -# system, value kind preserved), Start Menu shortcuts, uninstall registry -# keys (both 32/64-bit views), stale installer temp dirs, session PATH. -# -# Session model: -# - Everything runs inside one anonymous child scope: no functions, variables, -# or preference settings leak into the caller's session when piped through -# `irm ... | iex`. Console encoding is restored on exit; TLS settings are -# never touched. The script never calls `exit` — failure surfaces as a -# thrown error, so iex cannot close the user's window while -# `powershell.exe -File` still gets a non-zero exit code. -# -# Safety model: -# - Registry-provided paths are NEVER deleted or executed blindly. A directory -# is only touched after Get-SafeInstallDirectory proves it is a plausible -# Pythinker install: absolute, not a filesystem root, not a critical -# directory, leaf named "Pythinker", containing no reparse-point component, -# and either the default location or containing pythinker.exe / unins000.exe. -# - Uninstaller executables must additionally be named unins.exe and live -# directly in a validated install dir. -# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): -# machine-scope work requires re-running the whole script elevated, which -# keeps a tampered user-writable file from becoming a privilege escalation. -# - Recursive deletion refuses any path that contains, or sits beneath, a -# reparse point (junction/symlink), and never descends into nested ones. -# - Processes are killed only when their executable path resolves inside a -# validated install dir; escalation is per-PID with StartTime+Path -# revalidation, never machine-wide by image name. -# - Registry uninstall keys are removed only for installations that were -# actually handled (files gone or pending reboot); keys for unvalidated -# installations are left in place and reported. -# -# Failure model: -# - Step failures are recorded as WARNINGS and the run continues. -# - A final verification phase inspects real machine state and FAILS CLOSED: -# anything it cannot confirm clean becomes an UNRESOLVED item, and the -# result succeeds only when zero items are unresolved. -# - Locked paths scheduled for deletion on next reboot are tracked separately. -# -# Usage (paste into PowerShell, or host and pipe like the installer): +# Usage: # irm https://pythinker.com/uninstall.ps1 | iex # -# User data (config, sessions, logs under $HOME\.pythinker): -# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) -# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking -# unset -> ask once when interactive; keep otherwise +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. & { $ErrorActionPreference = "Stop" - # Save/restore console encoding so the caller's session is untouched. $originalEncoding = $null try { $originalEncoding = [Console]::OutputEncoding @@ -60,22 +32,51 @@ } catch {} try { - $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" - $PurgeData = $env:PYTHINKER_PURGE_DATA - $NoColor = $env:NO_COLOR + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- - # --- Color detection (RawUI access can throw in some hosts; probe defensively) $ESC = [char]27 $useColor = $false if (-not $NoColor) { try { - if ($null -ne $Host.UI.RawUI) { - $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] - if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } - else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true } - } catch { $useColor = $false } + } catch { + $useColor = $false + } } + if ($useColor) { $NAVY = "$ESC[38;5;24m" $FACE = "$ESC[38;5;255m" @@ -88,47 +89,63 @@ $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" } - # --- All mutable state lives in one reference object inside this child - # scope; functions read it via normal (dynamic) scope lookup. No $script: - # variables exist, so nothing can leak into an iex caller's session. - $State = [pscustomobject]@{ - RemovedCount = 0 - Warnings = New-Object System.Collections.Generic.List[string] - Unresolved = New-Object System.Collections.Generic.List[string] - PendingReboot = New-Object System.Collections.Generic.List[string] - DefaultInstallDir = $null + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject } - function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } - function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } - function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } - function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } - function Record-Removed($what) { $State.RemovedCount++; OK $what } + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } - function Format-Err($err) { - if ($null -eq $err) { return "" } - if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } - return [string]$err + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What } - function Record-Warning($what, $err) { - $detail = $what - $message = Format-Err $err - if ($message) { $detail = "$what — $message" } - $State.Warnings.Add($detail) - Warn $detail + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } } - function Record-Unresolved($what) { - $State.Unresolved.Add($what) - Warn $what + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } } - # Isolated step runner: a throwing step becomes a warning, never an abort. - function Invoke-Step($Name, [scriptblock]$Action) { - try { return & $Action } - catch { Record-Warning $Name $_; return $null } + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } } function Write-Header { @@ -143,491 +160,927 @@ Write-Host "" } - if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - throw "This uninstaller is for Windows." - } - - $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" - $DataDir = Join-Path $HOME ".pythinker" - $TempRoot = [System.IO.Path]::GetTempPath() + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- function Test-IsAdmin { try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { return $false } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } } function Test-Interactive { if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } - try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } - return ($Host.UI.RawUI -ne $null) + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } } - # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, - # canonicalizes rooted paths (. / ..), strips trailing separators. Relative - # tokens are returned un-canonicalized (never resolved against the cwd). - # Original registry tokens are never rewritten — this is only a match key. function Get-NormalizedPathToken($Value) { if ($null -eq $Value) { return "" } - $clean = $Value.Trim().Trim('"') + $clean = ([string]$Value).Trim().Trim('"') if ($clean -eq "") { return "" } $expanded = [Environment]::ExpandEnvironmentVariables($clean) - if ([IO.Path]::IsPathRooted($expanded)) { - try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} } return $expanded.TrimEnd('\', '/') } - # True when the path itself or any existing ancestor is a reparse point - # (junction/symlink). Fails CLOSED when inspection is impossible. - function Test-PathHasReparseComponent($Path) { - $p = $Path - while ($p -and -not (Test-Path -LiteralPath $p)) { - $p = Split-Path -Parent $p - } - if (-not $p) { return $false } - try { - $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop - while ($current) { - if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } - $current = $current.Parent + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true } - return $false - } catch { - Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ - return $true } + return $false } - # Reparse-point directories inside a tree, without ever descending into - # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). - function Get-NestedReparsePoints($Root) { - $found = New-Object System.Collections.Generic.List[string] - $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) - while ($stack.Count -gt 0) { - $dir = $stack.Pop() - $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } - if ($isDir) { $stack.Push($e) } + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent } - return $found + + return $false } - # Full tree listing (files + dirs) that never descends into reparse-point - # directories; the links themselves are returned as leaf directories. - function Get-TreeSafe($Root) { + function Get-SafeTreeSnapshot($Root) { $files = New-Object System.Collections.Generic.List[string] - $dirs = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) + $stack.Push($fullRoot) + while ($stack.Count -gt 0) { - $dir = $stack.Pop() + $directory = $stack.Pop() $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if (-not $isDir) { $files.Add($e); continue } - $dirs.Add($e) - if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf - $stack.Push($e) + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } } } - return [pscustomobject]@{ Files = $files; Dirs = $dirs } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } } - # --- Path safety: the ONLY guard between a registry value and recursive - # deletion / execution. Returns the canonical dir or $null. function Get-SafeInstallDirectory($Candidate) { - if (-not $Candidate) { return $null } - $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) - if (-not [IO.Path]::IsPathRooted($expanded)) { - Record-Warning "ignoring non-absolute install path: $Candidate" $null + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null return $null } - try { $raw = [IO.Path]::GetFullPath($expanded) } - catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } - $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') - $full = $raw.TrimEnd('\', '/') - if ($full -eq "" -or $full -ieq $root) { - Record-Warning "refusing filesystem root as install dir: $raw" $null + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null return $null } - # Never touch critical directories or any ancestor of them. - $critical = @( - [Environment]::GetFolderPath("Windows"), - [Environment]::GetFolderPath("ProgramFiles"), - [Environment]::GetFolderPath("ProgramFilesX86"), - [Environment]::GetFolderPath("UserProfile"), - [Environment]::GetFolderPath("CommonApplicationData"), - $env:SystemDrive - ) | Where-Object { $_ } - foreach ($c in $critical) { - $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') - if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { - Record-Warning "refusing critical directory as install dir: $full" $null - return $null - } + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null } if ([IO.Path]::GetFileName($full) -ine "Pythinker") { - Record-Warning "refusing directory not named 'Pythinker': $full" $null + Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } if (Test-PathHasReparseComponent $full) { - Record-Warning "refusing path with a reparse-point component: $full" $null + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null return $null } - # The default location is always plausible; custom locations must contain - # on-disk evidence of a real install. - if ($full -ieq $State.DefaultInstallDir) { return $full } - if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or - (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. return $full } - Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null return $null } - # An uninstaller executable is trusted only when it looks like an Inno - # uninstaller (unins.exe) AND lives directly in a validated install dir. - function Test-TrustedUninstaller($Exe, $Dir) { - if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + try { - $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') - $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') - } catch { return $false } - return ($parent -ieq $dd) + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) } - function Test-PathUnderDirs($ProcessPath, $Dirs) { - if (-not $ProcessPath) { return $false } - try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } - foreach ($d in $Dirs) { - $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') - if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 } - return $false + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine } - # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) function Find-UninstallEntries { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - $entries = @() - foreach ($hive in @("CurrentUser", "LocalMachine")) { - foreach ($view in @("Registry64", "Registry32")) { - $base = $null; $key = $null - try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - if ($key) { - $uninstall = $key.GetValue("UninstallString") - $quiet = $key.GetValue("QuietUninstallString") - $location = $key.GetValue("InstallLocation") - if ($uninstall -or $quiet) { - $entries += [pscustomobject]@{ - Hive = $hive; View = $view - UninstallString = $uninstall; QuietUninstallString = $quiet - InstallLocation = $location - } - } - } - # key absent = not installed at this scope/view; not an error. - } catch { - Record-Warning "could not inspect $hive\$view uninstall registry" $_ - } finally { - if ($key) { $key.Dispose() } - if ($base) { $base.Dispose() } - } + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } } } + return $entries } - function Get-UninstallerPath($entry) { - $raw = $entry.QuietUninstallString - if (-not $raw) { $raw = $entry.UninstallString } - if ($raw) { - $match = [regex]::Match($raw, '^"([^"]+)"') - if ($match.Success) { return $match.Groups[1].Value } - $match = [regex]::Match($raw, '^(.*?\.exe)') - if ($match.Success) { return $match.Groups[1].Value } + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) } + return $null } - # Each registry entry becomes an installation record: hive/view, validated - # dir (or $null), uninstaller exe, and whether that exe is trusted. - # HKCU views alias the same key (no WOW64 redirection there), so user-scope - # records are deduplicated across views. function Get-InstallationRecords($Entries) { - $records = @() - $seen = @{} - foreach ($e in $Entries) { - $dedupe = if ($e.Hive -eq "CurrentUser") { - "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" - } else { - "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } } - if ($seen.ContainsKey($dedupe)) { continue } - $seen[$dedupe] = $true - $exe = Get-UninstallerPath $e $dir = $null - $candidates = @($e.InstallLocation) - if ($exe) { $candidates += (Split-Path -Parent $exe) } - foreach ($c in $candidates) { - if (-not $c) { continue } - $dir = Get-SafeInstallDirectory $c - if ($dir) { break } + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null } + $trusted = $false - if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } - $records += [pscustomobject]@{ - Hive = $e.Hive; View = $e.View - Dir = $dir; Uninstaller = $exe; Trusted = $trusted + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) } + return $records } - # --- 2. Stop processes, but only ones rooted in a validated install dir. - function Stop-PythinkerProcesses($Dirs) { - $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) - if ($procs.Count -eq 0) { return } + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- - foreach ($p in $procs) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if (-not $procPath) { - Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null continue } - if (-not (Test-PathUnderDirs $procPath $Dirs)) { - Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" continue } - $start = $null - try { $start = $p.StartTime } catch { $start = $null } - Step "Stopping $($p.ProcessName) (PID $($p.Id))" - Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { - Stop-Process -Id $p.Id -Force -ErrorAction Stop - } | Out-Null + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } - $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue - if (-not $survivor) { continue } + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } - # Revalidate identity before per-PID escalation (PID reuse race). + $survivorPath = Get-ProcessExecutablePath $survivor $sameStart = $false - if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } - $survivorPath = $null - try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } - $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) - if (-not ($sameStart -and $samePath)) { - Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError continue } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue - if ($taskkill) { - Invoke-Step "taskkill failed for PID $($p.Id)" { - $out = & taskkill.exe /F /T /PID $p.Id 2>&1 - if ($LASTEXITCODE -ne 0) { throw "$out" } - } | Out-Null + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ } } } - # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a - # registry-selected executable: machine-scope runs require an elevated shell. - function Invoke-InnoUninstaller($Exe, $Scope) { - if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { - Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null - return - } - if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { - Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null - return + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false } - $parent = Split-Path -Parent $Exe - if (-not (Get-SafeInstallDirectory $parent)) { - Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null - return + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false } - if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null - return + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false } - Step "Running Pythinker uninstaller ($Scope scope)" - $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null try { - $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + if ($process.ExitCode -ne 0) { - Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null - return + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false } + OK "Uninstaller completed" + return $true } catch { - Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } } } - # --- 4. Removal helpers + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- - # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for - # locked paths. Idempotent across repeated runs in the same session. function Initialize-PendingDelete { if ("Win32.PendingDelete" -as [type]) { return $true } try { - $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' - Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop - } catch { } + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} return ($null -ne ("Win32.PendingDelete" -as [type])) } - function Register-PendingDeleteTree($Path) { + function Register-PendingDeleteSnapshot($Path, $Snapshot) { if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 $ok = $true - $tree = Get-TreeSafe $Path - foreach ($f in $tree.Files) { + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } - # Deepest directories first so they are empty when their turn comes. - foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } + try { - if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + return $ok } - # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, - # an absolute refusal to touch a filesystem root, and fail-closed reparse - # protection (never recurse through junctions/symlinks). - function Remove-PathRobust($Path, $What) { - if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } - try { - $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') - if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { - Record-Unresolved "refusing to remove filesystem root: $Path" - return + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop } - } catch { Record-Unresolved "refusing malformed path: $Path"; return } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } - if (Test-PathHasReparseComponent $Path) { - Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" return } - $nested = @(Get-NestedReparsePoints $Path) - if ($nested.Count -gt 0) { - Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" return } - $lastErr = $null + $lastError = $null for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + try { - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop - if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." } catch { - $lastErr = $_ + $lastError = $_ if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } } } - if (Register-PendingDeleteTree $Path) { - $State.PendingReboot.Add($Path) + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full OK "$What — locked now; scheduled for deletion on next reboot" return } - $hint = $lastErr - if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } Record-Warning "could not remove $What" $hint } - # Remove one directory from a registry PATH value via the .NET registry API: - # missing value = NotFound (not an error), original value kind preserved, - # non-matching entries kept verbatim. Comparison expands env vars + quotes. - function Remove-PathEntry($Dir, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - $base = $null; $key = $null + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") - $key = $base.OpenSubKey($subkey, $false) - if ($null -eq $key) { return "NotFound" } - $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) - if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal - $kind = $key.GetValueKind("Path") - $key.Dispose(); $key = $null + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } - $target = Get-NormalizedPathToken $Dir $matched = $false - $kept = @( - foreach ($e in ([string]$current -split ';')) { - if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break } - ) + } if (-not $matched) { return "NotFound" } - $newPath = $kept -join ';' + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null return "Failed" } - $key = $base.OpenSubKey($subkey, $true) - if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } - $key.SetValue("Path", $newPath, $kind) - Record-Removed "removed $Dir from $label" + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" return "Removed" } catch { - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ - } else { - Record-Warning "could not update $label" $_ - } + Record-Warning "could not update $label" $_ return "Failed" } finally { - if ($key) { $key.Dispose() } + if ($writeKey) { $writeKey.Dispose() } if ($base) { $base.Dispose() } } } - # True when a registry PATH value still contains any of $Dirs; $null when it - # could not be determined (caller must treat $null as UNRESOLVED). - function Test-PathEntryPresent($Dirs, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } - $base = $null; $key = $null + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) $key = $base.OpenSubKey($subkey, $false) if ($null -eq $key) { return $false } $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($null -eq $current) { return $false } - $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) - foreach ($e in ([string]$current -split ';')) { - $probe = Get-NormalizedPathToken $e - foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } } return $false } catch { @@ -638,65 +1091,103 @@ } } - # Strip the install dirs from this session's PATH so the current window is - # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. - function Remove-SessionPathEntries($Dirs) { + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } try { - $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } if ($targets.Count -eq 0) { return } - $kept = @($env:PATH -split ';' | Where-Object { - $probe = Get-NormalizedPathToken $_ - ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 - }) - $newPath = $kept -join ';' - if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } - } catch { Record-Warning "could not clean this session's PATH" $_ } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } } - # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } try { if (-not ("Win32.UninstallNativeMethods" -as [type])) { - $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' - Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop } $result = [UIntPtr]::Zero - [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) - } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } } - # Delete registry keys ONLY for installations that were actually handled: - # validated dir and (files gone or pending reboot). Keys for unvalidated or - # unfinished installations stay in place and are reported as unresolved. + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + function Remove-HandledRegistryEntries($Records) { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($r in $Records) { - if (-not $r.Dir) { - Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" continue } - if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { - Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" continue } - $base = $null; $key = $null + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) - $key = $base.OpenSubKey($uninstallPath) - if ($null -eq $key) { continue } # absent = already clean - $key.Dispose(); $key = $null - try { - $base.DeleteSubKeyTree($uninstallPath, $false) - Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" - } catch { - if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null - } else { - Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ - } - } + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" } catch { - Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } @@ -704,38 +1195,106 @@ } } - # Stale installer bootstrap temp dirs left by interrupted installs. Removed - # only when ALL ownership signals hold: strict name shape (GUID suffix), - # older than 1 hour, no setup process running, and contents limited to the - # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + function Remove-StaleInstallerTempDirs { $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 if ($setupRunning) { - Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + Dim "a Pythinker setup is running; leaving installer temp directories untouched" return } - $cutoff = (Get-Date).AddHours(-1) - $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) - foreach ($t in $candidates) { - $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) - $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) - if ($foreign.Count -gt 0) { - Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" continue } - Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" } } - # --- 5. Optional user-data purge (verified by Test-FinalState) + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + function Invoke-DataPurge { - if (-not (Test-Path -LiteralPath $DataDir)) { return } + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } $purge = $false - if ($PurgeData -eq "1") { + if ($PurgeDataSetting -eq "1") { $purge = $true - } elseif ($PurgeData -eq "0") { + } elseif ($PurgeDataSetting -eq "0") { $purge = $false } elseif (Test-Interactive) { Write-Host "" @@ -743,10 +1302,13 @@ try { $answer = Read-Host " Delete it too? [y/N]" $purge = ($answer -match '^(?i)y(es)?$') - } catch { $purge = $false } + } catch { + $purge = $false + } } if ($purge) { + $State.PurgeDataRequested = $true Remove-PathRobust $DataDir "user data $DataDir" } else { Write-Host "" @@ -754,179 +1316,226 @@ } } - # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed - # clean becomes unresolved. The result succeeds only at zero unresolved. - function Test-FinalState($Dirs, $Records) { - foreach ($dir in $Dirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "install directory still present: $dir" - } + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" } + } - foreach ($hive in @("CurrentUser", "LocalMachine")) { - $present = Test-PathEntryPresent $Dirs $hive - $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - if ($null -eq $present) { - Record-Unresolved "could not verify $label state" - } elseif ($present) { - $msg = "$label still contains a Pythinker entry" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message } - # HKCU has no WOW64 redirection here, so one view is authoritative there. - $combos = @( - @("CurrentUser", "Registry64"), - @("LocalMachine", "Registry64"), - @("LocalMachine", "Registry32") - ) $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($combo in $combos) { - $hive = $combo[0]; $view = $combo[1] - $base = $null; $key = $null - $exists = $null + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - $exists = ($null -ne $key) + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } } catch { - Record-Unresolved "could not verify $hive\$view uninstall registry state" - continue + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } } - if ($exists) { - $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) - if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place - $msg = "uninstall registry entry still present ($hive\$view)" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "Start Menu shortcuts still present: $dir" - } + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" } - foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { - Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" } } - if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { - Record-Unresolved "requested user-data purge did not complete: $DataDir" + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } } } - # --- Main (function-wrapped: returns a structured result, never calls exit) - function Invoke-PythinkerUninstall { - # Last-resort safety net for anything no step caught. Scoped INSIDE this - # function so it can never swallow the top-level failure signal below. - trap { Record-Warning "unexpected error" $_; continue } + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + function Invoke-PythinkerUninstall { Write-Header Step "Uninstalling Pythinker Code" - $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) - if ($null -eq $entries) { $entries = @() } - $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) - + $entries = @() + $records = @() $installDirs = New-Object System.Collections.Generic.List[string] - $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir - if ($defaultSafe) { - $installDirs.Add($defaultSafe) - } else { - Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" - } - foreach ($r in $records) { - if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } - } + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] - if ($records.Count -eq 0) { - Dim "No registered Pythinker uninstaller found — running manual cleanup only" - } + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } - Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } - foreach ($r in $records) { - if (-not $r.Uninstaller) { continue } - if (-not $r.Trusted) { - Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null - continue + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" } - Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null - } - # Sweep everything, whether or not an uninstaller ran (idempotent). - Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null - foreach ($dir in $installDirs) { - Remove-PathRobust $dir "install directory $dir" - Invoke-Step "PATH cleanup failed for $dir" { - [void](Remove-PathEntry $dir "CurrentUser") - [void](Remove-PathEntry $dir "LocalMachine") - } | Out-Null - } - Remove-SessionPathEntries $installDirs + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" - Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null - Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } - Send-EnvironmentBroadcast - Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs - # Final verification must never degrade to a warning: unknown = unresolved. - Step "Verifying final state" - try { - Test-FinalState $installDirs $records + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } } catch { - Record-Unresolved "final verification did not complete — $(Format-Err $_)" + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" } - # --- Summary Write-Host "" Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" if ($State.PendingReboot.Count -gt 0) { Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" - foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } } if ($State.Warnings.Count -gt 0) { - Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" - foreach ($w in $State.Warnings) { Dim " • $w" } + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } } Write-Host "" if ($State.Unresolved.Count -gt 0) { - Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" - foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } Write-Host "" Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." Write-Host "" } elseif ($State.PendingReboot.Count -gt 0) { - Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." Write-Host "" } else { - Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." Write-Host "" } @@ -941,14 +1550,10 @@ $result = Invoke-PythinkerUninstall if ($null -eq $result) { - # The function's trap should make this unreachable, but a swallowed - # failure must never look like success. throw "Pythinker uninstall did not produce a result." } if (-not $result.Success) { - # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error - # and the host stays open; `powershell.exe -File` exits non-zero. - throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." } } finally { if ($originalEncoding) { diff --git a/web/public/uninstall.ps1 b/web/public/uninstall.ps1 index 035cd766..7c595ac9 100644 --- a/web/public/uninstall.ps1 +++ b/web/public/uninstall.ps1 @@ -1,58 +1,30 @@ # Pythinker Code — native Windows uninstaller. # -# Reverses everything `irm https://pythinker.com/install.ps1 | iex` sets up: -# 1. Runs registered Inno Setup uninstallers (unins000.exe) silently. -# 2. Sweeps installer artifacts: validated install dirs, PATH entries (user + -# system, value kind preserved), Start Menu shortcuts, uninstall registry -# keys (both 32/64-bit views), stale installer temp dirs, session PATH. -# -# Session model: -# - Everything runs inside one anonymous child scope: no functions, variables, -# or preference settings leak into the caller's session when piped through -# `irm ... | iex`. Console encoding is restored on exit; TLS settings are -# never touched. The script never calls `exit` — failure surfaces as a -# thrown error, so iex cannot close the user's window while -# `powershell.exe -File` still gets a non-zero exit code. -# -# Safety model: -# - Registry-provided paths are NEVER deleted or executed blindly. A directory -# is only touched after Get-SafeInstallDirectory proves it is a plausible -# Pythinker install: absolute, not a filesystem root, not a critical -# directory, leaf named "Pythinker", containing no reparse-point component, -# and either the default location or containing pythinker.exe / unins000.exe. -# - Uninstaller executables must additionally be named unins.exe and live -# directly in a validated install dir. -# - The script NEVER elevates a registry-selected executable (no -Verb RunAs): -# machine-scope work requires re-running the whole script elevated, which -# keeps a tampered user-writable file from becoming a privilege escalation. -# - Recursive deletion refuses any path that contains, or sits beneath, a -# reparse point (junction/symlink), and never descends into nested ones. -# - Processes are killed only when their executable path resolves inside a -# validated install dir; escalation is per-PID with StartTime+Path -# revalidation, never machine-wide by image name. -# - Registry uninstall keys are removed only for installations that were -# actually handled (files gone or pending reboot); keys for unvalidated -# installations are left in place and reported. -# -# Failure model: -# - Step failures are recorded as WARNINGS and the run continues. -# - A final verification phase inspects real machine state and FAILS CLOSED: -# anything it cannot confirm clean becomes an UNRESOLVED item, and the -# result succeeds only when zero items are unresolved. -# - Locked paths scheduled for deletion on next reboot are tracked separately. -# -# Usage (paste into PowerShell, or host and pipe like the installer): +# Usage: # irm https://pythinker.com/uninstall.ps1 | iex # -# User data (config, sessions, logs under $HOME\.pythinker): -# $env:PYTHINKER_PURGE_DATA = "1" -> delete it without asking (verified) -# $env:PYTHINKER_PURGE_DATA = "0" -> keep it without asking -# unset -> ask once when interactive; keep otherwise +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. & { $ErrorActionPreference = "Stop" - # Save/restore console encoding so the caller's session is untouched. $originalEncoding = $null try { $originalEncoding = [Console]::OutputEncoding @@ -60,22 +32,51 @@ } catch {} try { - $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" - $PurgeData = $env:PYTHINKER_PURGE_DATA - $NoColor = $env:NO_COLOR + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- - # --- Color detection (RawUI access can throw in some hosts; probe defensively) $ESC = [char]27 $useColor = $false if (-not $NoColor) { try { - if ($null -ne $Host.UI.RawUI) { - $vt = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] - if ($vt) { $useColor = [bool]$Host.UI.SupportsVirtualTerminal } - else { $useColor = ([Environment]::OSVersion.Version.Major -ge 10) } # Win10+ conhost parses ANSI + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true } - } catch { $useColor = $false } + } catch { + $useColor = $false + } } + if ($useColor) { $NAVY = "$ESC[38;5;24m" $FACE = "$ESC[38;5;255m" @@ -88,47 +89,63 @@ $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" } - # --- All mutable state lives in one reference object inside this child - # scope; functions read it via normal (dynamic) scope lookup. No $script: - # variables exist, so nothing can leak into an iex caller's session. - $State = [pscustomobject]@{ - RemovedCount = 0 - Warnings = New-Object System.Collections.Generic.List[string] - Unresolved = New-Object System.Collections.Generic.List[string] - PendingReboot = New-Object System.Collections.Generic.List[string] - DefaultInstallDir = $null + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject } - function Step($msg) { Write-Host " $IRIS⠿$RESET $msg" } - function OK($msg) { Write-Host " $IRIS✓$RESET $msg" } - function Warn($msg) { Write-Host " $CORAL!$RESET $msg" } - function Dim($msg) { Write-Host " ${DIM}$msg${RESET}" } + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } - function Record-Removed($what) { $State.RemovedCount++; OK $what } + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } - function Format-Err($err) { - if ($null -eq $err) { return "" } - if ($err -is [System.Management.Automation.ErrorRecord]) { return $err.Exception.Message } - return [string]$err + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What } - function Record-Warning($what, $err) { - $detail = $what - $message = Format-Err $err - if ($message) { $detail = "$what — $message" } - $State.Warnings.Add($detail) - Warn $detail + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } } - function Record-Unresolved($what) { - $State.Unresolved.Add($what) - Warn $what + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } } - # Isolated step runner: a throwing step becomes a warning, never an abort. - function Invoke-Step($Name, [scriptblock]$Action) { - try { return & $Action } - catch { Record-Warning $Name $_; return $null } + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } } function Write-Header { @@ -143,491 +160,927 @@ Write-Host "" } - if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - throw "This uninstaller is for Windows." - } - - $State.DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" - $DataDir = Join-Path $HOME ".pythinker" - $TempRoot = [System.IO.Path]::GetTempPath() + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- function Test-IsAdmin { try { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { return $false } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } } function Test-Interactive { if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } - try { if ([Console]::IsInputRedirected) { return $false } } catch { return $false } - return ($Host.UI.RawUI -ne $null) + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } } - # Canonical form for PATH comparisons ONLY: trims quotes, expands env vars, - # canonicalizes rooted paths (. / ..), strips trailing separators. Relative - # tokens are returned un-canonicalized (never resolved against the cwd). - # Original registry tokens are never rewritten — this is only a match key. function Get-NormalizedPathToken($Value) { if ($null -eq $Value) { return "" } - $clean = $Value.Trim().Trim('"') + $clean = ([string]$Value).Trim().Trim('"') if ($clean -eq "") { return "" } $expanded = [Environment]::ExpandEnvironmentVariables($clean) - if ([IO.Path]::IsPathRooted($expanded)) { - try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { } + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} } return $expanded.TrimEnd('\', '/') } - # True when the path itself or any existing ancestor is a reparse point - # (junction/symlink). Fails CLOSED when inspection is impossible. - function Test-PathHasReparseComponent($Path) { - $p = $Path - while ($p -and -not (Test-Path -LiteralPath $p)) { - $p = Split-Path -Parent $p - } - if (-not $p) { return $false } - try { - $current = Get-Item -LiteralPath $p -Force -ErrorAction Stop - while ($current) { - if ($current.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true } - $current = $current.Parent + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true } - return $false - } catch { - Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ - return $true } + return $false } - # Reparse-point directories inside a tree, without ever descending into - # them (raw .NET enumeration; PS 5.1 provider traversal is not trusted). - function Get-NestedReparsePoints($Root) { - $found = New-Object System.Collections.Generic.List[string] - $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) - while ($stack.Count -gt 0) { - $dir = $stack.Pop() - $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { $found.Add($e); continue } - if ($isDir) { $stack.Push($e) } + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent } - return $found + + return $false } - # Full tree listing (files + dirs) that never descends into reparse-point - # directories; the links themselves are returned as leaf directories. - function Get-TreeSafe($Root) { + function Get-SafeTreeSnapshot($Root) { $files = New-Object System.Collections.Generic.List[string] - $dirs = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + $stack = New-Object System.Collections.Generic.Stack[string] - $stack.Push($Root) + $stack.Push($fullRoot) + while ($stack.Count -gt 0) { - $dir = $stack.Pop() + $directory = $stack.Pop() $entries = $null - try { $entries = [IO.Directory]::EnumerateFileSystemEntries($dir) } catch { continue } - foreach ($e in $entries) { - $attrs = $null - try { $attrs = [IO.File]::GetAttributes($e) } catch { continue } - $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) - if (-not $isDir) { $files.Add($e); continue } - $dirs.Add($e) - if ($attrs -band [IO.FileAttributes]::ReparsePoint) { continue } # link is a leaf - $stack.Push($e) + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } } } - return [pscustomobject]@{ Files = $files; Dirs = $dirs } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } } - # --- Path safety: the ONLY guard between a registry value and recursive - # deletion / execution. Returns the canonical dir or $null. function Get-SafeInstallDirectory($Candidate) { - if (-not $Candidate) { return $null } - $expanded = [Environment]::ExpandEnvironmentVariables(($Candidate.Trim().Trim('"'))) - if (-not [IO.Path]::IsPathRooted($expanded)) { - Record-Warning "ignoring non-absolute install path: $Candidate" $null + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null return $null } - try { $raw = [IO.Path]::GetFullPath($expanded) } - catch { Record-Warning "ignoring malformed install path: $Candidate" $_; return $null } - $root = ([IO.Path]::GetPathRoot($raw)).TrimEnd('\', '/') - $full = $raw.TrimEnd('\', '/') - if ($full -eq "" -or $full -ieq $root) { - Record-Warning "refusing filesystem root as install dir: $raw" $null + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null return $null } - # Never touch critical directories or any ancestor of them. - $critical = @( - [Environment]::GetFolderPath("Windows"), - [Environment]::GetFolderPath("ProgramFiles"), - [Environment]::GetFolderPath("ProgramFilesX86"), - [Environment]::GetFolderPath("UserProfile"), - [Environment]::GetFolderPath("CommonApplicationData"), - $env:SystemDrive - ) | Where-Object { $_ } - foreach ($c in $critical) { - $cc = ([IO.Path]::GetFullPath($c)).TrimEnd('\', '/') - if ($full -ieq $cc -or $cc.StartsWith($full + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { - Record-Warning "refusing critical directory as install dir: $full" $null - return $null - } + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null } if ([IO.Path]::GetFileName($full) -ine "Pythinker") { - Record-Warning "refusing directory not named 'Pythinker': $full" $null + Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } if (Test-PathHasReparseComponent $full) { - Record-Warning "refusing path with a reparse-point component: $full" $null + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null return $null } - # The default location is always plausible; custom locations must contain - # on-disk evidence of a real install. - if ($full -ieq $State.DefaultInstallDir) { return $full } - if ((Test-Path -LiteralPath (Join-Path $full "pythinker.exe")) -or - (Test-Path -LiteralPath (Join-Path $full "unins000.exe"))) { + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. return $full } - Record-Warning "ignoring unrecognized install directory (no pythinker.exe or unins000.exe inside): $full" $null + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null return $null } - # An uninstaller executable is trusted only when it looks like an Inno - # uninstaller (unins.exe) AND lives directly in a validated install dir. - function Test-TrustedUninstaller($Exe, $Dir) { - if (-not $Exe -or -not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + try { - $parent = ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe))).TrimEnd('\', '/') - $dd = ([IO.Path]::GetFullPath($Dir)).TrimEnd('\', '/') - } catch { return $false } - return ($parent -ieq $dd) + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) } - function Test-PathUnderDirs($ProcessPath, $Dirs) { - if (-not $ProcessPath) { return $false } - try { $full = ([IO.Path]::GetFullPath($ProcessPath)).TrimEnd('\', '/') } catch { return $false } - foreach ($d in $Dirs) { - $dd = ([IO.Path]::GetFullPath($d)).TrimEnd('\', '/') - if ($full.StartsWith($dd + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or $full -ieq $dd) { return $true } + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 } - return $false + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine } - # --- 1. Registry discovery (both hives, 32/64-bit views; handles always disposed) function Find-UninstallEntries { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - $entries = @() - foreach ($hive in @("CurrentUser", "LocalMachine")) { - foreach ($view in @("Registry64", "Registry32")) { - $base = $null; $key = $null - try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - if ($key) { - $uninstall = $key.GetValue("UninstallString") - $quiet = $key.GetValue("QuietUninstallString") - $location = $key.GetValue("InstallLocation") - if ($uninstall -or $quiet) { - $entries += [pscustomobject]@{ - Hive = $hive; View = $view - UninstallString = $uninstall; QuietUninstallString = $quiet - InstallLocation = $location - } - } - } - # key absent = not installed at this scope/view; not an error. - } catch { - Record-Warning "could not inspect $hive\$view uninstall registry" $_ - } finally { - if ($key) { $key.Dispose() } - if ($base) { $base.Dispose() } - } + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } } } + return $entries } - function Get-UninstallerPath($entry) { - $raw = $entry.QuietUninstallString - if (-not $raw) { $raw = $entry.UninstallString } - if ($raw) { - $match = [regex]::Match($raw, '^"([^"]+)"') - if ($match.Success) { return $match.Groups[1].Value } - $match = [regex]::Match($raw, '^(.*?\.exe)') - if ($match.Success) { return $match.Groups[1].Value } + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) } + return $null } - # Each registry entry becomes an installation record: hive/view, validated - # dir (or $null), uninstaller exe, and whether that exe is trusted. - # HKCU views alias the same key (no WOW64 redirection there), so user-scope - # records are deduplicated across views. function Get-InstallationRecords($Entries) { - $records = @() - $seen = @{} - foreach ($e in $Entries) { - $dedupe = if ($e.Hive -eq "CurrentUser") { - "CU|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" - } else { - "LM|$($e.View)|$($e.UninstallString)|$($e.QuietUninstallString)|$($e.InstallLocation)" + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } } - if ($seen.ContainsKey($dedupe)) { continue } - $seen[$dedupe] = $true - $exe = Get-UninstallerPath $e $dir = $null - $candidates = @($e.InstallLocation) - if ($exe) { $candidates += (Split-Path -Parent $exe) } - foreach ($c in $candidates) { - if (-not $c) { continue } - $dir = Get-SafeInstallDirectory $c - if ($dir) { break } + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null } + $trusted = $false - if ($exe -and $dir) { $trusted = Test-TrustedUninstaller $exe $dir } - $records += [pscustomobject]@{ - Hive = $e.Hive; View = $e.View - Dir = $dir; Uninstaller = $exe; Trusted = $trusted + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) } + return $records } - # --- 2. Stop processes, but only ones rooted in a validated install dir. - function Stop-PythinkerProcesses($Dirs) { - $procs = @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue) - if ($procs.Count -eq 0) { return } + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- - foreach ($p in $procs) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if (-not $procPath) { - Record-Warning "cannot inspect $($p.ProcessName) (PID $($p.Id)) — likely elevated; leaving it running rather than killing an unidentified process" $null + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null continue } - if (-not (Test-PathUnderDirs $procPath $Dirs)) { - Dim "skipping $($p.ProcessName) (PID $($p.Id)) — $procPath is outside the install dir" + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" continue } - $start = $null - try { $start = $p.StartTime } catch { $start = $null } - Step "Stopping $($p.ProcessName) (PID $($p.Id))" - Invoke-Step "could not stop $($p.ProcessName) (PID $($p.Id))" { - Stop-Process -Id $p.Id -Force -ErrorAction Stop - } | Out-Null + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } - $survivor = Get-Process -Id $p.Id -ErrorAction SilentlyContinue - if (-not $survivor) { continue } + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } - # Revalidate identity before per-PID escalation (PID reuse race). + $survivorPath = Get-ProcessExecutablePath $survivor $sameStart = $false - if ($start) { try { $sameStart = ($survivor.StartTime -eq $start) } catch { $sameStart = $false } } - $survivorPath = $null - try { $survivorPath = $survivor.Path } catch { $survivorPath = $null } - $samePath = ($survivorPath -and ($survivorPath -ieq $procPath)) - if (-not ($sameStart -and $samePath)) { - Record-Warning "PID $($p.Id) identity changed after the stop attempt — refusing taskkill escalation (possible PID reuse)" $null + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError continue } + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue - if ($taskkill) { - Invoke-Step "taskkill failed for PID $($p.Id)" { - $out = & taskkill.exe /F /T /PID $p.Id 2>&1 - if ($LASTEXITCODE -ne 0) { throw "$out" } - } | Out-Null + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ } } } - # --- 3. Run a trusted Inno uninstaller silently. NEVER elevates a - # registry-selected executable: machine-scope runs require an elevated shell. - function Invoke-InnoUninstaller($Exe, $Scope) { - if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { - Record-Warning "registered uninstaller missing on disk: $Exe — using manual cleanup" $null - return - } - if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { - Record-Warning "refusing to run an executable that is not an Inno uninstaller: $Exe" $null - return + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false } - $parent = Split-Path -Parent $Exe - if (-not (Get-SafeInstallDirectory $parent)) { - Record-Warning "refusing to run uninstaller from an unvalidated directory: $Exe" $null - return + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false } - if ($Scope -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine-scope uninstall requires elevation — re-run this script from an Administrator PowerShell instead of elevating a registry-selected executable" $null - return + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false } - Step "Running Pythinker uninstaller ($Scope scope)" - $uninstArgs = @("/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES") + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null try { - $process = Start-Process -FilePath $Exe -ArgumentList $uninstArgs -Wait -PassThru -ErrorAction Stop + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + if ($process.ExitCode -ne 0) { - Record-Warning "uninstaller exited with code $($process.ExitCode) — sweeping what remains" $null - return + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false } + OK "Uninstaller completed" + return $true } catch { - Record-Warning "could not launch uninstaller $Exe — using manual cleanup" $_ + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } } } - # --- 4. Removal helpers + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- - # MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) — best-effort last resort for - # locked paths. Idempotent across repeated runs in the same session. function Initialize-PendingDelete { if ("Win32.PendingDelete" -as [type]) { return $true } try { - $sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' - Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $sig -ErrorAction Stop - } catch { } + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} return ($null -ne ("Win32.PendingDelete" -as [type])) } - function Register-PendingDeleteTree($Path) { + function Register-PendingDeleteSnapshot($Path, $Snapshot) { if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 $ok = $true - $tree = Get-TreeSafe $Path - foreach ($f in $tree.Files) { + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($f, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } - # Deepest directories first so they are empty when their turn comes. - foreach ($d in @($tree.Dirs | Sort-Object { $_.Length } -Descending)) { + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { try { - if (-not [Win32.PendingDelete]::MoveFileEx($d, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } } + try { - if (-not [Win32.PendingDelete]::MoveFileEx($Path, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } - } catch { $ok = $false } + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + return $ok } - # Remove a file/dir with retry + backoff, pending-delete-on-reboot fallback, - # an absolute refusal to touch a filesystem root, and fail-closed reparse - # protection (never recurse through junctions/symlinks). - function Remove-PathRobust($Path, $What) { - if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return } - try { - $pathRoot = ([IO.Path]::GetPathRoot($Path)).TrimEnd('\', '/') - if ($Path.TrimEnd('\', '/') -ieq $pathRoot) { - Record-Unresolved "refusing to remove filesystem root: $Path" - return + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop } - } catch { Record-Unresolved "refusing malformed path: $Path"; return } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } - if (Test-PathHasReparseComponent $Path) { - Record-Unresolved "refusing recursive deletion through a reparse point: $Path — inspect and remove it manually" + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" return } - $nested = @(Get-NestedReparsePoints $Path) - if ($nested.Count -gt 0) { - Record-Unresolved "refusing recursive deletion: $($nested.Count) reparse point(s) inside $Path (first: $($nested[0])) — remove them manually" + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" return } - $lastErr = $null + $lastError = $null for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + try { - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop - if (-not (Test-Path -LiteralPath $Path)) { Record-Removed $What; return } + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." } catch { - $lastErr = $_ + $lastError = $_ if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } } } - if (Register-PendingDeleteTree $Path) { - $State.PendingReboot.Add($Path) + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full OK "$What — locked now; scheduled for deletion on next reboot" return } - $hint = $lastErr - if (-not (Test-IsAdmin)) { $hint = "$lastErr (retry from an Administrator PowerShell may succeed)" } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } Record-Warning "could not remove $What" $hint } - # Remove one directory from a registry PATH value via the .NET registry API: - # missing value = NotFound (not an error), original value kind preserved, - # non-matching entries kept verbatim. Comparison expands env vars + quotes. - function Remove-PathEntry($Dir, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - $base = $null; $key = $null + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") - $key = $base.OpenSubKey($subkey, $false) - if ($null -eq $key) { return "NotFound" } - $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) - if ($null -eq $current) { return "NotFound" } # no user/system Path value is normal - $kind = $key.GetValueKind("Path") - $key.Dispose(); $key = $null + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } - $target = Get-NormalizedPathToken $Dir $matched = $false - $kept = @( - foreach ($e in ([string]$current -split ';')) { - if ((Get-NormalizedPathToken $e) -ieq $target) { $matched = $true } else { $e } + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break } - ) + } if (-not $matched) { return "NotFound" } - $newPath = $kept -join ';' + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label still contains $Dir — re-run from an Administrator PowerShell to clean it" $null + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null return "Failed" } - $key = $base.OpenSubKey($subkey, $true) - if ($null -eq $key) { Record-Warning "could not open $label for writing" $null; return "Failed" } - $key.SetValue("Path", $newPath, $kind) - Record-Removed "removed $Dir from $label" + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" return "Removed" } catch { - if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "$label could not be inspected without elevation — if it contains $Dir, re-run from an Administrator PowerShell" $_ - } else { - Record-Warning "could not update $label" $_ - } + Record-Warning "could not update $label" $_ return "Failed" } finally { - if ($key) { $key.Dispose() } + if ($writeKey) { $writeKey.Dispose() } if ($base) { $base.Dispose() } } } - # True when a registry PATH value still contains any of $Dirs; $null when it - # could not be determined (caller must treat $null as UNRESOLVED). - function Test-PathEntryPresent($Dirs, $Hive) { - $subkey = if ($Hive -eq "CurrentUser") { "Environment" } else { "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" } - $base = $null; $key = $null + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, "Registry64") + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) $key = $base.OpenSubKey($subkey, $false) if ($null -eq $key) { return $false } $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($null -eq $current) { return $false } - $targets = @($Dirs | ForEach-Object { Get-NormalizedPathToken $_ }) - foreach ($e in ([string]$current -split ';')) { - $probe = Get-NormalizedPathToken $e - foreach ($t in $targets) { if ($probe -ieq $t) { return $true } } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } } return $false } catch { @@ -638,65 +1091,103 @@ } } - # Strip the install dirs from this session's PATH so the current window is - # usable immediately. Non-matching tokens (including empty ones) are kept verbatim. - function Remove-SessionPathEntries($Dirs) { + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } try { - $targets = @($Dirs | Where-Object { $_ } | ForEach-Object { Get-NormalizedPathToken $_ }) + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } if ($targets.Count -eq 0) { return } - $kept = @($env:PATH -split ';' | Where-Object { - $probe = Get-NormalizedPathToken $_ - ($targets | Where-Object { $probe -ieq $_ }).Count -eq 0 - }) - $newPath = $kept -join ';' - if ($newPath -ne $env:PATH) { $env:PATH = $newPath; OK "Cleaned PATH for this session" } - } catch { Record-Warning "could not clean this session's PATH" $_ } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } } - # Tell Explorer & new processes the PATH changed. Idempotent across repeated runs. function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } try { if (-not ("Win32.UninstallNativeMethods" -as [type])) { - $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' - Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $sig -ErrorAction Stop + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop } $result = [UIntPtr]::Zero - [void][Win32.UninstallNativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) - } catch { Record-Warning "could not broadcast the environment change to the desktop" $_ } + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } } - # Delete registry keys ONLY for installations that were actually handled: - # validated dir and (files gone or pending reboot). Keys for unvalidated or - # unfinished installations stay in place and are reported as unresolved. + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + function Remove-HandledRegistryEntries($Records) { $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($r in $Records) { - if (-not $r.Dir) { - Record-Unresolved "registry entry for an unvalidated installation was left in place ($($r.Hive)\$($r.View)) — handle its files manually, then remove the key" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" continue } - if ((Test-Path -LiteralPath $r.Dir) -and -not $State.PendingReboot.Contains($r.Dir)) { - Record-Unresolved "registry entry left in place because files remain at $($r.Dir) ($($r.Hive)\$($r.View))" + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" continue } - $base = $null; $key = $null + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($r.Hive, $r.View) - $key = $base.OpenSubKey($uninstallPath) - if ($null -eq $key) { continue } # absent = already clean - $key.Dispose(); $key = $null - try { - $base.DeleteSubKeyTree($uninstallPath, $false) - Record-Removed "removed uninstall registry entry ($($r.Hive)\$($r.View))" - } catch { - if ($r.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { - Record-Warning "machine uninstall registry entry remains — re-run from an Administrator PowerShell to remove it" $null - } else { - Record-Warning "could not remove uninstall registry entry ($($r.Hive)\$($r.View))" $_ - } - } + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" } catch { - Record-Warning "could not access $($r.Hive)\$($r.View) uninstall registry for cleanup" $_ + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } @@ -704,38 +1195,106 @@ } } - # Stale installer bootstrap temp dirs left by interrupted installs. Removed - # only when ALL ownership signals hold: strict name shape (GUID suffix), - # older than 1 hour, no setup process running, and contents limited to the - # exact installer asset names (PythinkerSetup-x.y.z.exe[.sha256]). + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + function Remove-StaleInstallerTempDirs { $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 if ($setupRunning) { - Dim "a Pythinker setup is currently running — leaving installer temp dirs alone" + Dim "a Pythinker setup is running; leaving installer temp directories untouched" return } - $cutoff = (Get-Date).AddHours(-1) - $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^pythinker-install-[0-9a-fA-F]{32}$' -and $_.LastWriteTime -lt $cutoff }) - foreach ($t in $candidates) { - $children = @(Get-ChildItem -LiteralPath $t.FullName -Force -ErrorAction SilentlyContinue) - $foreign = @($children | Where-Object { $_.Name -notmatch '^PythinkerSetup-[\d.]+\.exe(\.sha256)?$' }) - if ($foreign.Count -gt 0) { - Dim "skipping $($t.FullName) — contents do not match Pythinker installer assets" + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" continue } - Remove-PathRobust $t.FullName "installer temp dir $($t.FullName)" + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" } } - # --- 5. Optional user-data purge (verified by Test-FinalState) + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + function Invoke-DataPurge { - if (-not (Test-Path -LiteralPath $DataDir)) { return } + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } $purge = $false - if ($PurgeData -eq "1") { + if ($PurgeDataSetting -eq "1") { $purge = $true - } elseif ($PurgeData -eq "0") { + } elseif ($PurgeDataSetting -eq "0") { $purge = $false } elseif (Test-Interactive) { Write-Host "" @@ -743,10 +1302,13 @@ try { $answer = Read-Host " Delete it too? [y/N]" $purge = ($answer -match '^(?i)y(es)?$') - } catch { $purge = $false } + } catch { + $purge = $false + } } if ($purge) { + $State.PurgeDataRequested = $true Remove-PathRobust $DataDir "user data $DataDir" } else { Write-Host "" @@ -754,179 +1316,226 @@ } } - # --- 6. Final verification, FAIL CLOSED: anything that cannot be confirmed - # clean becomes unresolved. The result succeeds only at zero unresolved. - function Test-FinalState($Dirs, $Records) { - foreach ($dir in $Dirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "install directory still present: $dir" - } + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" } + } - foreach ($hive in @("CurrentUser", "LocalMachine")) { - $present = Test-PathEntryPresent $Dirs $hive - $label = if ($hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } - if ($null -eq $present) { - Record-Unresolved "could not verify $label state" - } elseif ($present) { - $msg = "$label still contains a Pythinker entry" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message } - # HKCU has no WOW64 redirection here, so one view is authoritative there. - $combos = @( - @("CurrentUser", "Registry64"), - @("LocalMachine", "Registry64"), - @("LocalMachine", "Registry32") - ) $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" - foreach ($combo in $combos) { - $hive = $combo[0]; $view = $combo[1] - $base = $null; $key = $null - $exists = $null + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null try { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - $key = $base.OpenSubKey($uninstallPath) - $exists = ($null -ne $key) + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } } catch { - Record-Unresolved "could not verify $hive\$view uninstall registry state" - continue + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" } finally { if ($key) { $key.Dispose() } if ($base) { $base.Dispose() } } - if ($exists) { - $rec = @($Records | Where-Object { $_.Hive -eq $hive -and $_.View -eq $view } | Select-Object -First 1) - if ($rec.Count -gt 0 -and -not $rec[0].Dir) { continue } # already reported as left-in-place - $msg = "uninstall registry entry still present ($hive\$view)" - if ($hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { $msg += " — re-run from an Administrator PowerShell" } - Record-Unresolved $msg - } } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { - if ((Test-Path -LiteralPath $dir) -and -not $State.PendingReboot.Contains($dir)) { - Record-Unresolved "Start Menu shortcuts still present: $dir" - } + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" } - foreach ($p in @(Get-Process -Name "pythinker*" -ErrorAction SilentlyContinue)) { - $procPath = $null - try { $procPath = $p.Path } catch { $procPath = $null } - if ($procPath -and (Test-PathUnderDirs $procPath $Dirs)) { - Record-Unresolved "pythinker process still running from install dir (PID $($p.Id))" + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" } } - if ($PurgeData -eq "1" -and (Test-Path -LiteralPath $DataDir) -and -not $State.PendingReboot.Contains($DataDir)) { - Record-Unresolved "requested user-data purge did not complete: $DataDir" + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } } } - # --- Main (function-wrapped: returns a structured result, never calls exit) - function Invoke-PythinkerUninstall { - # Last-resort safety net for anything no step caught. Scoped INSIDE this - # function so it can never swallow the top-level failure signal below. - trap { Record-Warning "unexpected error" $_; continue } + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + function Invoke-PythinkerUninstall { Write-Header Step "Uninstalling Pythinker Code" - $entries = @(Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries }) - if ($null -eq $entries) { $entries = @() } - $records = @(Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries }) - + $entries = @() + $records = @() $installDirs = New-Object System.Collections.Generic.List[string] - $defaultSafe = Get-SafeInstallDirectory $State.DefaultInstallDir - if ($defaultSafe) { - $installDirs.Add($defaultSafe) - } else { - Record-Unresolved "default install directory failed safety validation ($($State.DefaultInstallDir)) — manual removal may be required" - } - foreach ($r in $records) { - if ($r.Dir -and -not $installDirs.Contains($r.Dir)) { $installDirs.Add($r.Dir) } - } + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] - if ($records.Count -eq 0) { - Dim "No registered Pythinker uninstaller found — running manual cleanup only" - } + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } - Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } - foreach ($r in $records) { - if (-not $r.Uninstaller) { continue } - if (-not $r.Trusted) { - Record-Warning "uninstaller failed trust validation (must be unins.exe inside a validated install dir): $($r.Uninstaller)" $null - continue + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" } - Invoke-Step "uninstaller run failed for $($r.Uninstaller)" { Invoke-InnoUninstaller $r.Uninstaller $r.Hive } | Out-Null - } - # Sweep everything, whether or not an uninstaller ran (idempotent). - Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null - foreach ($dir in $installDirs) { - Remove-PathRobust $dir "install directory $dir" - Invoke-Step "PATH cleanup failed for $dir" { - [void](Remove-PathEntry $dir "CurrentUser") - [void](Remove-PathEntry $dir "LocalMachine") - } | Out-Null - } - Remove-SessionPathEntries $installDirs + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } - $startDirs = @( - (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker"), - (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") - ) - foreach ($dir in $startDirs) { Remove-PathRobust $dir "Start Menu shortcuts ($dir)" } + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" - Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null - Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } - Send-EnvironmentBroadcast - Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs - # Final verification must never degrade to a warning: unknown = unresolved. - Step "Verifying final state" - try { - Test-FinalState $installDirs $records + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } } catch { - Record-Unresolved "final verification did not complete — $(Format-Err $_)" + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" } - # --- Summary Write-Host "" Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" if ($State.PendingReboot.Count -gt 0) { Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" - foreach ($p in $State.PendingReboot) { Write-Host " $CORAL•$RESET $p" } + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } } if ($State.Warnings.Count -gt 0) { - Write-Host " ${DIM}$($State.Warnings.Count) transient warning(s) during the run:${RESET}" - foreach ($w in $State.Warnings) { Dim " • $w" } + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } } Write-Host "" if ($State.Unresolved.Count -gt 0) { - Write-Host " $CORAL$($State.Unresolved.Count) thing(s) could not be fully removed or verified:${RESET}" - foreach ($u in $State.Unresolved) { Write-Host " $CORAL•$RESET $u" } + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } Write-Host "" Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." Write-Host "" } elseif ($State.PendingReboot.Count -gt 0) { - Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing $($State.PendingReboot.Count) locked item(s)." + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." Write-Host "" } else { - Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for PATH changes to apply." + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." Write-Host "" } @@ -941,14 +1550,10 @@ $result = Invoke-PythinkerUninstall if ($null -eq $result) { - # The function's trap should make this unreachable, but a swallowed - # failure must never look like success. throw "Pythinker uninstall did not produce a result." } if (-not $result.Success) { - # Top-level throw, OUTSIDE the function trap: `irm | iex` shows the error - # and the host stays open; `powershell.exe -File` exits non-zero. - throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see summary above." + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." } } finally { if ($originalEncoding) { From 4eb6ac0eb2cc87e4555ac2d280df08a19dfa496c Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 19:30:58 -0400 Subject: [PATCH 4/6] fix(installer): replace -ine operator flagged by the typos workflow Use [string]::Equals(..., OrdinalIgnoreCase) for the Pythinker leaf-name check; identical semantics, and the repo has no typos config to suppress the PowerShell -ine operator being flagged as a misspelling. --- docs/public/uninstall.ps1 | 2 +- scripts/uninstall.ps1 | 2 +- web/public/uninstall.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/public/uninstall.ps1 b/docs/public/uninstall.ps1 index 7c595ac9..7644d5c3 100644 --- a/docs/public/uninstall.ps1 +++ b/docs/public/uninstall.ps1 @@ -435,7 +435,7 @@ return $null } - if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 7c595ac9..7644d5c3 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -435,7 +435,7 @@ return $null } - if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } diff --git a/web/public/uninstall.ps1 b/web/public/uninstall.ps1 index 7c595ac9..7644d5c3 100644 --- a/web/public/uninstall.ps1 +++ b/web/public/uninstall.ps1 @@ -435,7 +435,7 @@ return $null } - if ([IO.Path]::GetFileName($full) -ine "Pythinker") { + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { Record-Warning "refusing install directory not named 'Pythinker': $full" $null return $null } From 51e5e2244d911c44f849dbd004317ec550849625 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 20:05:12 -0400 Subject: [PATCH 5/6] fix(repo): address CodeRabbit review findings on the rename sweep - issue template: make the discussions link label match its pythinker-cli target - README: fix 'cd pythinker-code' after cloning pythinker-cli.git - feedback: migrate saved Pythoughts-labs/pythinker-code repo values (the pre-rename bundled default) to pythinker-cli, with a regression test - help skill: point documentation base/llms.txt/page URLs at the canonical pythinker-cli GitHub Pages path instead of relying on the rename redirect - tests: report a missing pwsh as pytest.skip instead of a silent pass --- .github/ISSUE_TEMPLATE/1-bug-report.yml | 2 +- CHANGELOG.md | 1 + README.md | 2 +- src/pythinker_code/feedback_repo.py | 1 + src/pythinker_code/skills/pythinker-code-help/SKILL.md | 6 +++--- tests/core/test_config.py | 6 ++++++ tests/test_installation_docs.py | 4 +++- 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index a802ab3e..a3637e4a 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -9,7 +9,7 @@ body: value: | Thank you for submitting a bug report! It helps make Pythinker Code better for everyone. - If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-code/discussions](https://github.com/Pythoughts-labs/pythinker-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. + If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-cli/discussions](https://github.com/Pythoughts-labs/pythinker-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. Make sure you are running the latest version of Pythinker Code (`uv tool upgrade pythinker-code` to upgrade). The bug you are experiencing may already have been fixed. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2c779c..6a000f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Migrate saved `feedback.github_repo` values of `Pythoughts-labs/pythinker-code` (the pre-rename bundled default) to `Pythoughts-labs/pythinker-cli` on config load, and point the built-in help skill's documentation URLs at the canonical `pythinker-cli` GitHub Pages path. - Add `scripts/uninstall.ps1`, a Windows uninstaller served at `https://pythinker.com/uninstall.ps1` (`irm https://pythinker.com/uninstall.ps1 | iex`): runs a trusted Inno uninstaller (non-elevated, user-scope, known directory only), then sweeps PATH entries (user + system, kind-preserved), Start Menu shortcuts, uninstall registry keys (native 32/64-bit views), and owned stale installer temp dirs, and verifies final state with fail-closed semantics. Recursive deletion is snapshot-based and limited to known install dirs — registry-selected custom installs are never swept or executed automatically (they are reported for manual action) — registry-provided paths are canonicalized and rejected on roots/UNC/device/drive-relative/reparse-point components, processes are killed per-PID only from validated dirs with StartTime/path revalidation, and the script never self-elevates or calls `exit`, so piping through `iex` cannot close the host. User data under `%USERPROFILE%\.pythinker` is kept unless `PYTHINKER_PURGE_DATA=1` is set. - Update all repository URLs and repo-slug constants to `Pythoughts-labs/pythinker-cli` after the GitHub repository rename (install scripts, updater, feedback default repo, docs, workflows, packaging). - Retry the post-update smoke check briefly before recording `VERIFICATION_FAILED`, absorbing the Homebrew launcher-relink race that falsely failed successful upgrades, and print the verification failure on screen instead of leaving "Updated successfully!" as the last word when verification genuinely fails. diff --git a/README.md b/README.md index 03caab30..89eb815d 100644 --- a/README.md +++ b/README.md @@ -743,7 +743,7 @@ are used automatically unless you turn telemetry off. ```sh git clone https://github.com/Pythoughts-labs/pythinker-cli.git -cd pythinker-code +cd pythinker-cli make prepare ``` diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py index 92d0715b..7333048b 100644 --- a/src/pythinker_code/feedback_repo.py +++ b/src/pythinker_code/feedback_repo.py @@ -7,6 +7,7 @@ _LEGACY_FEEDBACK_GITHUB_OWNER + "/Pythinker-Code", _LEGACY_FEEDBACK_GITHUB_OWNER + "/pythinker-code", "TechMatrix-labs/pythinker-code", + "Pythoughts-labs/pythinker-code", } _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = { repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS diff --git a/src/pythinker_code/skills/pythinker-code-help/SKILL.md b/src/pythinker_code/skills/pythinker-code-help/SKILL.md index f305aeaa..5d848c65 100644 --- a/src/pythinker_code/skills/pythinker-code-help/SKILL.md +++ b/src/pythinker_code/skills/pythinker-code-help/SKILL.md @@ -15,17 +15,17 @@ Help users with Pythinker CLI questions by consulting documentation and source c ## Documentation -Base URL: `https://pythoughts-labs.github.io/pythinker-code/` +Base URL: `https://pythoughts-labs.github.io/pythinker-cli/` Fetch documentation index to find relevant pages: ``` -https://pythoughts-labs.github.io/pythinker-code/llms.txt +https://pythoughts-labs.github.io/pythinker-cli/llms.txt ``` ### Page URL Pattern -- Pages: `https://pythoughts-labs.github.io/pythinker-code/en/...` +- Pages: `https://pythoughts-labs.github.io/pythinker-cli/en/...` ### Topic Mapping diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 760a8752..07bd21e8 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -211,6 +211,12 @@ def test_load_config_migrates_legacy_org_feedback_repo_default(): assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" +def test_load_config_migrates_pre_rename_feedback_repo_default(): + config = load_config_from_string('[feedback]\ngithub_repo = "Pythoughts-labs/pythinker-code"\n') + + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" + + def test_agent_execution_profile_autonomous_sets_autonomy_defaults(): config = load_config_from_string('agent_execution_profile = "autonomous_coding"') diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index 30a99387..938974ce 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -3,6 +3,8 @@ import tomllib from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] @@ -195,7 +197,7 @@ def test_windows_readme_documents_uninstall_one_liner() -> None: def test_native_powershell_uninstaller_is_parseable_when_pwsh_is_available() -> None: pwsh = shutil.which("pwsh") if pwsh is None: - return + pytest.skip("pwsh is not available") uninstaller = (ROOT / "scripts" / "uninstall.ps1").resolve() result = subprocess.run( [ From e6605a7dae82c8f079630025887158e66dee09d8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 3 Aug 2026 20:29:42 -0400 Subject: [PATCH 6/6] docs(skills): add language tag to llms.txt URL fence (MD040) --- src/pythinker_code/skills/pythinker-code-help/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pythinker_code/skills/pythinker-code-help/SKILL.md b/src/pythinker_code/skills/pythinker-code-help/SKILL.md index 5d848c65..1bd32818 100644 --- a/src/pythinker_code/skills/pythinker-code-help/SKILL.md +++ b/src/pythinker_code/skills/pythinker-code-help/SKILL.md @@ -19,7 +19,7 @@ Base URL: `https://pythoughts-labs.github.io/pythinker-cli/` Fetch documentation index to find relevant pages: -``` +```text https://pythoughts-labs.github.io/pythinker-cli/llms.txt ```