diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69c9cf74..b264ec7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,6 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: | version="${RELEASE_TAG#v}" - release_notes="Download the installer for your platform below. PickForge auto-updates from this release." if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then echo "Release $RELEASE_TAG already exists; reusing it." @@ -53,8 +52,7 @@ jobs: gh release create "$RELEASE_TAG" \ --draft \ --title "PickForge v${version}" \ - --notes "$release_notes" \ - --generate-notes + --notes-file docs/releases/v0.2.1.md fi build: @@ -220,6 +218,9 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: | version="${RELEASE_TAG#v}" + # The release is cut before the repository rename so v0.2.0 can + # complete one final update smoke. RUNBOOK.md regenerates this + # manifest with canonical pickforge-ide asset URLs after the rename. download_base="https://github.com/pickforge/pickforge/releases/download/${RELEASE_TAG}" rm -rf release-assets latest.json diff --git a/Cargo.lock b/Cargo.lock index 0469b2a1..c08dd911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3430,7 +3430,7 @@ dependencies = [ [[package]] name = "pickforge-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "futures-util", @@ -3451,14 +3451,14 @@ dependencies = [ [[package]] name = "pickforge-mcp" -version = "0.2.0" +version = "0.2.1" dependencies = [ "tokio", ] [[package]] name = "pickforge-tauri" -version = "0.2.0" +version = "0.2.1" dependencies = [ "arboard", "base64 0.22.1", @@ -3489,7 +3489,7 @@ dependencies = [ [[package]] name = "pickforged" -version = "0.2.0" +version = "0.2.1" dependencies = [ "pickforge-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index da34480f..1c386653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/pickforge-core", "crates/pickforge-mcp", "crates/pickforged", [workspace.package] edition = "2021" -version = "0.2.0" +version = "0.2.1" license = "MIT" authors = ["PickForge"] diff --git a/README.md b/README.md index b79c1b2d..241e3646 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +> [!IMPORTANT] +> **PickForge IDE was retired on September 3, 2026.** It is replaced by the new +> [Pickforge](https://pickforge.dev). [v0.2.1](https://github.com/pickforge/pickforge-ide/releases/tag/v0.2.1) +> is the final release. Existing installs keep working offline, but receive no +> further updates. This repository is archived and read-only. +

PickForge

diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 00000000..03fb6e5a --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,152 @@ +# PickForge IDE retirement runbook + +Owner: Elberte. Run these steps only after the retirement PR is merged. Do not let the successor repository take `pickforge/pickforge` until the canonical-manifest step below is complete. + +## 1. Prepare v0.2.1 + +The updater private key stays in the GitHub Actions secret at `pickforge/pickforge` → Settings → Secrets and variables → Actions → `TAURI_SIGNING_PRIVATE_KEY`. Its password is in `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Do not print or copy either value locally. + +```bash +cd /home/dev/Projects/Pickforge/pickforge +git switch main +git pull --ff-only origin main +git status --short +test "$(node -p "require('./package.json').version")" = "0.2.1" +test "$(node -p "require('./src-tauri/tauri.conf.json').version")" = "0.2.1" +test "$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)" = "0.2.1" +bun install --frozen-lockfile +bun run pickforge-tauri-release validate-config +gh secret list --repo pickforge/pickforge | grep -E '^TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)?[[:space:]]' +``` + +The last command must show both secret names. It must not show secret values. + +Create the same annotated release tag style used for v0.2.0: + +```bash +git tag -a v0.2.1 -m "pickforge v0.2.1" +git push origin v0.2.1 +``` + +The tag starts `.github/workflows/release.yml`. CI creates a draft release, builds the platform bundles, signs updater artifacts from the two Actions secrets, collects them with `@pickforge/tauri-release`, and generates `latest.json`. + +```bash +RUN_ID="$(gh run list --repo pickforge/pickforge --workflow release.yml --commit "$(git rev-parse 'v0.2.1^{}')" --limit 1 --json databaseId --jq '.[0].databaseId')" +test -n "$RUN_ID" +gh run watch "$RUN_ID" --repo pickforge/pickforge --exit-status +gh release view v0.2.1 --repo pickforge/pickforge --json isDraft,tagName,url +``` + +## 2. Verify and publish the release + +Download the draft assets without exposing signing material: + +```bash +VERIFY_DIR="$(mktemp -d)" +gh release download v0.2.1 --repo pickforge/pickforge --dir "$VERIFY_DIR" +bun run pickforge-tauri-release verify-latest-json --input "$VERIFY_DIR/latest.json" +jq -e '.version == "0.2.1" and (.platforms | length > 0)' "$VERIFY_DIR/latest.json" +find "$VERIFY_DIR" -maxdepth 1 -type f -name '*.sig' -print +jq -r '.platforms[].url' "$VERIFY_DIR/latest.json" | while IFS= read -r url; do curl --fail --location --head "$url"; done +rm -rf "$VERIFY_DIR" +``` + +Stop if there are no `.sig` files, manifest verification fails, or any asset URL fails. Inspect the draft assets and release note, then publish: + +```bash +gh release edit v0.2.1 \ + --repo pickforge/pickforge \ + --title "PickForge v0.2.1" \ + --notes-file docs/releases/v0.2.1.md \ + --draft=false +gh release view v0.2.1 --repo pickforge/pickforge --json isDraft,publishedAt,url +curl --fail --location --silent --show-error \ + https://github.com/pickforge/pickforge/releases/latest/download/latest.json \ + | jq -e '.version == "0.2.1"' +``` + +## 3. Verify the v0.2.0 update before the rename + +If a v0.2.0 installation is available, launch it while `pickforge/pickforge` still has that name. Confirm that it offers v0.2.1, downloads it, verifies the signature, installs it, relaunches as v0.2.1, and shows the retirement notice. Confirm both notice links and dismissal. Relaunch once more and confirm the dismissed notice stays dismissed. + +If no v0.2.0 installation is available, record that gap. Do not replace it with a release build or local signing run. The endpoint and asset checks above are the minimum fallback evidence. + +## 4. Rename, canonicalize the manifest, and archive + +The release workflow initially writes old-repository asset URLs so the pre-rename v0.2.0 smoke can complete. GitHub carries the release across the rename, but the old path will eventually belong to the successor. Immediately after renaming, regenerate `latest.json` so both the v0.2.1 endpoint and every URL inside its manifest use `pickforge/pickforge-ide` explicitly. + +```bash +gh repo rename pickforge-ide --repo pickforge/pickforge --yes +gh repo view pickforge/pickforge-ide --json nameWithOwner,isArchived,url + +MANIFEST_DIR="$(mktemp -d)" +mkdir -p "$MANIFEST_DIR/assets" +gh release download v0.2.1 --repo pickforge/pickforge-ide --dir "$MANIFEST_DIR/assets" +rm "$MANIFEST_DIR/assets/latest.json" +bun run pickforge-tauri-release generate-latest-json \ + --assets-dir "$MANIFEST_DIR/assets" \ + --version 0.2.1 \ + --download-base-url https://github.com/pickforge/pickforge-ide/releases/download/v0.2.1 \ + --out "$MANIFEST_DIR/latest.json" +bun run pickforge-tauri-release verify-latest-json --input "$MANIFEST_DIR/latest.json" +jq -e ' + .version == "0.2.1" and + ([.platforms[].url | startswith("https://github.com/pickforge/pickforge-ide/releases/download/v0.2.1/")] | all) +' "$MANIFEST_DIR/latest.json" +gh release upload v0.2.1 "$MANIFEST_DIR/latest.json" --repo pickforge/pickforge-ide --clobber +curl --fail --location --silent --show-error \ + https://github.com/pickforge/pickforge-ide/releases/latest/download/latest.json \ + --output "$MANIFEST_DIR/published-latest.json" +bun run pickforge-tauri-release verify-latest-json --input "$MANIFEST_DIR/published-latest.json" +cmp "$MANIFEST_DIR/latest.json" "$MANIFEST_DIR/published-latest.json" +jq -r '.platforms[].url' "$MANIFEST_DIR/published-latest.json" | while IFS= read -r url; do curl --fail --location --head "$url"; done +rm -rf "$MANIFEST_DIR" + +gh repo archive pickforge/pickforge-ide --yes +gh repo view pickforge/pickforge-ide --json nameWithOwner,isArchived,url +``` + +Do not archive if the canonical endpoint, manifest comparison, or asset checks fail. + +## 5. Site handoff + +Hand the live v0.2.1 and archived-repository URLs to the prepared `pickforge/landing-page` pivot PR. That PR must remove the IDE download/install call to action, point the product name to the successor at `https://pickforge.dev`, and leave no site-owned updater manifest for the IDE. Review and merge it through its own runbook only after its checks are green. + +```bash +gh pr list --repo pickforge/landing-page --state open \ + --json number,title,headRefName,url,statusCheckRollup +``` + +Before the successor takes the old `pickforge/pickforge` path, make this a release invariant: successor releases must never contain an asset named `latest.json`. Legacy v0.2.0 installs still request that exact filename. A 404 is safe; a successor manifest is not. + +## If v0.2.0 fetched a manifest from the wrong repository + +A startup check only fetches metadata. It does not download or execute an update until the user chooses to install it, and a downloaded updater artifact must still pass the IDE's embedded signature check. + +1. Quit the v0.2.0 app immediately. This clears its in-memory pending update. Do not click Update. +2. Remove the accidental `latest.json` asset from the successor's latest release: + + ```bash + WRONG_TAG="$(gh release view --repo pickforge/pickforge --json tagName --jq .tagName)" + gh release delete-asset "$WRONG_TAG" latest.json --repo pickforge/pickforge --yes + curl --fail --location --silent --show-error \ + https://github.com/pickforge/pickforge/releases/latest/download/latest.json + ``` + + The final `curl` must fail. If it returns JSON, stop and find the remaining asset before launching v0.2.0 again. +3. Download v0.2.1 directly from `https://github.com/pickforge/pickforge-ide/releases/tag/v0.2.1` and install the package for that machine manually. +4. Relaunch and confirm the installed version is v0.2.1 and the retirement notice appears. +5. If the user already clicked Update, preserve the error shown by the signature check, quit, and manually install v0.2.1. If anything other than the IDE was installed, stop and investigate before reopening the existing PickForge data directory. + +## Final verification checklist + +- [ ] v0.2.1 tag points at the reviewed merge commit. +- [ ] Release workflow is green and both updater signing secret names were present. +- [ ] v0.2.1 is published with `docs/releases/v0.2.1.md` as its note. +- [ ] `latest.json` reports v0.2.1 and passes `verify-latest-json`. +- [ ] Every final manifest URL starts with `https://github.com/pickforge/pickforge-ide/` and downloads successfully. +- [ ] A v0.2.0 update smoke passed, or the missing-install gap was recorded. +- [ ] The updated app relaunches as v0.2.1 and shows the retirement notice once. +- [ ] `pickforge/pickforge-ide` is archived and read-only. +- [ ] The successor's `pickforge/pickforge` releases do not expose `latest.json`. +- [ ] The landing-page retirement/pivot PR was handed the final URLs and merged only with green checks. diff --git a/bun.lock b/bun.lock index c9063479..67fe4094 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,11 @@ }, }, "overrides": { + "brace-expansion": "^5.0.9", + "browserslist": "^4.28.7", + "nanoid": "^3.3.18", "postcss": "^8.5.18", + "undici": "^7.29.0", }, "packages": { "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.198", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@modelcontextprotocol/sdk", "zod"] }, "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA=="], @@ -417,15 +421,15 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": "dist/cli.cjs" }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -447,7 +451,7 @@ "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], - "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.420", "", {}, "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -569,11 +573,11 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -665,11 +669,11 @@ "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], - "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@7.28.0", "", {}, "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], diff --git a/crates/pickforge-core/src/voice/segments.rs b/crates/pickforge-core/src/voice/segments.rs index 7268654c..8d87d3f4 100644 --- a/crates/pickforge-core/src/voice/segments.rs +++ b/crates/pickforge-core/src/voice/segments.rs @@ -224,8 +224,8 @@ pub fn parse_wav_pcm16(bytes: &[u8]) -> Result { let data = data.ok_or_else(|| "missing data chunk".to_string())?; let mut samples = Vec::with_capacity(data.len() / 2); - for chunk in data.chunks_exact(2) { - samples.push(i16::from_le_bytes([chunk[0], chunk[1]])); + for chunk in data.as_chunks::<2>().0 { + samples.push(i16::from_le_bytes(*chunk)); } Ok(WavData { diff --git a/docs/architecture/update-checks.md b/docs/architecture/update-checks.md index c30fa4cf..c0ae1049 100644 --- a/docs/architecture/update-checks.md +++ b/docs/architecture/update-checks.md @@ -1,14 +1,14 @@ # Update Checks -PickForge auto-updates via the **Tauri updater plugin** (`tauri-plugin-updater`). -On launch the app checks a GitHub Releases endpoint, verifies the download's -signature against an embedded public key, and offers a one-click install + -relaunch. +PickForge v0.2.1 is the final release. It uses the **Tauri updater plugin** +(`tauri-plugin-updater`) for the one last update from v0.2.0. On launch the app +checks a GitHub Releases endpoint, verifies the download's signature against an +embedded public key, and offers a one-click install + relaunch. ## How it works - `src-tauri/tauri.conf.json` → `plugins.updater`: - - `endpoints`: `https://github.com/pickforge/pickforge/releases/latest/download/latest.json` + - `endpoints`: `https://github.com/pickforge/pickforge-ide/releases/latest/download/latest.json` - `pubkey`: the base64 minisign public key (safe to commit). - `bundle.createUpdaterArtifacts: true` makes the bundler emit the signed updater artifacts (`*.sig`) alongside each installer. @@ -31,11 +31,20 @@ key** that matches the `pubkey` in `tauri.conf.json`. - `TAURI_SIGNING_PRIVATE_KEY` — the private key file's contents. - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — its password (empty string if none). 3. Push a `v*` tag. `.github/workflows/release.yml` builds every platform, signs - the artifacts, creates a draft GitHub Release, and uploads `latest.json` - (`includeUpdaterJson: true`). Publish the release to ship the update. + the artifacts, creates a draft GitHub Release, and generates `latest.json` + with `@pickforge/tauri-release` from the collected signed assets. Publish the + release to ship the update. + +The final release is cut while the repository is still named +`pickforge/pickforge`, so its first manifest uses that repository's download +base and v0.2.0 can complete an update smoke. The repository rename carries the +release and assets with it. Before the old name is reused, follow `RUNBOOK.md` +to regenerate and replace `latest.json` with canonical `pickforge/pickforge-ide` +asset URLs. The release-tool config's `repository` field is metadata; the +explicit `--download-base-url` is what is written into each platform URL. If the secrets are absent the build still succeeds but produces unsigned -artifacts, and clients will reject the update — so set them before tagging. +artifacts, and clients will reject the update, so set them before tagging. ## Privacy diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 53b043ac..695e1f31 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -1,197 +1,3 @@ -# Unreleased — v0.2.1 candidate +# Unreleased -Working draft for the next PickForge release. Keep this current while PRs land. -At release time, copy and polish it into the GitHub release description, then -reset this file. - -## User-facing changes - -- The macOS window now uses native chrome: system rounded corners, shadow, - and traffic lights via a titlebar overlay (the custom window-control - buttons and edge resize handles are macOS-native now). Windows/Linux keep - the existing frameless chrome. The traffic lights remain centered through - resizing, fullscreen transitions, and interface zoom changes. -- The Codex agent-model picker now merges live-discovered models (`codex - debug models --bundled`) with the curated static table, so new Codex - releases can show up without a PickForge update. Curated entries keep - their hand-tuned labels/effort levels; a probe failure falls back to the - curated table only. Claude Code has no stable model-listing command, so - its picker stays the curated static table. Quick-launch chips are - unaffected — they still pin the curated cheap-model defaults. -- OMP (Oh My Pi) native chat is now enabled by default: model catalog in the - composer picker, honest cancel with persisted interrupted turns, and - connector diagnostics. The `ompAgents` flag remains as an off-switch. -- Claude Opus 5 replaces Opus 4.8 in the agent model picker, swarm model - aliases, and cost estimates (same $5/$25 pricing; default effort high). -- Swarm commands no longer misread model version digits as lane counts - ("/swarm of three opus 5 agents" now launches 3 lanes, not 5; same fix - covers "sonnet 5" and "gpt-5.5"). -- Pi native chat is now always available when compatible Pi is installed (>=0.79.10 and <0.82.0). -- Fixed OMP 17.1.1 native chat being incorrectly reported as unavailable. -- Stopped OMP turns now retain the user prompt, partial response, and interrupted status in chat history. -- Fixed macOS window rubber-banding and popover menus selecting a neighboring row near the bottom of the window. -- Terminal chats now use an honest terminal identity and icon instead of being labeled as Claude Code; existing falsely stamped terminal history is migrated automatically, the new-chat menu now shows terminal and harness marks, and a recognized harness running in a terminal chat under a raw pty appears transiently beside the terminal icon. Chats using the dtach or tmux recovery backend show the terminal icon alone. -- Fixed bypass-permissions mode breaking agent SDK sessions and leaving later sends stuck as working. -- The Source Control pane now has a single refresh button that refreshes both the repo status and the Changes review listing (the duplicate header button is gone). -- The chat composer's context-window and session-cost readout moved into the - message box itself — tokens at its bottom-left, cost at its bottom-right, and - the box's bottom edge doubling as the usage bar. It no longer floats loose - beside the agent/model pickers or stack into three lines in a narrow pane, and - the wider bar makes a small fraction of a large context window visible. -- Fixed flat-list work cards collapsing into clipped one-line pills when the sidebar pane is short (e.g. Files section expanded); the list now scrolls, which also stops rows shifting under the cursor and eating clicks. -- The sidebar chat list no longer lets the browser silently re-scroll it when a - chat is promoted to a live card above the fold. Applies to Chromium-based - webviews (Windows/WebView2) today; WebKit does not implement scroll anchoring - until Safari 27, so this is prevention rather than a fix there. macOS builds - were never affected by this mechanism — the transient pane clipping reported - in #356 has a different, still-unidentified cause on WKWebView. -- The chat now follows a turn all the way to its end. A streaming reply no longer - reserves a screen of empty space below itself, and the turn's closing token/cost - row is no longer left just below the fold — the view stopped following at the - moment the reserved space collapsed. -- Shift+Enter in the composer now opens a visible new line on the first press - (it used to take two), and the caret stays in view as a long draft grows. -- Chat timeline rows are tighter: collapsed command, tool, and thinking rows take - the height they actually need instead of a 48px minimum, and the gap between - rows is smaller, so an agent run reads as a log rather than a list of cards. -- Numbered lists in chat messages no longer push their "10." markers into the - message bubble's edge. -- `AskUserQuestion` is answerable. A Claude agent asking you to choose between - options used to render as a bare "TOOL AskUserQuestion" Allow/Deny prompt, - and pressing Allow made the agent report that you had declined to answer and - continue on invented defaults. It now renders the real questions and options, - sends your actual answers back, and "Allow for session" can no longer - auto-answer a later question with a stale reply. -- Chat timeline spacing is now run-aware: a burst of CMD/TOOL/MCP/web rows sits - tight as one block, while prose keeps the wider "new thought" distance either - side of it. Previously every pair was spaced identically, so nothing in the - layout told a run of tool calls apart from a change of subject. -- Swarm run cards in chat and the pi-kit lanes panel now use the same - expand/collapse motion and rotating chevron as the rest of the transcript, - so every disclosure in the app behaves one way. -- Expanding a CMD, TOOL, MCP or WEB row in chat now eases open and closed with - a chevron that turns, instead of the body popping into existence and - everything below snapping down. THINKING already behaved this way, so the - same gesture no longer had two different behaviours in one transcript. - Reduced motion renders it instantly. -- Tool rows in Claude chats now show their arguments and actually finish. Every - non-Bash tool used to be emitted as running and never updated — permanently - "working" long after it completed — and MCP calls rendered as one dead grey - line with no arguments, no result and a disabled chevron. Rows whose tool - carries none of the four previously recognised argument keys (AskUserQuestion, - Task, Skill, Glob, ToolSearch, Read-with-offset) now summarise their input - instead of rendering bare. - -## Internal/release changes (dark: no default-on behavior change) - -- Interrupting a turn with messages queued now says so: the dock re-voices as - `[ HELD · n` with explicit Send and Discard, and the composer yields its - accent to that decision (#357 PR 2). Retrying a dead connection holds the - queue the same way, instead of leaving it to sit until some later turn - happened to close (#369). Queued messages can now carry image attachments. -- On Pi, a message typed while a turn is running is now handed to that turn - through the native follow-up RPC instead of waiting in the queue, so the - agent can act on it inside the same run (#357 PR 2). Backends whose protocol - has no equivalent still queue. A message carrying images, or a follow-up the - RPC rejects, falls back to the queue rather than being lost. -- Message queue edge cases (behind the default-off `messageQueue` flag, #369): - the entry currently being dispatched no longer offers a Remove control that - silently does nothing — it takes the fill and bright text of the message it - is about to become. Turning the flag off now stops new queuing without - stranding entries already queued: they stay visible, removable, and still - drain. -- Message queuing while an agent turn is running (behind the default-off - `messageQueue` flag, #357 PR 1): typing and pressing Enter mid-turn now - queues the message instead of being refused, on every native backend — - previously Claude Code and OMP left the composer dead, and Codex and Pi - only offered steering. Queued messages rack above the composer as unfilled - ghosts of the bubbles they become, drain one at a time in FIFO order when - the turn closes, and can be removed before they send. Interrupting holds - the queue rather than firing it. A failed queued send surfaces the error - and leaves the backlog intact. -- Corrected the design-system section of `AGENTS.md`, which pointed agents at - a non-existent `src/styles/tokens.css`; tokens live in - `@pickforge/brand/src/tokens.css` and are imported via `src/styles/global.css`. -- update-vrt-baselines now re-dispatches ci on the branch after committing regenerated PNGs (GITHUB_TOKEN pushes trigger no workflows), so PRs no longer strand at "no checks reported"; ci is manually dispatchable and its token is pinned read-only. -- The v2 SDK bridge now grants the skip-permissions capability at every spawn - because its long-lived CLI process cannot gain that capability later; - permissions are still bypassed only when the user explicitly selects bypass - mode. -- Removed the `piAgents` flag after Pi native chat shipped default-on in v0.2.0. -- A `pickforge-lanes` MCP call in chat now expands to the run's lane cards — - the same cards the Settings panel shows, not a second copy (#362 PR 3, behind - the default-off `mcpToolDetail` flag). Lanes are live while the call is in - flight, so watching a `lanes_wait` is worth something, and frozen once it - finishes so a replayed message does not rewrite itself from a run that has - moved on. Abandon stays in Settings. -- The working row now names what is running and how long it has been running, - behind the new default-off `turnActivity` flag (#365). During a long MCP call - the chat used to show a bare "Working" dot with no way to tell thinking from - blocked from dead. The elapsed clock comes from the SDK's own heartbeat, not - a client-side timer, so it cannot drift; compacting and requesting are - surfaced too. -- Sidebar work cards now show what the agent is actually doing (#361 PR 2, - behind the default-off `flatChatList` flag): the brief line is the plan's - active step, so a card reading `plan 2/5` also says what step 2 is. Both come - from one source, so they cannot disagree. A chat with no plan falls back to a - model-written one-liner, and a finished plan shows nothing rather than a - stale final step. -- The default-off `pikitLanes` panel no longer re-reads and re-parses every run - on disk every four seconds, or grow without bound (#363 PR 2). It shows every - ACTIVE run plus the five most recent ended ones, with the rest behind a - scrollable "view all" dialog. Active runs are deliberately never capped: run - ids sort by start time, so a strict newest-N page would hide a long-running - run — exactly the one still worth acting on. -- Sidebar work card visual gaps closed (behind the default-off `flatChatList` - flag, #361 PR 1): the NEEDS YOU label now carries the locked bracket - treatment as real glyphs rather than only a recolour, the footer cost reads - at card scale (`$0.41`, sub-cent as `<$0.01`) instead of the composer's four - decimals, and the branch reads one step up the text ramp so it is legible as - an identifier beside `plan M/N` rather than one grey run. -- MCP and tool rows in chat carry their arguments, result and a real terminal - status behind the new default-off `mcpToolDetail` flag (#362, #365): the row - resolves in place from WORKING to done or failed instead of staying - permanently "running", its chevron opens the full payload, and the toggle now - survives scrolling away and back. -- Fixed a race in the default-off `changesReview` re-fold (#368): a turn that - started while the re-fold was fetching history had its just-sent message - wiped from the timeline, because the commit replaced the timeline wholesale - with history that predated the optimistic row. The re-fold now re-defers to - the next terminal event instead of committing over a live turn. -- Fixed expanded runs collapsing on their own in the default-off `pikitLanes` - panel (#363): the 4s poll replaced the run list with freshly deserialized - objects, so `For` disposed and recreated every card. The store now diffs by - run id, which also lets lane values update in place instead of remounting the - row. Swarm run cards in chat had the same defect from their own store's - per-update object rebuild and now key expansion by run id. -- The flat sidebar's project filter (behind the default-off `flatChatList` - flag, #371) is now the app's one dropdown instead of a wrapping row of pill - chips. The chips wrapped onto multiple rows and grew with every project — - 72px of fixed chrome against a 108px list viewport; the dropdown is 36px and - leaves 144px. Project right-click options (rename, remote host, move to - group, archive) moved onto the dropdown's option rows, so nothing is lost. -- Settings shell (behind the default-off `settingsNavigation` flag, #211 PR 3): - section titles inside the content pane are now real headings (h2, with h3 - for sub-groups like Connector diagnostics and Danger zone) instead of - unlabeled text, so assistive tech can navigate by heading; added focused - keyboard-nav/persistence unit tests and a reduced-motion VRT check for the - category rail. - -## Validation - -- `bun run test:unit` -- `bun run test:coverage` -- `bun run lint` -- `bun run build` -- `bunx playwright test tests/vrt/dropdown.spec.ts` (against an isolated VRT dev server) -- `cargo test -p pickforge-core` -- `cargo test --workspace --locked --all-targets` - -### Not tested yet — release gates - -- Terminal harness VRT was skipped because another checkout owns port 1420 and local macOS baselines are not CI-canonical. - -### Known limits - -- Pi native chat requires Pi >=0.79.10 and <0.82.0. -- OMP native chat requires OMP >=17.1.1 and <18.0.0 and remains behind the default-off `ompAgents` flag pending #284 and #285. +PickForge IDE was retired with v0.2.1. No further releases are planned. diff --git a/docs/releases/v0.2.1.md b/docs/releases/v0.2.1.md new file mode 100644 index 00000000..fe442b31 --- /dev/null +++ b/docs/releases/v0.2.1.md @@ -0,0 +1,8 @@ +# PickForge v0.2.1 + +PickForge IDE was retired on September 3, 2026. This is its final release. + +- The updater now checks the archived `pickforge/pickforge-ide` repository, so it can never consume update metadata published by the successor product at the old repository path. +- The app shows a one-time retirement notice with links to the new [Pickforge](https://pickforge.dev) and the [archived IDE repository](https://github.com/pickforge/pickforge-ide). + +Existing installs keep working offline. There will be no further updates. diff --git a/package.json b/package.json index 35637bbe..d47727db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pickforge-tauri", "private": true, - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "type": "module", "scripts": { @@ -62,6 +62,10 @@ "vitest": "^4.1.9" }, "overrides": { - "postcss": "^8.5.18" + "brace-expansion": "^5.0.9", + "browserslist": "^4.28.7", + "nanoid": "^3.3.18", + "postcss": "^8.5.18", + "undici": "^7.29.0" } } diff --git a/pickforge.release.json b/pickforge.release.json index 2012a6d7..a9df88e5 100644 --- a/pickforge.release.json +++ b/pickforge.release.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "repository": "pickforge/pickforge", + "repository": "pickforge/pickforge-ide", "appName": "PickForge", "tauriConfig": "src-tauri/tauri.conf.json", "collect": { diff --git a/playwright.config.ts b/playwright.config.ts index af3f8706..0305bd5b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,6 +17,19 @@ export default defineConfig({ use: { baseURL: "http://localhost:1420", viewport: { width: 1280, height: 820 }, + // Existing visual baselines represent the steady state after the one-time + // retirement notice. screens.spec.ts separately proves its first launch. + storageState: { + cookies: [], + origins: [ + { + origin: "http://localhost:1420", + localStorage: [ + { name: "pickforge.retirementNoticeDismissed.v0.2.1", value: "true" }, + ], + }, + ], + }, }, // Baselines are regenerated on the CI runner (update-vrt-baselines.yml) so PR // runs diff CI-rendered pixels against CI-rendered pixels. This tolerance is diff --git a/src-tauri/src/ios_commands.rs b/src-tauri/src/ios_commands.rs index 717a23b9..d492c413 100644 --- a/src-tauri/src/ios_commands.rs +++ b/src-tauri/src/ios_commands.rs @@ -51,10 +51,10 @@ impl OsLogManager { &self, udid: &str, session: OsLogSession, - ) -> Result, OsLogSession> { + ) -> Result, Box> { let mut sessions = self.0.lock().await; if self.is_shutting_down() { - return Err(session); + return Err(Box::new(session)); } Ok(sessions.insert(udid.to_string(), session)) } @@ -173,7 +173,7 @@ pub async fn oslog_start( Ok(Some(old)) => stop_child(old).await, Ok(None) => {} Err(session) => { - stop_child(session).await; + stop_child(*session).await; return Err("os_log manager is shutting down".to_string()); } } @@ -362,7 +362,7 @@ mod tests { Err(raced) => raced, Ok(_) => panic!("shutdown gate must reject raced session"), }; - stop_child(raced).await; + stop_child(*raced).await; assert_ne!(unsafe { libc::kill(raced_pid as i32, 0) }, 0); } } diff --git a/src-tauri/src/logcat_commands.rs b/src-tauri/src/logcat_commands.rs index 34ae0ae9..20641306 100644 --- a/src-tauri/src/logcat_commands.rs +++ b/src-tauri/src/logcat_commands.rs @@ -45,10 +45,10 @@ impl LogcatManager { &self, serial: &str, session: LogcatSession, - ) -> Result, LogcatSession> { + ) -> Result, Box> { let mut sessions = self.0.lock().await; if self.is_shutting_down() { - return Err(session); + return Err(Box::new(session)); } Ok(sessions.insert(serial.to_string(), session)) } @@ -128,7 +128,7 @@ pub async fn logcat_start( Ok(Some(old)) => stop_child(old).await, Ok(None) => {} Err(session) => { - stop_child(session).await; + stop_child(*session).await; return Err("logcat manager is shutting down".to_string()); } } @@ -315,7 +315,7 @@ mod tests { Err(raced) => raced, Ok(_) => panic!("shutdown gate must reject raced session"), }; - stop_child(raced).await; + stop_child(*raced).await; assert_ne!(unsafe { libc::kill(raced_pid as i32, 0) }, 0); } } diff --git a/src-tauri/src/mirror_commands.rs b/src-tauri/src/mirror_commands.rs index 7d57bbd6..6ec2c50a 100644 --- a/src-tauri/src/mirror_commands.rs +++ b/src-tauri/src/mirror_commands.rs @@ -42,10 +42,10 @@ impl MirrorManager { &self, serial: &str, session: MirrorSession, - ) -> Result, MirrorSession> { + ) -> Result, Box> { let mut sessions = self.0.lock().await; if self.is_shutting_down() { - return Err(session); + return Err(Box::new(session)); } Ok(sessions.insert(serial.to_string(), session)) } @@ -106,7 +106,7 @@ pub async fn mirror_start( Ok(Some(old)) => stop_session(old).await, Ok(None) => {} Err(session) => { - stop_session(session).await; + stop_session(*session).await; return Err("mirror manager is shutting down".to_string()); } } @@ -386,7 +386,7 @@ mod shutdown_tests { Err(raced) => raced, Ok(_) => panic!("shutdown gate must reject raced session"), }; - stop_session(raced).await; + stop_session(*raced).await; assert_ne!(unsafe { libc::kill(raced_pid as i32, 0) }, 0); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 913607a0..cc891e1f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PickForge", - "version": "0.2.0", + "version": "0.2.1", "identifier": "dev.pickforge.app", "mainBinaryName": "pickforge", "build": { @@ -44,7 +44,7 @@ }, "updater": { "endpoints": [ - "https://github.com/pickforge/pickforge/releases/latest/download/latest.json" + "https://github.com/pickforge/pickforge-ide/releases/latest/download/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg5RTg4QTkyMDFCNEZEOTYKUldTVy9iUUJrb3JvaWVaTUtLbGRoVmtjNlpYRlVBekU2RUlINzFaZDBWQytBaDBNQkhtekxBV0wK" } diff --git a/src/App.tsx b/src/App.tsx index 3e2f3a57..a160f2b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { checkForUpdate, updateAvailable } from "./lib/updater"; import { startStudioUpdateCheck, studioUpdateState } from "./stores/studioUpdate"; import { activeUpdateInfo } from "./lib/studioUpdateView"; import { StudioUpdateDialog } from "./components/StudioUpdateDialog"; +import { RetirementNotice } from "./components/RetirementNotice"; import { MonoEyebrow, StatusPill } from "./components/ui"; import { WindowControls } from "./components/WindowControls"; import { ResizeHandles } from "./components/ResizeHandles"; @@ -298,6 +299,7 @@ export function App() { {/* Drag starts only after the pointer moves so the second primary press remains available to toggle maximize before Tauri takes over. */} +
{/* Workbench stays mounted (display toggled) so its terminals/shells diff --git a/src/components/RetirementNotice.css b/src/components/RetirementNotice.css new file mode 100644 index 00000000..75c94ede --- /dev/null +++ b/src/components/RetirementNotice.css @@ -0,0 +1,81 @@ +.pf-retirement { + display: flex; + align-items: center; + gap: var(--pf-space-lg); + flex: none; + min-height: 48px; + padding: var(--pf-space-sm) var(--pf-space-lg); + border-bottom: var(--pf-hairline-width) solid var(--pf-hairline-strong); + background: var(--pf-surface-1); + color: var(--pf-text-med); + font-size: var(--pf-size-body-sm); +} + +.pf-retirement-copy { + display: flex; + align-items: center; + gap: var(--pf-space-md); + flex: 1; + min-width: 0; +} + +.pf-retirement-copy .pf-eyebrow-row { + flex: none; +} + +.pf-retirement-actions { + display: flex; + align-items: center; + gap: var(--pf-space-sm); + flex: none; +} + +.pf-retirement-link, +.pf-retirement-dismiss { + border: var(--pf-hairline-width) solid var(--pf-hairline-strong); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-text-med); + font-family: var(--pf-font-mono); + font-size: var(--pf-size-label-sm); + cursor: pointer; + transition: border-color var(--pf-dur-fast) var(--pf-ease-forge), + color var(--pf-dur-fast) var(--pf-ease-forge), + background var(--pf-dur-fast) var(--pf-ease-forge); +} + +.pf-retirement-link { + padding: var(--pf-space-xs) var(--pf-space-sm); +} + +.pf-retirement-link:hover, +.pf-retirement-dismiss:hover { + border-color: var(--pf-text-low); + background: var(--pf-item-fill); + color: var(--pf-text-hi); +} + +.pf-retirement-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; +} + +@media (max-width: 900px) { + .pf-retirement { + align-items: flex-start; + gap: var(--pf-space-sm); + } + .pf-retirement-copy { + align-items: flex-start; + flex-direction: column; + gap: var(--pf-space-xs); + } + .pf-retirement-actions { + flex-wrap: wrap; + justify-content: flex-end; + } +} diff --git a/src/components/RetirementNotice.tsx b/src/components/RetirementNotice.tsx new file mode 100644 index 00000000..4e7c6eed --- /dev/null +++ b/src/components/RetirementNotice.tsx @@ -0,0 +1,65 @@ +import { createSignal, Show } from "solid-js"; +import { openExternalUrl } from "../lib/opener"; +import { IconClose } from "./icons"; +import { MonoEyebrow } from "./ui"; +import "./RetirementNotice.css"; + +const DISMISSED_KEY = "pickforge.retirementNoticeDismissed.v0.2.1"; +const SUCCESSOR_URL = "https://pickforge.dev"; +const ARCHIVED_REPO_URL = "https://github.com/pickforge/pickforge-ide"; + +function wasDismissed(): boolean { + try { + return localStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +export function RetirementNotice() { + const [visible, setVisible] = createSignal(!wasDismissed()); + + const dismiss = () => { + setVisible(false); + try { + localStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // The notice still dismisses for this process when storage is unavailable. + } + }; + + const open = (url: string) => { + void openExternalUrl(url).catch(() => {}); + }; + + return ( + + + + ); +} diff --git a/tests/unit/RetirementNotice.test.tsx b/tests/unit/RetirementNotice.test.tsx new file mode 100644 index 00000000..c9041228 --- /dev/null +++ b/tests/unit/RetirementNotice.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "solid-js/web"; +import { RetirementNotice } from "../../src/components/RetirementNotice"; + +const opener = vi.hoisted(() => ({ openExternalUrl: vi.fn(async () => {}) })); +vi.mock("../../src/lib/opener", () => ({ openExternalUrl: opener.openExternalUrl })); + +describe("RetirementNotice", () => { + beforeEach(() => { + localStorage.clear(); + opener.openExternalUrl.mockClear(); + }); + + it("states the final-release contract and opens both retirement destinations", () => { + const root = document.createElement("div"); + document.body.append(root); + const dispose = render(() => , root); + + expect(root.textContent).toContain("PickForge IDE retired on September 3, 2026"); + expect(root.textContent).toContain("Version 0.2.1 is the final release"); + expect(root.textContent).toContain("keeps working offline"); + expect(root.textContent).toContain("no further updates"); + + const links = root.querySelectorAll(".pf-retirement-link"); + links[0].click(); + links[1].click(); + expect(opener.openExternalUrl).toHaveBeenNthCalledWith(1, "https://pickforge.dev"); + expect(opener.openExternalUrl).toHaveBeenNthCalledWith( + 2, + "https://github.com/pickforge/pickforge-ide", + ); + + dispose(); + root.remove(); + }); + + it("stays dismissed after the first v0.2.1 startup notice", () => { + const root = document.createElement("div"); + document.body.append(root); + const dispose = render(() => , root); + + root.querySelector("[aria-label='Dismiss retirement notice']")?.click(); + expect(root.querySelector(".pf-retirement")).toBeNull(); + expect(localStorage.getItem("pickforge.retirementNoticeDismissed.v0.2.1")).toBe("true"); + dispose(); + + const disposeAgain = render(() => , root); + expect(root.querySelector(".pf-retirement")).toBeNull(); + disposeAgain(); + root.remove(); + }); +}); diff --git a/tests/vrt/retirement-notice.spec.ts b/tests/vrt/retirement-notice.spec.ts new file mode 100644 index 00000000..69bcec6d --- /dev/null +++ b/tests/vrt/retirement-notice.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; + +// Override the suite's steady-state storage so this one spec exercises the +// first v0.2.1 launch before the retirement notice has been dismissed. +test.use({ storageState: { cookies: [], origins: [] } }); + +test("retirement notice: first launch only", async ({ page }) => { + await page.goto("/#/workbench"); + + await expect(page.getByRole("status", { name: "PickForge retirement notice" })).toBeVisible(); + await page.getByRole("button", { name: "Dismiss retirement notice" }).click(); + await expect(page.getByRole("status", { name: "PickForge retirement notice" })).toBeHidden(); + + await page.reload(); + await expect(page.getByRole("status", { name: "PickForge retirement notice" })).toBeHidden(); +});