From 4d0aadf29926592e3292ee194b9c816f2f9815f2 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 25 Sep 2026 10:20:20 +0800 Subject: [PATCH] Detect in-place `flutter upgrade` in vfox-managed SDKs and warn --- README.md | 8 +- docs/flutter-upgrade.md | 60 ++++++++++++ hooks/pre_install.lua | 4 + hooks/pre_use.lua | 54 +++++++++++ lib/manifest.lua | 182 +++++++++++++++++++++++++++++++++++ metadata.lua | 1 + tests/e2e/linux/run.sh | 78 +++++++++++++++ tests/e2e/windows/run.ps1 | 2 +- tests/e2e/windows/verify.ps1 | 57 ++++++++++- 9 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 docs/flutter-upgrade.md create mode 100644 hooks/pre_use.lua create mode 100644 lib/manifest.lua diff --git a/README.md b/README.md index 3900c9d..b6929c6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,13 @@ See [docs/mirror.md](docs/mirror.md) for details. See [docs/ohos.md](docs/ohos.md) for details. -## Install from source +## FAQ + +### About `flutter upgrade` + +See [docs/flutter-upgrade.md](docs/flutter-upgrade.md) for details. + +### Install from source See [docs/install-from-source.md](docs/install-from-source.md) for details. diff --git a/docs/flutter-upgrade.md b/docs/flutter-upgrade.md new file mode 100644 index 0000000..2dd7bea --- /dev/null +++ b/docs/flutter-upgrade.md @@ -0,0 +1,60 @@ +# About `flutter upgrade` + +The Flutter release archives this plugin installs contain a full `.git` +checkout, so `flutter upgrade` (and its siblings `flutter downgrade`, +`flutter channel`, and any `git checkout` / `git pull` run inside the SDK +directory) can move the SDK's HEAD to a newer commit in place. vfox manages +SDK versions by directory and does not track in-place mutations, so the next +`vfox use flutter@3.47.0` will silently hand you whatever the SDK has drifted +to, not 3.47.0. + +Do not run `flutter upgrade` inside a vfox-managed Flutter SDK. Change +versions through vfox: + +```bash +vfox install flutter@3.47.4 +vfox use --global flutter@3.47.4 +``` + +## Recovering a drifted SDK + +If you already upgraded in place, this plugin prints a warning on every +`vfox use` of that version and shows the commands that restore it: + +```bash +vfox uninstall flutter@3.47.0 +vfox install flutter@3.47.0 +vfox use flutter@3.47.0 +``` + +## How the check works + +On install the plugin writes a `.vfox-manifest` file next to the SDK's `.git`, +recording the expected git HEAD, and re-checks it on each `vfox use`. + +The warning is only a warning: `vfox use` still selects the version you asked +for. SDKs without a `.git` directory are left alone. + +## Disabling the check + +Delete the `.vfox-manifest` file of the affected installation. The file is +written by a real install, so it comes back only after you uninstall and +reinstall that version; `vfox install` alone skips an already-installed +version and leaves it absent. + +### Ubuntu 26.04.1 + +Execute in Bash, + +```bash +find ~/.vfox/cache/flutter/v-3.47.0 -name .vfox-manifest -print -delete +``` + +### Windows 11 + +Assume that PowerShell 7 is already installed. Execute in PowerShell 7, + +```powershell +Get-ChildItem "$env:USERPROFILE\.vfox\cache\flutter\v-3.47.0" -Recurse -Force -Filter .vfox-manifest | + Remove-Item +``` diff --git a/hooks/pre_install.lua b/hooks/pre_install.lua index 858425c..3b1efd8 100644 --- a/hooks/pre_install.lua +++ b/hooks/pre_install.lua @@ -1,5 +1,6 @@ local http = require("http") local json = require("json") +local manifest = require("manifest") local ohos = require("ohos") local source = require("source") @@ -62,6 +63,9 @@ function PLUGIN:PostInstall(ctx) if sdk == nil then return end + if sdk.path ~= nil and sdk.path ~= "" then + pcall(manifest.write, sdk.path, sdk) + end if ohos.isOhosVersion(sdk.version) then ohos.clean(sdk.version) return diff --git a/hooks/pre_use.lua b/hooks/pre_use.lua new file mode 100644 index 0000000..5cf4817 --- /dev/null +++ b/hooks/pre_use.lua @@ -0,0 +1,54 @@ +local manifest = require("manifest") + +local REASON = { + ["head-drifted"] = "the SDK's git HEAD no longer matches the commit vfox installed", + ["sdk-no-longer-git"] = "vfox can no longer read the SDK's git HEAD (its .git may be broken)" +} + +local function printDriftWarning(sdkPath, version, manifestInfo, currentHead, reason) + local installedVersion = (manifestInfo and manifestInfo.version) or version + local expectedHead = (manifestInfo and manifestInfo.expected_head) or "unknown" + io.write(string.format( + [[ +Warning: flutter SDK at %s has drifted from the version vfox installed. + vfox installed: flutter@%s (git HEAD %s) + current git HEAD: %s + reason: %s + +This typically happens when `flutter upgrade` or a similar in-place command +is run inside a vfox-managed SDK. vfox manages SDK versions by directory and +does not track in-place mutations, so the version you get may differ from +the one you asked for. + +To restore flutter@%s: + vfox uninstall flutter@%s + vfox install flutter@%s + vfox use flutter@%s + +To upgrade to a newer version, use vfox directly: + vfox install flutter@ + vfox use flutter@ +]], + sdkPath, + installedVersion, + expectedHead, + currentHead or "unknown", + REASON[reason] or reason, + version, + version, + version, + version + )) +end + +function PLUGIN:PreUse(ctx) + local sdk = ctx.installedSdks and ctx.installedSdks[ctx.version] + if sdk == nil or sdk.path == nil or sdk.path == "" then + return { version = ctx.version } + end + local drifted, reason = manifest.checkDrift(sdk.path) + if drifted then + printDriftWarning(sdk.path, ctx.version, manifest.read(sdk.path), manifest.currentHead(sdk.path), reason) + end + return { version = ctx.version } +end diff --git a/lib/manifest.lua b/lib/manifest.lua new file mode 100644 index 0000000..841d0fc --- /dev/null +++ b/lib/manifest.lua @@ -0,0 +1,182 @@ +local json = require("json") + +local MARKER = ".vfox-manifest" + +local function sep() + if RUNTIME.osType == "windows" then + return "\\" + end + return "/" +end + +local function joinPath(a, b) + return a .. sep() .. b +end + +-- vfox feeds command strings to cmd.exe /c on Windows. Go has to re-quote the +-- whole command line, which turns a double quote into \" so cmd.exe reads a +-- quoted path back as the volume-relative path \path\ and rejects it with +-- "The filename, directory name, or volume label syntax is incorrect." Match +-- lib/git.lua and pass paths unquoted there; a path with a space then simply +-- skips the drift check instead of breaking vfox use. +local function quote(value) + if RUNTIME.osType == "windows" then + return value + end + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function capture(command) + local function run() + local pipe = io.popen(command) + if pipe == nil then + return nil + end + local content = pipe:read("*a") + pipe:close() + if content == nil then + return nil + end + content = content:match("^(.-)%s*$") + if content == "" then + return nil + end + return content + end + local ok, content = pcall(run) + if not ok then + return nil + end + return content +end + +local function hasGitDir(path) + local f = io.open(joinPath(path, ".git" .. sep() .. "HEAD"), "r") + if f == nil then + return false + end + f:close() + return true +end + +local function isEmptyTable(table) + for _ in pairs(table) do + return false + end + return true +end + +local M = {} + +function M.gitRoot(sdkRoot) + if type(sdkRoot) ~= "string" or sdkRoot == "" then + return nil + end + if hasGitDir(sdkRoot) then + return sdkRoot + end + local listCmd + if RUNTIME.osType == "windows" then + listCmd = "dir /b /ad " .. quote(sdkRoot) + else + listCmd = "find " .. quote(sdkRoot) .. " -maxdepth 1 -mindepth 1 -type d -printf '%f\\n'" + end + local subdirs = capture(listCmd) + if subdirs == nil then + return nil + end + for line in (subdirs .. "\n"):gmatch("(.-)\n") do + line = line:match("^(.-)%s*$") + if line and line ~= "" then + local candidate = joinPath(sdkRoot, line) + if hasGitDir(candidate) then + return candidate + end + end + end + return nil +end + +function M.currentHead(sdkRoot) + local root = M.gitRoot(sdkRoot) + if root == nil then + return nil + end + return capture("git -C " .. quote(root) .. " rev-parse HEAD") +end + +function M.manifestPath(sdkRoot) + local root = M.gitRoot(sdkRoot) + if root == nil then + return nil + end + return joinPath(root, MARKER) +end + +function M.write(sdkRoot, info) + local root = M.gitRoot(sdkRoot) + if root == nil then + return false + end + local head = capture("git -C " .. quote(root) .. " rev-parse HEAD") + local data = { + plugin = "vfox-flutter", + plugin_version = (PLUGIN and PLUGIN.version) or "unknown", + version = (info and info.version) or "unknown", + expected_head = head, + installed_at = os.time(), + } + local f = io.open(joinPath(root, MARKER), "w") + if f == nil then + return false + end + local ok, encoded = pcall(json.encode, data) + if not ok then + f:close() + return false + end + f:write(encoded) + f:close() + return true +end + +function M.read(sdkRoot) + local path = M.manifestPath(sdkRoot) + if path == nil then + return nil + end + local f = io.open(path, "r") + if f == nil then + return nil + end + local content = f:read("*a") + f:close() + local ok, data = pcall(json.decode, content) + if not ok or type(data) ~= "table" or isEmptyTable(data) then + return nil + end + return data +end + +function M.checkDrift(sdkRoot) + if type(sdkRoot) ~= "string" or sdkRoot == "" then + return false, "no-path" + end + local m = M.read(sdkRoot) + if m == nil then + return false, "no-manifest" + end + if m.expected_head == nil or m.expected_head == "" then + return false, "no-git-anchor" + end + local current = M.currentHead(sdkRoot) + if current == nil then + return true, "sdk-no-longer-git" + end + if current ~= m.expected_head then + return true, "head-drifted" + end + return false, "ok" +end + +return M diff --git a/metadata.lua b/metadata.lua index 2020340..bb11cca 100644 --- a/metadata.lua +++ b/metadata.lua @@ -24,4 +24,5 @@ NOTE: PLUGIN.minRuntimeVersion = "0.3.0" -- Some things that need user to be attention! PLUGIN.notes = { + "Do NOT run `flutter upgrade` (or `flutter downgrade`, `flutter channel`, or any in-place git operation) inside a vfox-managed Flutter SDK. It mutates the SDK in place and vfox will silently hand you the wrong version on the next `vfox use`. Change versions with `vfox install flutter@` and `vfox use flutter@`. This plugin detects such drift and prints a warning with the restore commands on the next `vfox use`.", } diff --git a/tests/e2e/linux/run.sh b/tests/e2e/linux/run.sh index 97ddc05..1393fa5 100755 --- a/tests/e2e/linux/run.sh +++ b/tests/e2e/linux/run.sh @@ -100,6 +100,84 @@ else fi echo 'PASS the OpenHarmony SDK is a git checkout with its engine pins tracked' fi + +# --- in-place `flutter upgrade` drift detection --------------------------- +manifest="$sdk/.vfox-manifest" +if [ ! -f "$manifest" ]; then + echo 'FAIL the SDK has no .vfox-manifest, so its installed version cannot be anchored' >&2 + exit 1 +fi +echo 'PASS the SDK carries a .vfox-manifest' + +installed_head="$(git -C "$sdk" rev-parse HEAD)" +anchor="$(sed -n 's/.*"expected_head"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest")" +if [ -z "$anchor" ]; then + echo 'FAIL the .vfox-manifest has no expected_head' >&2 + exit 1 +fi +if [ "$anchor" != "$installed_head" ]; then + echo "FAIL the .vfox-manifest anchor $anchor does not match the installed git HEAD $installed_head" >&2 + exit 1 +fi +echo 'PASS the .vfox-manifest anchor matches the installed git HEAD' + +set +e +clean_output="$(vfox use --global flutter@"$version" 2>&1)" +clean_code=$? +set -e +if [ "$clean_code" -ne 0 ]; then + echo "FAIL vfox use exited with code ${clean_code}" >&2 + exit 1 +fi +if echo "$clean_output" | grep -qF 'has drifted'; then + echo 'FAIL a freshly installed SDK reports drift' >&2 + echo "--- actual ---" >&2 + echo "$clean_output" >&2 + exit 1 +fi +echo 'PASS a freshly installed SDK reports no drift' + +if ! GIT_AUTHOR_NAME=e2e GIT_AUTHOR_EMAIL=e2e@vfox.flutter \ + GIT_COMMITTER_NAME=e2e GIT_COMMITTER_EMAIL=e2e@vfox.flutter \ + git -C "$sdk" commit --allow-empty -q -m 'simulated flutter upgrade'; then + echo 'FAIL failed to record a simulated flutter upgrade commit' >&2 + exit 1 +fi +drifted_head="$(git -C "$sdk" rev-parse HEAD)" + +set +e +drift_output="$(vfox use --global flutter@"$version" 2>&1)" +drift_code=$? +set -e +if [ "$drift_code" -ne 0 ]; then + echo "FAIL vfox use exited with code ${drift_code}" >&2 + exit 1 +fi +assert_contains "$drift_output" 'has drifted from the version vfox installed' 'drift warning' +assert_contains "$drift_output" "$anchor" 'drift warning expected head' +assert_contains "$drift_output" "$drifted_head" 'drift warning current head' +assert_contains "$drift_output" "vfox uninstall flutter@$version" 'drift warning restore command' +assert_contains "$drift_output" "vfox install flutter@$version" 'drift warning restore command' +assert_contains "$drift_output" "vfox use flutter@$version" 'drift warning restore command' +assert_contains "$drift_output" 'vfox install flutter@' 'drift warning upgrade command' + +git -C "$sdk" reset -q --hard "$anchor" +set +e +restored_output="$(vfox use --global flutter@"$version" 2>&1)" +restored_code=$? +set -e +if [ "$restored_code" -ne 0 ]; then + echo "FAIL vfox use exited with code ${restored_code}" >&2 + exit 1 +fi +if echo "$restored_output" | grep -qF 'has drifted'; then + echo 'FAIL a restored SDK still reports drift' >&2 + echo "--- actual ---" >&2 + echo "$restored_output" >&2 + exit 1 +fi +echo 'PASS a restored SDK reports no drift' + if ! retry 'pub.dev' curl -fsSL --max-time 20 -o /dev/null 'https://pub.dev/api/packages/args' >/dev/null; then echo 'FAIL pub.dev is unreachable, the Flutter tool cannot bootstrap' >&2 exit 1 diff --git a/tests/e2e/windows/run.ps1 b/tests/e2e/windows/run.ps1 index 5798c12..3e1f915 100644 --- a/tests/e2e/windows/run.ps1 +++ b/tests/e2e/windows/run.ps1 @@ -53,4 +53,4 @@ $PSNativeCommandUseErrorActionPreference = $true if ($activationCode -ne 0) { throw "FAIL vfox activate pwsh exited with code $activationCode" } Invoke-Expression $activation -Invoke-Native { & pwsh -NoProfile -File "$PSScriptRoot\verify.ps1" -Flavor $flavor } "verify.ps1 ($flavor)" +Invoke-Native { & pwsh -NoProfile -File "$PSScriptRoot\verify.ps1" -Flavor $flavor -Version $version } "verify.ps1 ($flavor)" diff --git a/tests/e2e/windows/verify.ps1 b/tests/e2e/windows/verify.ps1 index bf7eca3..e35b222 100644 --- a/tests/e2e/windows/verify.ps1 +++ b/tests/e2e/windows/verify.ps1 @@ -1,5 +1,6 @@ param( - [Parameter(Mandatory = $true)] [string] $Flavor + [Parameter(Mandatory = $true)] [string] $Flavor, + [Parameter(Mandatory = $true)] [string] $Version ) $ErrorActionPreference = 'Stop' @@ -29,6 +30,60 @@ if ($Flavor -eq 'official') { if (-not (git -C $sdk ls-files bin/internal/engine.version)) { throw 'FAIL the OpenHarmony engine version pin is not tracked' } Write-Output 'PASS the OpenHarmony SDK is a git checkout with its engine pins tracked' } + +# --- in-place `flutter upgrade` drift detection --------------------------- +$manifestPath = Join-Path $sdk '.vfox-manifest' +if (-not (Test-Path $manifestPath)) { throw 'FAIL the SDK has no .vfox-manifest, so its installed version cannot be anchored' } +Write-Output 'PASS the SDK carries a .vfox-manifest' + +$installedHead = @(git -C $sdk rev-parse HEAD)[0] +$anchorMatch = [regex]::Match((Get-Content $manifestPath -Raw), '"expected_head"\s*:\s*"([^"]+)"') +if (-not $anchorMatch.Success) { throw 'FAIL the .vfox-manifest has no expected_head' } +$anchor = $anchorMatch.Groups[1].Value +if ($anchor -ne $installedHead) { throw "FAIL the .vfox-manifest anchor $anchor does not match the installed git HEAD $installedHead" } +Write-Output 'PASS the .vfox-manifest anchor matches the installed git HEAD' + +$PSNativeCommandUseErrorActionPreference = $false +$cleanOutput = (& vfox use --global "flutter@$Version" 2>&1) -join "`n" +$cleanCode = $LASTEXITCODE +$PSNativeCommandUseErrorActionPreference = $true +if ($cleanCode -ne 0) { throw "FAIL vfox use exited with code $cleanCode" } +if ($cleanOutput -match [regex]::Escape('has drifted')) { throw "FAIL a freshly installed SDK reports drift`n--- actual ---`n$cleanOutput" } +Write-Output 'PASS a freshly installed SDK reports no drift' + +$env:GIT_AUTHOR_NAME = 'e2e' +$env:GIT_AUTHOR_EMAIL = 'e2e@vfox.flutter' +$env:GIT_COMMITTER_NAME = 'e2e' +$env:GIT_COMMITTER_EMAIL = 'e2e@vfox.flutter' +$PSNativeCommandUseErrorActionPreference = $false +git -C $sdk commit --allow-empty -q -m 'simulated flutter upgrade' +$commitCode = $LASTEXITCODE +$PSNativeCommandUseErrorActionPreference = $true +if ($commitCode -ne 0) { throw "FAIL failed to record a simulated flutter upgrade commit (git exit $commitCode)" } + +$driftedHead = @(git -C $sdk rev-parse HEAD)[0] +$PSNativeCommandUseErrorActionPreference = $false +$driftOutput = (& vfox use --global "flutter@$Version" 2>&1) -join "`n" +$driftCode = $LASTEXITCODE +$PSNativeCommandUseErrorActionPreference = $true +if ($driftCode -ne 0) { throw "FAIL vfox use exited with code $driftCode" } +Assert-Contains $driftOutput 'has drifted from the version vfox installed' 'drift warning' +Assert-Contains $driftOutput $anchor 'drift warning expected head' +Assert-Contains $driftOutput $driftedHead 'drift warning current head' +Assert-Contains $driftOutput "vfox uninstall flutter@$Version" 'drift warning restore command' +Assert-Contains $driftOutput "vfox install flutter@$Version" 'drift warning restore command' +Assert-Contains $driftOutput "vfox use flutter@$Version" 'drift warning restore command' +Assert-Contains $driftOutput 'vfox install flutter@' 'drift warning upgrade command' + +git -C $sdk reset -q --hard $anchor +$PSNativeCommandUseErrorActionPreference = $false +$restoredOutput = (& vfox use --global "flutter@$Version" 2>&1) -join "`n" +$restoredCode = $LASTEXITCODE +$PSNativeCommandUseErrorActionPreference = $true +if ($restoredCode -ne 0) { throw "FAIL vfox use exited with code $restoredCode" } +if ($restoredOutput -match [regex]::Escape('has drifted')) { throw "FAIL a restored SDK still reports drift`n--- actual ---`n$restoredOutput" } +Write-Output 'PASS a restored SDK reports no drift' + try { & curl.exe -fsSL --max-time 20 -o NUL 'https://pub.dev/api/packages/args' } catch {