From d8253182f5e1eec36bcf48414279252b33af0c29 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 10:39:07 -0400 Subject: [PATCH 01/18] fix(release): advertise only published versions on the update channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CDN manifest took its version from apps/pythinker-code/package.json and the site autodeploys on every push to main, so a `ci: release packages` merge advertised the next version before — and, when a changeset landed while the version PR was open, without — npm and the GitHub release assets ever getting it. Clients then polled GitHub for assets that did not exist for about six minutes on every launch. Derive the advertised version from the npm dist-tag instead, take publishedAt from npm's own publish timestamp so unrelated site deploys stop re-anchoring the client rollout window, and run the release consistency check on version-bump merges that published nothing — it was gated on a successful publish, so it skipped exactly the case where the version and the published artifacts diverge. --- .../update-channel-published-versions-only.md | 5 ++ .github/workflows/release.yml | 18 +++++-- apps/site/scripts/build-cdn.mjs | 51 +++++++++++++++++-- .../release/verify-release-consistency.mjs | 38 ++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 .changeset/update-channel-published-versions-only.md diff --git a/.changeset/update-channel-published-versions-only.md b/.changeset/update-channel-published-versions-only.md new file mode 100644 index 00000000..8135cb77 --- /dev/null +++ b/.changeset/update-channel-published-versions-only.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12a3f886..161c2b61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -203,15 +203,23 @@ jobs: retention-days: 7 if-no-files-found: error - # code.pythinker.com redeploys via Dokploy autodeploy on push to main - # (app Pythinker/code builds apps/site/Dockerfile from the repo, no npm - # registry dependency), so no deploy webhook is fired here — this job only - # verifies the published release is internally consistent. + # code.pythinker.com redeploys via Dokploy autodeploy on push to main (app + # Pythinker/code builds apps/site/Dockerfile from the repo), so no deploy + # webhook is fired here — this job verifies that the published release is + # internally consistent and that the CDN is not advertising a version npm + # does not have. + # + # It also runs on a `ci: release packages` merge that published nothing: that + # commit bumps the version on main, so gating the check on a successful + # publish hid the one case where the version and the published artifacts + # diverge — and every client polled the CDN for a release that never existed. verify-cdn-release: timeout-minutes: 15 name: Verify release consistency needs: release - if: needs.release.outputs.packages_published == 'true' + if: >- + needs.release.outputs.packages_published == 'true' + || startsWith(github.event.head_commit.message, 'ci: release packages') runs-on: ubuntu-latest steps: - name: Checkout diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index c8b5d293..a41191bc 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, parse, resolve } from 'node:path'; @@ -29,6 +30,47 @@ function parseArgs() { return { out, skipRg }; } +/** + * Resolve the version this CDN advertises from a *published* release, never from + * the working tree. + * + * `apps/pythinker-code/package.json` is bumped by the `ci: release packages` + * merge and this site autodeploys on every push to main, so deriving the + * manifest from it advertised the next version the moment that merge landed — + * before the npm publish and the GitHub release assets existed, and permanently + * when the publish never ran at all. Installed clients then polled GitHub for + * assets that did not exist (~6 minutes per launch, every launch). The npm + * dist-tag is the publish barrier, so it is the only safe source. + * + * `publishedAt` comes from npm's own publish timestamp: stamping build time made + * every unrelated site deploy re-anchor the clients' rollout eligibility window. + * + * A registry read failure throws on purpose: a failed image build leaves the + * previous container serving the last good manifest, which is the safe outcome. + */ +async function resolvePublishedRelease(packageName) { + const pinned = process.env.PYTHINKER_CDN_VERSION?.trim(); + if (pinned) return { version: pinned, publishedAt: new Date().toISOString() }; + const view = JSON.parse( + execFileSync( + 'npm', + ['view', packageName, 'dist-tags', 'time', '--json', '--registry=https://registry.npmjs.org'], + { encoding: 'utf8', timeout: 60_000 }, + ), + ); + const version = view['dist-tags']?.latest; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error( + `npm dist-tag latest for ${packageName} is not a release version: ${String(version)}`, + ); + } + const publishedAt = view.time?.[version]; + return { + version, + publishedAt: typeof publishedAt === 'string' ? publishedAt : new Date().toISOString(), + }; +} + async function copyPlugins(repoRoot, cdnRoot) { const source = join(repoRoot, 'plugins/cdn'); const destination = join(cdnRoot, 'pythinker-code/plugins'); @@ -101,10 +143,11 @@ const repoRoot = await findRepoRoot(); const packageJson = JSON.parse( await readFile(join(repoRoot, 'apps/pythinker-code/package.json'), 'utf8'), ); -const version = packageJson.version; -if (typeof version !== 'string' || version.trim() === '') { - throw new Error('apps/pythinker-code/package.json has no version'); +const packageName = packageJson.name; +if (typeof packageName !== 'string' || packageName.trim() === '') { + throw new Error('apps/pythinker-code/package.json has no name'); } +const { version, publishedAt } = await resolvePublishedRelease(packageName); const siteDist = join(repoRoot, 'apps/site/dist'); await access(join(siteDist, 'index.html')); @@ -126,7 +169,7 @@ await mkdir(channelRoot, { recursive: true }); await writeFile(join(channelRoot, 'latest'), `${version}\n`); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ version, - publishedAt: new Date().toISOString(), + publishedAt, rollout: [], }, null, 2)}\n`); await cp( diff --git a/scripts/release/verify-release-consistency.mjs b/scripts/release/verify-release-consistency.mjs index 37696936..5956a736 100644 --- a/scripts/release/verify-release-consistency.mjs +++ b/scripts/release/verify-release-consistency.mjs @@ -4,11 +4,22 @@ import { readFileSync } from 'node:fs'; const PACKAGE_NAME = '@pythoughts/pythinker-code'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const CDN_MANIFEST_URL = 'https://code.pythinker.com/pythinker-code/latest.json'; + function fail(reason) { console.error(`consistency failed: ${reason}`); process.exit(1); } +/** Numeric major/minor/patch compare over two SEMVER regex matches. */ +function compareRelease(left, right) { + for (let index = 1; index <= 3; index += 1) { + const diff = Number(left[index]) - Number(right[index]); + if (diff !== 0) return diff; + } + return 0; +} + let localVersion; let distTags; @@ -49,4 +60,31 @@ try { } if (!gitTags.trim().split('\n').includes(releaseTag)) fail(`missing git tag ${releaseTag}`); +// The CDN manifest is what every installed client polls for updates, so a +// version it advertises that npm does not have sends all of them into an install +// that cannot succeed. Ahead of npm is a hard failure; behind is deploy lag, +// since the site rebuilds on the next push to main. +let cdnVersion; +try { + const response = await fetch(CDN_MANIFEST_URL, { signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + cdnVersion = JSON.parse(await response.text()).version; +} catch (error) { + console.warn(`warning: cannot read the CDN manifest (${error.message}) — CDN check skipped`); +} + +if (typeof cdnVersion === 'string' && cdnVersion !== distTags.latest) { + const cdnMatch = cdnVersion.match(SEMVER); + if (!cdnMatch) fail(`CDN manifest version is not semver: ${cdnVersion}`); + if (compareRelease(cdnMatch, latestMatch) > 0) { + fail( + `CDN advertises ${cdnVersion} but npm latest is ${distTags.latest} — ` + + 'clients would try to install a release that does not exist', + ); + } + console.log( + `CDN is behind npm (cdn=${cdnVersion} latest=${distTags.latest}); it catches up on the next push to main`, + ); +} + console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`); From 88a90cc63b77b2065713bd1a0d5b8092ad5b7f95 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 13:20:48 -0400 Subject: [PATCH 02/18] fix(update): name the artifact in the update manifest The CDN manifest advertised a version only, so a client had to guess a GitHub asset URL and poll for six minutes when the guess was wrong. `latest.json` now carries the resolved per-platform artifact (url + sha256), copied from the release's own native manifest.json, and the client exposes one predicate over it. A manifest with no `platforms` key, or an unparseable one, still resolves to available: a CDN blip must never stop a working update. A manifest that explicitly omits the running platform is a definitive denial. --- apps/pythinker-code/src/cli/update/cdn.ts | 51 +++++++ apps/pythinker-code/src/cli/update/types.ts | 10 ++ .../test/cli/update/cdn.test.ts | 142 +++++++++++++++++- apps/site/scripts/build-cdn.mjs | 43 ++++++ 4 files changed, 245 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/cli/update/cdn.ts b/apps/pythinker-code/src/cli/update/cdn.ts index eddf84b9..b0483846 100644 --- a/apps/pythinker-code/src/cli/update/cdn.ts +++ b/apps/pythinker-code/src/cli/update/cdn.ts @@ -12,6 +12,23 @@ const RolloutBatchSchema = z.object({ delaySeconds: z.number().int().min(0), }); +const UpdateManifestPlatformSchema = z.object({ + url: z + .string() + .refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + { error: 'invalid url' }, + ), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), +}); + /** * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown * fields are ignored so future manifest additions never break shipped @@ -24,6 +41,18 @@ export const UpdateManifestSchema = z.object({ .string() .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), rollout: z.array(RolloutBatchSchema).readonly().default([]), + /** + * Resolved per-platform artifacts, keyed `-`. A malformed + * value drops only this field via `.catch(undefined)` so `version` and + * `publishedAt` still parse — failing the whole manifest would send + * clients to the plain-text `/latest` fallback, which carries no platform + * information at all. + */ + platforms: z + .record(z.string(), UpdateManifestPlatformSchema) + .readonly() + .optional() + .catch(undefined), }); export interface FetchLatestResult { @@ -95,3 +124,25 @@ export async function fetchLatestFromCdn( const latest = await fetchLatestVersionFromCdn(fetchImpl); return { latest, manifest: null }; } + +export type ArtifactAvailability = 'available' | 'unavailable'; + +/** + * Whether the manifest advertises an artifact for `target`. Unknown — a + * null manifest or one that predates artifact addressing — resolves to + * 'available': a CDN blip must never stop a working update, while a + * manifest that explicitly omits the target platform is a definitive + * denial. + */ +export function manifestArtifactAvailability( + manifest: UpdateManifest | null, + target: string = `${process.platform}-${process.arch}`, +): ArtifactAvailability { + if (manifest === null) { + return 'available'; + } + if (manifest.platforms === undefined) { + return 'available'; + } + return Object.hasOwn(manifest.platforms, target) ? 'available' : 'unavailable'; +} diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index 485535ec..e20de662 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -22,6 +22,11 @@ export interface RolloutBatch { readonly delaySeconds: number; } +export interface UpdateManifestPlatform { + readonly url: string; + readonly sha256: string; +} + /** * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array * order; an empty array means the release is fully rolled out immediately. @@ -30,6 +35,11 @@ export interface UpdateManifest { readonly version: string; readonly publishedAt: string; readonly rollout: readonly RolloutBatch[]; + /** + * Resolved per-platform artifacts, keyed `-`. Absent on + * manifests published before artifact addressing shipped. + */ + readonly platforms?: Readonly>; } export interface UpdateCache { diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index fe9237d4..e6fbab2c 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { + fetchLatestFromCdn, + fetchLatestVersionFromCdn, + manifestArtifactAvailability, +} from '#/cli/update/cdn'; import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { @@ -130,6 +134,72 @@ describe('fetchLatestFromCdn', () => { expect(result.manifest?.rollout).toEqual([]); }); + it('drops a platforms entry with an invalid sha256 but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'nope', + }, + }, + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.latest).toBe('2.0.0'); + expect(result.manifest?.version).toBe('2.0.0'); + expect(result.manifest?.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result.manifest, 'darwin-arm64')).toBe('available'); + }); + + it('drops a platforms entry with a non-URL url but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + platforms: { + 'darwin-arm64': { url: 'not-a-url', sha256: 'a'.repeat(64) }, + }, + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.latest).toBe('2.0.0'); + expect(result.manifest?.version).toBe('2.0.0'); + expect(result.manifest?.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result.manifest, 'darwin-arm64')).toBe('available'); + }); + + it('carries a well-formed platforms record onto the parsed manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + 'linux-x64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', + sha256: 'b'.repeat(64), + }, + }, + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.latest).toBe('2.0.0'); + expect(result.manifest?.platforms).toEqual({ + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + 'linux-x64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', + sha256: 'b'.repeat(64), + }, + }); + }); + const fallbackCases: ReadonlyArray = [ ['latest.json is missing (HTTP 404)', { status: 404 }], ['latest.json fetch throws', new Error('network down')], @@ -231,3 +301,73 @@ describe('fetchLatestFromCdn', () => { } }); }); + +describe('manifestArtifactAvailability', () => { + it('treats a null manifest as available (unknown is not a denial)', () => { + expect(manifestArtifactAvailability(null)).toBe('available'); + }); + + it('treats a manifest without platforms as available', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); + }); + + it('is available when platforms has an own entry for the target', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); + }); + + it('is unavailable when platforms omits the target', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest, 'linux-x64')).toBe('unavailable'); + }); + + it('is unavailable for an empty platforms object', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: {}, + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('unavailable'); + }); + + it('defaults the target to the running platform', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + [`${process.platform}-${process.arch}`]: { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest)).toBe('available'); + }); +}); diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index a41191bc..67d690ff 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -71,6 +71,47 @@ async function resolvePublishedRelease(packageName) { }; } +const RELEASE_DOWNLOAD_BASE = 'https://github.com/Pythoughts-labs/pythinker-code/releases/download'; + +/** + * Resolve the per-platform native artifacts for a published version. + * + * The release already carries a `manifest.json` asset written by + * `apps/pythinker-code/scripts/native/produce-manifest.mjs`, so this only turns + * `{ filename, checksum }` into `{ url, sha256 }`. Naming the artifact in + * `latest.json` is what lets a client answer "is there anything to download for + * my platform" from the manifest alone, instead of guessing an asset URL and + * polling GitHub for six minutes when the guess is wrong. + * + * Returns null — never throws — when the release shipped no native manifest. + * An npm-only release is legitimate, and a site build must not fail because of + * it; clients that see no `platforms` key keep their previous behaviour. + */ +async function resolvePlatformArtifacts(version) { + const base = `${RELEASE_DOWNLOAD_BASE}/%40pythoughts%2Fpythinker-code%40${version}`; + let native; + try { + const response = await fetch(`${base}/manifest.json`, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); + native = await response.json(); + } catch (error) { + console.log(`no native manifest for ${version} (${error.message}); omitting platforms`); + return null; + } + if (native?.version !== version) { + console.log(`native manifest reports ${native?.version}, expected ${version}; omitting platforms`); + return null; + } + const platforms = {}; + for (const [target, entry] of Object.entries(native.platforms ?? {})) { + if (typeof entry?.filename !== 'string' || !/^[a-f0-9]{64}$/.test(entry?.checksum ?? '')) { + throw new Error(`Malformed native manifest entry for ${target}`); + } + platforms[target] = { url: `${base}/${entry.filename}`, sha256: entry.checksum }; + } + return Object.keys(platforms).length > 0 ? platforms : null; +} + async function copyPlugins(repoRoot, cdnRoot) { const source = join(repoRoot, 'plugins/cdn'); const destination = join(cdnRoot, 'pythinker-code/plugins'); @@ -167,9 +208,11 @@ await cp(siteDist, outDir, { recursive: true }); const channelRoot = join(outDir, 'pythinker-code'); await mkdir(channelRoot, { recursive: true }); await writeFile(join(channelRoot, 'latest'), `${version}\n`); +const platforms = await resolvePlatformArtifacts(version); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ version, publishedAt, + platforms: platforms ?? undefined, rollout: [], }, null, 2)}\n`); await cp( From 085d6232989a2151f21213a8089d4b23c864c249 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 13:26:56 -0400 Subject: [PATCH 03/18] fix(installer): bound every network call in the installers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grep for max-time/connect-timeout over install.sh returned 0. A connection that accepted and never answered left the installer running forever, and its pid stays recorded as the active update, so one hung request wedged every update path with no expiry. curl now gets a connect cap, a per-attempt ceiling and a stall guard. The script owns retry, so --retry is deliberately absent: it resets the --max-time counter on every attempt. wget gets -T and nothing else, the one timeout flag BusyBox also understands — GNU's long options abort the install outright on Alpine-class systems. The PowerShell installer bounds the archive with a cancellation token instead. HttpClient.Timeout cannot do that job: its setter throws once the client has sent a request, and the metadata calls run first, and with ResponseHeadersRead it never covered the streaming body at all. Verified through the wired helpers, not by inspection: _fetch and _download_quiet against a black-holed address both abort after 10s with curl exit 28. --- apps/pythinker-web/public/install.ps1 | 47 +++++++++++++++++++++++++-- apps/pythinker-web/public/install.sh | 38 ++++++++++++++++++---- apps/site/public/install.ps1 | 47 +++++++++++++++++++++++++-- apps/site/public/install.sh | 38 ++++++++++++++++++---- 4 files changed, 150 insertions(+), 20 deletions(-) diff --git a/apps/pythinker-web/public/install.ps1 b/apps/pythinker-web/public/install.ps1 index b7aee99a..0c64344f 100644 --- a/apps/pythinker-web/public/install.ps1 +++ b/apps/pythinker-web/public/install.ps1 @@ -41,6 +41,15 @@ param( $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" + # Network timeouts, in seconds. The installer owns retry (helpers re-invoke + # up to 3 times with backoff), so no client-side retry is used and each bound + # covers exactly one attempt. + # - Metadata requests (CDN version, GitHub API, checksum): 30s total. + # - Archive download: 600s total. Both share a 10s connect cap. + $ConnectTimeoutSeconds = 10 + $MetadataTimeoutSeconds = 30 + $ArchiveTimeoutSeconds = 600 + $previousOutputEncoding = $null $previousSecurityProtocol = $null $httpClient = $null @@ -302,8 +311,23 @@ Unix / macOS / Linux users: $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $true + # Connect cap: 10s. HttpClientHandler.ConnectTimeout is available on + # PowerShell 7 (System.Net.Http on .NET Core) and on Windows PowerShell 5.1 + # hosts with .NET Framework 4.7.2+; the guard below skips it on older .NET + # Framework hosts, where the operation timeout still bounds the whole call. + try { + $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) + } catch { + # Older .NET Framework without ConnectTimeout: nothing to set; the + # operation timeout below still bounds the call. + } $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler - $client.Timeout = [TimeSpan]::FromMinutes(15) + # Operation timeout: 30s, and never reassigned — the setter throws once the + # client has sent its first request. For the metadata calls, which read with + # ResponseContentRead, this covers the whole operation (request, headers and + # body) on both PowerShell 7 and Windows PowerShell 5.1. The archive + # download bounds itself with a cancellation token; see Download-File. + $client.Timeout = [TimeSpan]::FromSeconds($MetadataTimeoutSeconds) [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') return $client @@ -365,6 +389,7 @@ Unix / macOS / Linux users: $inputStream = $null $outputStream = $null $stopwatch = $null + $attemptCts = $null $received = [long]0 $frameIndex = 0 @@ -378,7 +403,22 @@ Unix / macOS / Linux users: try { if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + # Archive download: bounded by a cancellation token, not by + # HttpClient.Timeout. Two reasons the property cannot do this job. + # First, its setter throws InvalidOperationException once the client has + # sent a request, and metadata calls have already run by the time we get + # here. Second, with ResponseHeadersRead it only bounds the wait for the + # headers, never the streaming body — so a connection that accepts and + # then stops would hang the installer forever, with its pid still + # recorded as the active update. + # One token covers the header wait and every read below. + # Total ceiling only, no per-read stall guard: curl's --speed-time + # equivalent needs a token per read. Add it if a 600s trickle ever + # shows up in the wild. + $attemptCts = New-Object System.Threading.CancellationTokenSource + $attemptCts.CancelAfter([TimeSpan]::FromSeconds($ArchiveTimeoutSeconds)) + + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $attemptCts.Token).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { $status = [int]$response.StatusCode throw "$Label failed with HTTP $status $($response.ReasonPhrase)" @@ -398,7 +438,7 @@ Unix / macOS / Linux users: $lastRenderMilliseconds = [long]-1000 while ($true) { - $read = $inputStream.Read($buffer, 0, $buffer.Length) + $read = $inputStream.ReadAsync($buffer, 0, $buffer.Length, $attemptCts.Token).GetAwaiter().GetResult() if ($read -le 0) { break } $outputStream.Write($buffer, 0, $read) @@ -434,6 +474,7 @@ Unix / macOS / Linux users: if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $response) { $response.Dispose() } + if ($null -ne $attemptCts) { $attemptCts.Dispose() } if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue if ($useAnimation) { diff --git a/apps/pythinker-web/public/install.sh b/apps/pythinker-web/public/install.sh index 9d5998d0..ad731cd7 100755 --- a/apps/pythinker-web/public/install.sh +++ b/apps/pythinker-web/public/install.sh @@ -28,6 +28,30 @@ NO_COLOR="${NO_COLOR:-}" REPO="Pythoughts-labs/pythinker-code" CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" +# Network timeout policy. The script owns retry — _download_with_progress and +# _download_quiet_with_retry re-invoke the helpers up to 3 times with backoff — +# so curl and wget must not add their own retry: curl's --max-time counter +# resets on every --retry attempt, which would let one logical attempt run far +# beyond its budget. With retry left to the script, --max-time bounds exactly +# one attempt, which is what the retry loops expect. +# +# Metadata requests (_content_length, _fetch): 10s connect, 30s total. +# Archive downloads (_download_quiet, _start_download): 10s connect, 600s total, +# plus a stall guard that aborts when throughput stays under 1024 bytes/s for +# 30s — a connection that is alive but crawling would otherwise burn the whole +# 600s budget. +# wget gets `-T` and nothing else on purpose. GNU's --connect-timeout / +# --read-timeout / --tries do not exist in BusyBox wget, which is the only wget +# on Alpine-class systems, and an unrecognized option there aborts the install +# outright — turning a working fallback into a hard failure. `-T` is understood +# by both: GNU treats it as dns+connect+read at once, BusyBox as the network +# read timeout. It is an inactivity bound, not a total one, so it is sized for +# "this connection is dead", not for the whole transfer. +CURL_META_OPTS=(--connect-timeout 10 --max-time 30) +WGET_META_OPTS=(-T 30) +CURL_ARCHIVE_OPTS=(--connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30) +WGET_ARCHIVE_OPTS=(-T 60) + # Operational globals are populated by main(). Keeping rendering helpers at # file scope makes the installer sourceable for regression tests and tooling. target="" @@ -322,7 +346,7 @@ _current_file_size() { _content_length() { local url="$1" command -v curl >/dev/null 2>&1 || return 1 - curl -fsIL "$url" 2>/dev/null \ + curl -fsIL "${CURL_META_OPTS[@]}" "$url" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2) bytes = $2 @@ -534,9 +558,9 @@ print_done() { _fetch() { local url="$1" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" + curl -fsSL "${CURL_META_OPTS[@]}" "$url" elif command -v wget >/dev/null 2>&1; then - wget -qO- "$url" + wget -qO- "${WGET_META_OPTS[@]}" "$url" else return 127 fi @@ -545,9 +569,9 @@ _fetch() { _download_quiet() { local url="$1" output="$2" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" else return 127 fi @@ -558,9 +582,9 @@ _start_download() { DOWNLOAD_PID="" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" & + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" & elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" & + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" & else return 127 fi diff --git a/apps/site/public/install.ps1 b/apps/site/public/install.ps1 index b7aee99a..0c64344f 100644 --- a/apps/site/public/install.ps1 +++ b/apps/site/public/install.ps1 @@ -41,6 +41,15 @@ param( $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" + # Network timeouts, in seconds. The installer owns retry (helpers re-invoke + # up to 3 times with backoff), so no client-side retry is used and each bound + # covers exactly one attempt. + # - Metadata requests (CDN version, GitHub API, checksum): 30s total. + # - Archive download: 600s total. Both share a 10s connect cap. + $ConnectTimeoutSeconds = 10 + $MetadataTimeoutSeconds = 30 + $ArchiveTimeoutSeconds = 600 + $previousOutputEncoding = $null $previousSecurityProtocol = $null $httpClient = $null @@ -302,8 +311,23 @@ Unix / macOS / Linux users: $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $true + # Connect cap: 10s. HttpClientHandler.ConnectTimeout is available on + # PowerShell 7 (System.Net.Http on .NET Core) and on Windows PowerShell 5.1 + # hosts with .NET Framework 4.7.2+; the guard below skips it on older .NET + # Framework hosts, where the operation timeout still bounds the whole call. + try { + $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) + } catch { + # Older .NET Framework without ConnectTimeout: nothing to set; the + # operation timeout below still bounds the call. + } $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler - $client.Timeout = [TimeSpan]::FromMinutes(15) + # Operation timeout: 30s, and never reassigned — the setter throws once the + # client has sent its first request. For the metadata calls, which read with + # ResponseContentRead, this covers the whole operation (request, headers and + # body) on both PowerShell 7 and Windows PowerShell 5.1. The archive + # download bounds itself with a cancellation token; see Download-File. + $client.Timeout = [TimeSpan]::FromSeconds($MetadataTimeoutSeconds) [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') return $client @@ -365,6 +389,7 @@ Unix / macOS / Linux users: $inputStream = $null $outputStream = $null $stopwatch = $null + $attemptCts = $null $received = [long]0 $frameIndex = 0 @@ -378,7 +403,22 @@ Unix / macOS / Linux users: try { if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + # Archive download: bounded by a cancellation token, not by + # HttpClient.Timeout. Two reasons the property cannot do this job. + # First, its setter throws InvalidOperationException once the client has + # sent a request, and metadata calls have already run by the time we get + # here. Second, with ResponseHeadersRead it only bounds the wait for the + # headers, never the streaming body — so a connection that accepts and + # then stops would hang the installer forever, with its pid still + # recorded as the active update. + # One token covers the header wait and every read below. + # Total ceiling only, no per-read stall guard: curl's --speed-time + # equivalent needs a token per read. Add it if a 600s trickle ever + # shows up in the wild. + $attemptCts = New-Object System.Threading.CancellationTokenSource + $attemptCts.CancelAfter([TimeSpan]::FromSeconds($ArchiveTimeoutSeconds)) + + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $attemptCts.Token).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { $status = [int]$response.StatusCode throw "$Label failed with HTTP $status $($response.ReasonPhrase)" @@ -398,7 +438,7 @@ Unix / macOS / Linux users: $lastRenderMilliseconds = [long]-1000 while ($true) { - $read = $inputStream.Read($buffer, 0, $buffer.Length) + $read = $inputStream.ReadAsync($buffer, 0, $buffer.Length, $attemptCts.Token).GetAwaiter().GetResult() if ($read -le 0) { break } $outputStream.Write($buffer, 0, $read) @@ -434,6 +474,7 @@ Unix / macOS / Linux users: if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $response) { $response.Dispose() } + if ($null -ne $attemptCts) { $attemptCts.Dispose() } if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue if ($useAnimation) { diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh index 9d5998d0..ad731cd7 100755 --- a/apps/site/public/install.sh +++ b/apps/site/public/install.sh @@ -28,6 +28,30 @@ NO_COLOR="${NO_COLOR:-}" REPO="Pythoughts-labs/pythinker-code" CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" +# Network timeout policy. The script owns retry — _download_with_progress and +# _download_quiet_with_retry re-invoke the helpers up to 3 times with backoff — +# so curl and wget must not add their own retry: curl's --max-time counter +# resets on every --retry attempt, which would let one logical attempt run far +# beyond its budget. With retry left to the script, --max-time bounds exactly +# one attempt, which is what the retry loops expect. +# +# Metadata requests (_content_length, _fetch): 10s connect, 30s total. +# Archive downloads (_download_quiet, _start_download): 10s connect, 600s total, +# plus a stall guard that aborts when throughput stays under 1024 bytes/s for +# 30s — a connection that is alive but crawling would otherwise burn the whole +# 600s budget. +# wget gets `-T` and nothing else on purpose. GNU's --connect-timeout / +# --read-timeout / --tries do not exist in BusyBox wget, which is the only wget +# on Alpine-class systems, and an unrecognized option there aborts the install +# outright — turning a working fallback into a hard failure. `-T` is understood +# by both: GNU treats it as dns+connect+read at once, BusyBox as the network +# read timeout. It is an inactivity bound, not a total one, so it is sized for +# "this connection is dead", not for the whole transfer. +CURL_META_OPTS=(--connect-timeout 10 --max-time 30) +WGET_META_OPTS=(-T 30) +CURL_ARCHIVE_OPTS=(--connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30) +WGET_ARCHIVE_OPTS=(-T 60) + # Operational globals are populated by main(). Keeping rendering helpers at # file scope makes the installer sourceable for regression tests and tooling. target="" @@ -322,7 +346,7 @@ _current_file_size() { _content_length() { local url="$1" command -v curl >/dev/null 2>&1 || return 1 - curl -fsIL "$url" 2>/dev/null \ + curl -fsIL "${CURL_META_OPTS[@]}" "$url" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2) bytes = $2 @@ -534,9 +558,9 @@ print_done() { _fetch() { local url="$1" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" + curl -fsSL "${CURL_META_OPTS[@]}" "$url" elif command -v wget >/dev/null 2>&1; then - wget -qO- "$url" + wget -qO- "${WGET_META_OPTS[@]}" "$url" else return 127 fi @@ -545,9 +569,9 @@ _fetch() { _download_quiet() { local url="$1" output="$2" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" else return 127 fi @@ -558,9 +582,9 @@ _start_download() { DOWNLOAD_PID="" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" & + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" & elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" & + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" & else return 127 fi From 5b99dfa18a8dbbf40ca5b60266ba1e7059a2365c Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 13:35:16 -0400 Subject: [PATCH 04/18] fix(update): do not offer a native update with no artifact for this platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client trusted the manifest version alone, so it advertised, prompted for and background-installed a release that had no build for the running platform. That is what left an installer polling GitHub for six minutes on every launch. One predicate, three call sites: the passive preflight, the TUI /update path and `pythinker upgrade`. Only the native source is gated — npm-family sources install from the registry, where the published version is the artifact, and homebrew installs through its formula, so suppressing those would be a regression. That exemption is the case the tests protect hardest. The preflight still refreshes in the background when it declines, or a client would freeze on the cached answer and never learn about the next release. --- apps/pythinker-code/src/cli/sub/upgrade.ts | 16 ++- .../src/cli/update/preflight.ts | 18 ++- apps/pythinker-code/src/cli/update/select.ts | 19 ++- .../test/cli/update/preflight.test.ts | 114 ++++++++++++++++++ .../test/cli/update/select.test.ts | 51 +++++++- apps/pythinker-code/test/cli/upgrade.test.ts | 76 ++++++++++++ 6 files changed, 290 insertions(+), 4 deletions(-) diff --git a/apps/pythinker-code/src/cli/sub/upgrade.ts b/apps/pythinker-code/src/cli/sub/upgrade.ts index 5260714c..22279ee6 100644 --- a/apps/pythinker-code/src/cli/sub/upgrade.ts +++ b/apps/pythinker-code/src/cli/sub/upgrade.ts @@ -2,7 +2,7 @@ import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import { track as trackTelemetry, type TelemetryProperties } from '@pythoughts/pythinker-telemetry'; import { refreshUpdateCache } from '#/cli/update/refresh'; -import { selectUpdateTarget } from '#/cli/update/select'; +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; import { detectInstallSource } from '#/cli/update/source'; import { canAutoInstall, @@ -85,6 +85,20 @@ export async function handleUpgrade( } const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so take the same exit as being up to date. + if (!isTargetInstallable(source, cache.manifest)) { + trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { + current_version: currentVersion, + }); + logUpgradeInfo(deps.logger, 'manual upgrade no update', { + currentVersion, + }); + deps.stdout.write( + `${formatDisplayVersion(target.version)} is published but has no build for this platform yet.\n`, + ); + return 0; + } const installCommand = installCommandFor(source, target.version, deps.platform); if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index d321ccaa..d5e3bf62 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -26,7 +26,7 @@ import { type InstallPromptOptions, } from './prompt'; import { refreshUpdateCache } from './refresh'; -import { selectUpdateTarget } from './select'; +import { isTargetInstallable, selectUpdateTarget } from './select'; import { appendRolloutDecisionLog, decidePassiveUpdateTarget, @@ -1032,6 +1032,11 @@ export async function startManualUpdate( const platform = process.platform; const source = await detectInstallSource().catch(() => 'unsupported' as const); + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so treat it as nothing to update. + if (!isTargetInstallable(source, cache.manifest)) { + return { status: 'up-to-date' }; + } const installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); if (hasFreshActiveInstall(installState)) { return { @@ -1206,6 +1211,17 @@ export async function runUpdatePreflight( ? 'unsupported' : await detectInstallSource().catch(() => 'unsupported' as const); + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so do not offer or start it. Non-native + // sources install from the registry/formula and are never gated here. + if (!isTargetInstallable(source, cachedManifest)) { + // Still refresh: the next manifest may carry this platform's artifact, + // and returning without one would freeze this client on the cached + // answer forever. + refreshInBackground(); + return 'continue'; + } + const decision = decideUpdateAction(target, isInteractive, source, platform); if (decision === 'none') { refreshInBackground(); diff --git a/apps/pythinker-code/src/cli/update/select.ts b/apps/pythinker-code/src/cli/update/select.ts index bf241ed8..c5ab033c 100644 --- a/apps/pythinker-code/src/cli/update/select.ts +++ b/apps/pythinker-code/src/cli/update/select.ts @@ -1,6 +1,7 @@ import { gt, valid } from 'semver'; -import { type UpdateTarget } from './types'; +import { manifestArtifactAvailability } from './cdn'; +import { type InstallSource, type UpdateManifest, type UpdateTarget } from './types'; export function selectUpdateTarget( currentVersion: string, @@ -11,3 +12,19 @@ export function selectUpdateTarget( if (!gt(latest, currentVersion)) return null; return { version: latest }; } + +/** + * Whether an update can be installed from this source at all. Only the native + * install path consumes a platform artifact, so only it must be suppressed + * when the manifest does not advertise one: + * - npm-family sources (`npm-global`, `pnpm-global`, `yarn-global`, + * `bun-global`) install from the npm registry, where the published version + * *is* the artifact — a missing native zip says nothing about them, and + * suppressing their update would be a regression. + * - `homebrew` installs through its own formula. + * - `unsupported` never installs anyway. + */ +export function isTargetInstallable(source: InstallSource, manifest: UpdateManifest | null): boolean { + if (source !== 'native') return true; + return manifestArtifactAvailability(manifest) === 'available'; +} diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 3773ae75..f6ad54c5 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -160,6 +160,31 @@ function releasedForEveryone(version: string): UpdateManifest { }); } +/** A manifest advertising an artifact for a platform other than the running one. */ +function manifestOmittingRunningTarget(version: string): UpdateManifest { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return manifestFor(version, { + platforms: { + [`${process.platform}-${otherArch}`]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }); +} + +/** A manifest advertising an artifact for the running platform. */ +function manifestForRunningTarget(version: string): UpdateManifest { + return manifestFor(version, { + platforms: { + [`${process.platform}-${process.arch}`]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }); +} + function installState(overrides: Partial = {}): UpdateInstallState { return { active: null, @@ -616,6 +641,61 @@ describe('runUpdatePreflight', () => { } }); + it('native: offers and installs nothing when the manifest omits the running platform', async () => { + const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(omitted); + mocks.refreshUpdateCache.mockResolvedValue(omitted); + mocks.detectInstallSource.mockResolvedValue('native'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(detectInstallSource).toHaveBeenCalledTimes(1); + }); + + it('native: prompts and installs when the manifest advertises the running platform', async () => { + disableAutoInstall(); + const advertised = cacheWithManifest(manifestForRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(advertised); + mocks.refreshUpdateCache.mockResolvedValue(advertised); + mocks.detectInstallSource.mockResolvedValue('native'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'native' }), + ); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('npm-global: still prompts and installs when the manifest omits the running platform', async () => { + disableAutoInstall(); + const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(omitted); + mocks.refreshUpdateCache.mockResolvedValue(omitted); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'npm-global' }), + ); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + it('unsupported: prints fallback npm command', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -1663,6 +1743,40 @@ describe('startManualUpdate', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); + it('native: reports up-to-date when the manifest omits the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('native'); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'up-to-date' }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('native: starts a background install when the manifest advertises the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestForRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('native'); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('npm-global: still starts the update when the manifest omits the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + }); + it('starts a background install for an auto-installable source', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); diff --git a/apps/pythinker-code/test/cli/update/select.test.ts b/apps/pythinker-code/test/cli/update/select.test.ts index a616a486..6eb72a71 100644 --- a/apps/pythinker-code/test/cli/update/select.test.ts +++ b/apps/pythinker-code/test/cli/update/select.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { selectUpdateTarget } from '#/cli/update/select'; +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; +import type { UpdateManifest } from '#/cli/update/types'; describe('selectUpdateTarget', () => { it('returns the latest version when it is newer than current', () => { @@ -32,3 +33,51 @@ describe('selectUpdateTarget', () => { expect(selectUpdateTarget('0.5.0', '0.5.0-rc.1')).toBeNull(); }); }); + +describe('isTargetInstallable', () => { + const artifact = { + url: 'https://code.pythinker.com/pythinker-code-0.5.0.zip', + sha256: 'a'.repeat(64), + }; + + function manifestWithPlatforms(platforms: Record): UpdateManifest { + return { + version: '0.5.0', + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + platforms, + }; + } + + function manifestOmittingRunningTarget(): UpdateManifest { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return manifestWithPlatforms({ [`${process.platform}-${otherArch}`]: artifact }); + } + + it('native: returns false when the manifest omits the running target', () => { + expect(isTargetInstallable('native', manifestOmittingRunningTarget())).toBe(false); + }); + + it('native: returns true when the manifest has an entry for the running target', () => { + expect( + isTargetInstallable('native', manifestWithPlatforms({ [`${process.platform}-${process.arch}`]: artifact })), + ).toBe(true); + }); + + it('native: returns true for a null manifest', () => { + expect(isTargetInstallable('native', null)).toBe(true); + }); + + it('native: returns true for a manifest with no platforms key', () => { + const manifest: UpdateManifest = { + version: '0.5.0', + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + }; + expect(isTargetInstallable('native', manifest)).toBe(true); + }); + + it('npm-global: returns true even when the manifest omits the running target', () => { + expect(isTargetInstallable('npm-global', manifestOmittingRunningTarget())).toBe(true); + }); +}); diff --git a/apps/pythinker-code/test/cli/upgrade.test.ts b/apps/pythinker-code/test/cli/upgrade.test.ts index 455df590..89efab24 100644 --- a/apps/pythinker-code/test/cli/upgrade.test.ts +++ b/apps/pythinker-code/test/cli/upgrade.test.ts @@ -16,6 +16,31 @@ function cacheWith( }; } +function platformManifest(version: string, platform: string): UpdateCache['manifest'] { + return { + version, + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + platforms: { + [platform]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }; +} + +/** A manifest advertising an artifact for a platform other than the running one. */ +function manifestOmittingRunningTarget(version: string): UpdateCache['manifest'] { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return platformManifest(version, `${process.platform}-${otherArch}`); +} + +/** A manifest advertising an artifact for the running platform. */ +function manifestForRunningTarget(version: string): UpdateCache['manifest'] { + return platformManifest(version, `${process.platform}-${process.arch}`); +} + function captureOutput(): { stdout: string[]; stderr: string[]; @@ -232,4 +257,55 @@ describe('handleUpgrade', () => { expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); }); + + it('native: refuses the update when the manifest omits the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'native', + manifest: manifestOmittingRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.detectInstallSource).toHaveBeenCalledTimes(1); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ + current_version: '0.4.0', + })); + expect(stdout.join('')).toContain('v0.5.0 is published but has no build for this platform yet.'); + }); + + it('native: installs when the manifest advertises the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'native', + manifest: manifestForRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('npm-global: still installs when the manifest omits the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + manifest: manifestOmittingRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_prompted', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); }); From 07d6365b26d92f8b57142d16062dbebbb4a14caa Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 13:53:12 -0400 Subject: [PATCH 05/18] feat(update): report real download progress from the background installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh rendered a determinate bar only when stdout was a TTY. The background installer has no TTY, and in that branch the script did not merely skip rendering — it called a single blocking curl and reported nothing at all, which is why an update in flight looked identical to one that was wedged. The non-animated branch now polls the same way the animated one does and emits one newline-terminated line per state on stderr, the stream the parent already pipes. The parent reads stderr by line: progress lines are parsed and recorded on the active install record, and are kept out of the failure tail, or a long download would evict the very error text that buffer exists to preserve. percent, transferred and total travel together, so an unknown size degrades to indeterminate instead of showing a fabricated percentage. Proven by execution against a local throttled HTTP server, with a known and an unknown Content-Length; nothing renders it yet. --- .../src/cli/update/install-state.ts | 13 +- .../src/cli/update/preflight.ts | 113 ++++++++- apps/pythinker-code/src/cli/update/types.ts | 18 ++ .../test/cli/update/install-state.test.ts | 97 ++++++++ .../test/cli/update/preflight.test.ts | 228 ++++++++++++++++++ apps/pythinker-web/public/install.sh | 71 +++++- apps/site/public/install.sh | 71 +++++- 7 files changed, 595 insertions(+), 16 deletions(-) create mode 100644 apps/pythinker-code/test/cli/update/install-state.test.ts diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index c34ce297..be4e49d5 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getUpdateInstallStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; -import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types'; +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallProgress, type UpdateInstallState } from './types'; const InstallSourceSchema: z.ZodType = z.enum([ 'npm-global', @@ -18,6 +18,16 @@ const InstallSourceSchema: z.ZodType = z.enum([ const UpdateInstallOperationSchema = z.enum(['install', 'prepare', 'activate']); const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); +const UpdateInstallProgressSchema: z.ZodType = z + .object({ + state: z.enum(['downloading', 'waiting', 'done', 'failed']), + percent: z.number().int().min(0).max(100).optional(), + transferred: z.number().int().nonnegative().optional(), + total: z.number().int().nonnegative().optional(), + updatedAt: z.string().min(1), + }) + .strict(); + const UpdateInstallStateSchema: z.ZodType = z .object({ active: z @@ -28,6 +38,7 @@ const UpdateInstallStateSchema: z.ZodType = z pid: z.number().int().positive().optional(), operation: UpdateInstallOperationSchema.optional(), jobId: z.uuid().optional(), + progress: UpdateInstallProgressSchema.optional(), }) .strict() .nullable(), diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index d5e3bf62..a1964614 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -42,6 +42,7 @@ import { type InstallSource, type UpdateDecision, type UpdateInstallOperation, + type UpdateInstallProgress, type UpdateInstallState, type UpdateCache, type UpdateManifest, @@ -614,21 +615,79 @@ export async function installUpdate( /** Keep the tail only: installers can be chatty, and the state file is small. */ const INSTALLER_STDERR_TAIL_CHARS = 2000; +/** At most one active-record progress write every 2s; terminal states bypass it. */ +const INSTALLER_PROGRESS_WRITE_INTERVAL_MS = 2_000; +const INSTALLER_PROGRESS_PREFIX = 'progress: '; + +function isInstallerProgressState(value: string): value is UpdateInstallProgress['state'] { + return value === 'downloading' || value === 'waiting' || value === 'done' || value === 'failed'; +} /** - * Buffer the installer's stderr so a failed background install records why it - * failed. Discarding it (the previous `stdio: 'ignore'`) left `lastFailure` - * with nothing but an exit code, which made a broken installer script - * impossible to diagnose without reproducing the spawn by hand. + * Parse one `progress: key=value key=value` line from the installer's stderr. + * Unknown or malformed keys are skipped — an installer from a different + * release must never crash the parent. Returns null when the line carries no + * usable state. */ -function captureStderrTail(child: ReturnType): () => string | undefined { +function parseInstallerProgressLine(line: string): UpdateInstallProgress | null { + let state: UpdateInstallProgress['state'] | undefined; + let percent: number | undefined; + let transferred: number | undefined; + let total: number | undefined; + for (const field of line.slice(INSTALLER_PROGRESS_PREFIX.length).split(/\s+/u)) { + const eq = field.indexOf('='); + if (eq <= 0) continue; + const key = field.slice(0, eq); + const value = field.slice(eq + 1); + switch (key) { + case 'state': + if (isInstallerProgressState(value)) state = value; + break; + case 'percent': + if (/^(?:100|[0-9]{1,2})$/u.test(value)) percent = Number(value); + break; + case 'transferred': + if (/^[0-9]+$/u.test(value)) transferred = Number(value); + break; + case 'total': + if (/^[0-9]+$/u.test(value)) total = Number(value); + break; + default: + break; + } + } + if (state === undefined) return null; + return { state, percent, transferred, total, updatedAt: nowIso() }; +} + +/** + * Read the installer's stderr as lines. `progress: …` lines are parsed and + * handed to `onProgress`; they never enter the failure tail, or a long + * download would evict the very error text the tail exists to preserve. All + * other lines are kept in the trailing `INSTALLER_STDERR_TAIL_CHARS` window. + * Returns a getter for that tail. + */ +function captureStderrTail( + child: ReturnType, + onProgress: (update: UpdateInstallProgress) => void, +): () => string | undefined { // Typed `Readable | null`, but absent entirely when stderr was not piped. const stream: Readable | null | undefined = child.stderr; if (stream === null || stream === undefined) return () => undefined; let tail = ''; + let partial = ''; stream.setEncoding('utf8'); stream.on('data', (chunk: string) => { - tail = (tail + chunk).slice(-INSTALLER_STDERR_TAIL_CHARS); + const lines = (partial + chunk).split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) { + if (line.startsWith(INSTALLER_PROGRESS_PREFIX)) { + const update = parseInstallerProgressLine(line); + if (update !== null) onProgress(update); + } else { + tail = (tail + line + '\n').slice(-INSTALLER_STDERR_TAIL_CHARS); + } + } }); // A detached installer outliving this process must not crash it, and the // pipe must not hold the event loop open on the way out. `child.stderr` is @@ -637,7 +696,12 @@ function captureStderrTail(child: ReturnType): () => string | unde stream.on('error', () => {}); (stream as Readable & { unref?: () => void }).unref?.(); return () => { - const trimmed = tail.trim(); + // A final partial line without a newline is still installer text; include + // it unless it is a truncated progress line. + const complete = partial.length === 0 || partial.startsWith(INSTALLER_PROGRESS_PREFIX) + ? tail + : tail + partial; + const trimmed = complete.trim(); return trimmed.length === 0 ? undefined : trimmed; }; } @@ -886,11 +950,42 @@ async function startBackgroundInstall( // of stdio; stdio: 'ignore' alone does not suppress it. windowsHide: platform === 'win32', // stdout stays discarded (install progress is noise); stderr is piped so - // a failure records the installer's own error text. + // the installer's machine-readable progress lines can be recorded and a + // failure still keeps the installer's own error text. stdio: ['ignore', 'ignore', 'pipe'], env: env === undefined ? undefined : { ...process.env, ...env }, }); - const readStderrTail = captureStderrTail(child); + let lastProgressWriteAt = 0; + const recordInstallerProgress = (update: UpdateInstallProgress): void => { + // Terminal states always persist; intermediate ones at most every 2s. + const terminal = update.state === 'done' || update.state === 'failed'; + if ( + !terminal + && Date.now() - lastProgressWriteAt < INSTALLER_PROGRESS_WRITE_INTERVAL_MS + ) return; + if (startedState.active === null) return; + lastProgressWriteAt = Date.now(); + const nextState: UpdateInstallState = { + ...startedState, + // Carry the pid explicitly: a progress line can arrive while the pid + // write is still in flight, and writing the pre-pid record over it + // would strip the pid this record's liveness check depends on. + active: { + ...startedState.active, + pid: child.pid ?? startedState.active.pid, + progress: update, + }, + }; + writeUpdateInstallState(nextState).catch((error) => { + // A progress write is best-effort; it must never reject the spawn path. + logUpdateWarn(logger, 'could not record installer progress', { + targetVersion: target.version, + source, + error: formatErrorMessage(error), + }); + }); + }; + const readStderrTail = captureStderrTail(child, recordInstallerProgress); child.once('error', (error) => { void finish(false, formatErrorMessage(error)); }); child.once('exit', (code, signal) => { void finish(code === 0, describeChildExit(cmd, code, signal)); diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index e20de662..61280637 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -53,6 +53,19 @@ export interface UpdateCache { export type UpdateInstallOperation = 'install' | 'prepare' | 'activate'; export type UpdateRequestOrigin = 'automatic' | 'manual'; +/** + * Latest machine-readable progress line from the background installer's + * stderr, as recorded on the active install record. + */ +export interface UpdateInstallProgress { + readonly state: 'downloading' | 'waiting' | 'done' | 'failed'; + /** Integer 0..100; absent while the download size is unknown. */ + readonly percent?: number; + readonly transferred?: number; + readonly total?: number; + readonly updatedAt: string; +} + export interface UpdateInstallActive { readonly version: string; readonly source: InstallSource; @@ -61,6 +74,11 @@ export interface UpdateInstallActive { readonly pid?: number; readonly operation?: UpdateInstallOperation; readonly jobId?: string; + /** + * Latest progress line from the installer; absent until the installer + * emits one or in records persisted by older versions. + */ + readonly progress?: UpdateInstallProgress; } export interface UpdatePreparedHomebrew { diff --git a/apps/pythinker-code/test/cli/update/install-state.test.ts b/apps/pythinker-code/test/cli/update/install-state.test.ts new file mode 100644 index 00000000..71483449 --- /dev/null +++ b/apps/pythinker-code/test/cli/update/install-state.test.ts @@ -0,0 +1,97 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyUpdateInstallState, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; +import type { UpdateInstallState } from '#/cli/update/types'; +import { getUpdateInstallStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pythinker-install-state-')); + process.env['PYTHINKER_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('update install state', () => { + it('round-trips an active record carrying installer progress', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + pid: 42_424, + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + await writeUpdateInstallState(state); + + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); + + it('round-trips progress without a total (unknown download size)', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + transferred: 5_320_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + await writeUpdateInstallState(state); + + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); + + it('falls back to an empty state when the active record has malformed progress', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateInstallStateFile(), + JSON.stringify({ + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { state: 'bogus', updatedAt: '2026-04-23T08:01:00.000Z' }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }), + 'utf-8', + ); + + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); +}); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index f6ad54c5..0b9419a6 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -300,6 +300,80 @@ function mockSpawnExitWithStderr(code: number, stderrText: string): void { }); } +/** + * Like mockSpawnExitWithStderr, but stderr arrives as several separate chunks + * so the line reader has to reassemble a progress line split mid-way across + * 'data' events. + */ +function mockSpawnExitWithChunkedStderr(code: number, chunks: string[]): void { + mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { + const stdio = options?.stdio; + const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; + const stderr = Object.assign(new EventEmitter(), { + setEncoding: vi.fn(), + unref: vi.fn(), + }); + const child = Object.assign(new EventEmitter(), { + pid: 42_424, + unref: vi.fn(), + stderr: stderrPiped ? stderr : null, + }); + queueMicrotask(() => { + if (stderrPiped) { + for (const chunk of chunks) stderr.emit('data', chunk); + } + child.emit('exit', code, null); + }); + return child; + }); +} + +/** + * Like mockSpawnExitWithStderr, but stderr chunks and the exit arrive on real + * timers, so the parent's write throttle sees realistic time deltas. + */ +function mockSpawnExitWithTimedStderr( + code: number, + chunks: Array<{ atMs: number; text: string }>, + exitAtMs: number, +): void { + mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { + const stdio = options?.stdio; + const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; + const stderr = Object.assign(new EventEmitter(), { + setEncoding: vi.fn(), + unref: vi.fn(), + }); + const child = Object.assign(new EventEmitter(), { + pid: 42_424, + unref: vi.fn(), + stderr: stderrPiped ? stderr : null, + }); + for (const chunk of chunks) { + setTimeout(() => { + if (stderrPiped) stderr.emit('data', chunk.text); + }, chunk.atMs); + } + setTimeout(() => { child.emit('exit', code, null); }, exitAtMs); + return child; + }); +} + +/** The failure messages written by the background-install finalizer, in order. */ +function progressFailureMessages(): string[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null && state.lastFailure !== undefined && state.lastFailure !== null) + .map((state) => state.lastFailure.message); +} + +/** The states written with an active record carrying progress, in order. */ +function progressActiveStates(): unknown[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null && state.active?.progress !== undefined); +} + async function flushBackgroundInstall(): Promise { await new Promise((resolve) => { setImmediate(resolve); @@ -1661,6 +1735,160 @@ describe('runUpdatePreflight', () => { ); }); }); + + describe('background installer progress lines', () => { + it('reassembles a progress line split across data events into one update', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithChunkedStderr(0, [ + 'progress: state=downloading percent=4', + '2 transferred=5320', + '000 total=12600000\n', + ]); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + }), + }), + })); + }); + + it('keeps progress lines out of the failure tail and ordinary stderr lines in it', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 1, + 'progress: state=downloading percent=42 transferred=5320000 total=12600000\n' + + 'bash: line 900: BASH_SOURCE[0]: unbound variable\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const messages = progressFailureMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('BASH_SOURCE[0]: unbound variable'); + expect(messages[0]).not.toContain('progress: state=downloading'); + expect(messages[0]).not.toContain('percent=42'); + }); + + it('leaves the real error in the tail after a hundred progress lines', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const progressLines = Array.from({ length: 100 }, (_, i) => ( + `progress: state=downloading percent=${i} transferred=${(i + 1) * 1000} total=12600000\n` + )).join(''); + mockSpawnExitWithStderr(1, `${progressLines}npm ERR! real failure\n`); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const messages = progressFailureMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('npm ERR! real failure'); + expect(messages[0]).not.toContain('progress:'); + expect(messages[0]).not.toContain('percent='); + }); + + it('ignores unknown keys and non-numeric percent values without throwing', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 0, + 'progress: state=downloading percent=not-a-number transferred=5320000 total=12600000 mystery=1\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + transferred: 5_320_000, + total: 12_600_000, + percent: undefined, + }), + }), + })); + }); + + it('accepts a downloading update without a total and without a percent', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr(0, 'progress: state=downloading transferred=5320000\n'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + transferred: 5_320_000, + percent: undefined, + total: undefined, + }), + }), + })); + }); + + it('throttles progress writes to one per two seconds but never drops the terminal update', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithTimedStderr( + 0, + [ + { atMs: 0, text: 'progress: state=downloading percent=10 transferred=1000 total=10000\n' }, + { atMs: 100, text: 'progress: state=downloading percent=20 transferred=2000 total=10000\n' }, + { atMs: 250, text: 'progress: state=done transferred=10000\n' }, + ], + 320, + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await new Promise((resolve) => setTimeout(resolve, 500)); + + const progressStates = progressActiveStates(); + expect(progressStates).toHaveLength(2); + expect(progressStates[0]).toEqual(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'downloading', percent: 10 }), + }), + })); + expect(progressStates[1]).toEqual(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'done', transferred: 10_000 }), + }), + })); + }); + }); }); describe('spawnForSource native', () => { diff --git a/apps/pythinker-web/public/install.sh b/apps/pythinker-web/public/install.sh index ad731cd7..b46ee1b1 100755 --- a/apps/pythinker-web/public/install.sh +++ b/apps/pythinker-web/public/install.sh @@ -592,19 +592,83 @@ _start_download() { DOWNLOAD_PID=$! } +# Machine-readable progress for the parent process. The background installer +# has no TTY, so stdout stays human-only (and is discarded by the spawn) and +# stderr carries the protocol: one newline-terminated line per update. +_emit_download_progress() { + local percent="${1:-}" current="$2" total="$3" + if [[ -n "$percent" ]]; then + printf 'progress: state=downloading percent=%s transferred=%s total=%s\n' \ + "$percent" "$current" "$total" >&2 + else + printf 'progress: state=downloading transferred=%s\n' "$current" >&2 + fi +} + # One download attempt with a live progress display. Returns non-zero on # transport failure, an empty file, or a size short of Content-Length. _download_attempt_with_progress() { local url="$1" output="$2" - local total="" pid="" current=0 percent=0 i=0 frame_index=0 + local total="" pid="" current=0 percent="" last_percent="-1" i=0 last_emit_i=-100 frame_index=0 local -a frames=('◐' '◓' '◑' '◒') rm -f "$output" if [[ -z "$_anim" ]]; then - _download_quiet "$url" "$output" || return 1 - _validate_download "$output" "" || return 1 + # Background install: no TTY, so no ANSI. Poll the same way as the + # animated branch, but report machine-readable lines on stderr instead of + # rendering a bar. One line per second at most, and only when the integer + # percent moved; the parent records these at most every 2s, so the + # protocol stays far below the parent's throttle. + if command -v curl >/dev/null 2>&1; then + total="$(_content_length "$url" || true)" + fi + + # `|| {...}` and not `if ! …`: after `if ! cmd`, `$?` inside the branch is + # the negation's 0, so the real failure code would be reported as success. + _start_download "$url" "$output" || { + local start_rc=$? + printf 'progress: state=failed\n' >&2 + return "$start_rc" + } + pid="$DOWNLOAD_PID" + + while kill -0 "$pid" 2>/dev/null; do + current="$(_current_file_size "$output")" + + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then + percent="$(_download_percent "$output" "$total" || printf '0')" + else + percent="" + fi + + # An unknown size has no percent to change, so it emits on the interval + # alone — otherwise a wget-only host would show one line and then look + # frozen for the whole download. + if (( i - last_emit_i >= 9 )) && [[ -z "$percent" || "$percent" != "$last_percent" ]]; then + _emit_download_progress "$percent" "$current" "$total" + last_percent="$percent" + last_emit_i="$i" + fi + + sleep 0.12 + i=$((i + 1)) + done + + if ! wait "$pid"; then + DOWNLOAD_PID="" + printf 'progress: state=failed\n' >&2 + return 1 + fi + DOWNLOAD_PID="" + + if ! _validate_download "$output" "$total"; then + printf 'progress: state=failed\n' >&2 + return 1 + fi + current="$(_current_file_size "$output")" + printf 'progress: state=done transferred=%s\n' "$current" >&2 status_ok 'Download complete' "$(_format_bytes "$current")" return 0 fi @@ -787,6 +851,7 @@ The release may still be publishing. Try again shortly, or pin a known-good vers _render_waiting "${frames[$((attempt % 4))]}" "$delay" else printf ' Waiting for release assets; retrying in %ss\n' "$delay" + printf 'progress: state=waiting retry_in=%s elapsed=%s\n' "$delay" "$elapsed" >&2 fi sleep "$delay" diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh index ad731cd7..b46ee1b1 100755 --- a/apps/site/public/install.sh +++ b/apps/site/public/install.sh @@ -592,19 +592,83 @@ _start_download() { DOWNLOAD_PID=$! } +# Machine-readable progress for the parent process. The background installer +# has no TTY, so stdout stays human-only (and is discarded by the spawn) and +# stderr carries the protocol: one newline-terminated line per update. +_emit_download_progress() { + local percent="${1:-}" current="$2" total="$3" + if [[ -n "$percent" ]]; then + printf 'progress: state=downloading percent=%s transferred=%s total=%s\n' \ + "$percent" "$current" "$total" >&2 + else + printf 'progress: state=downloading transferred=%s\n' "$current" >&2 + fi +} + # One download attempt with a live progress display. Returns non-zero on # transport failure, an empty file, or a size short of Content-Length. _download_attempt_with_progress() { local url="$1" output="$2" - local total="" pid="" current=0 percent=0 i=0 frame_index=0 + local total="" pid="" current=0 percent="" last_percent="-1" i=0 last_emit_i=-100 frame_index=0 local -a frames=('◐' '◓' '◑' '◒') rm -f "$output" if [[ -z "$_anim" ]]; then - _download_quiet "$url" "$output" || return 1 - _validate_download "$output" "" || return 1 + # Background install: no TTY, so no ANSI. Poll the same way as the + # animated branch, but report machine-readable lines on stderr instead of + # rendering a bar. One line per second at most, and only when the integer + # percent moved; the parent records these at most every 2s, so the + # protocol stays far below the parent's throttle. + if command -v curl >/dev/null 2>&1; then + total="$(_content_length "$url" || true)" + fi + + # `|| {...}` and not `if ! …`: after `if ! cmd`, `$?` inside the branch is + # the negation's 0, so the real failure code would be reported as success. + _start_download "$url" "$output" || { + local start_rc=$? + printf 'progress: state=failed\n' >&2 + return "$start_rc" + } + pid="$DOWNLOAD_PID" + + while kill -0 "$pid" 2>/dev/null; do + current="$(_current_file_size "$output")" + + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then + percent="$(_download_percent "$output" "$total" || printf '0')" + else + percent="" + fi + + # An unknown size has no percent to change, so it emits on the interval + # alone — otherwise a wget-only host would show one line and then look + # frozen for the whole download. + if (( i - last_emit_i >= 9 )) && [[ -z "$percent" || "$percent" != "$last_percent" ]]; then + _emit_download_progress "$percent" "$current" "$total" + last_percent="$percent" + last_emit_i="$i" + fi + + sleep 0.12 + i=$((i + 1)) + done + + if ! wait "$pid"; then + DOWNLOAD_PID="" + printf 'progress: state=failed\n' >&2 + return 1 + fi + DOWNLOAD_PID="" + + if ! _validate_download "$output" "$total"; then + printf 'progress: state=failed\n' >&2 + return 1 + fi + current="$(_current_file_size "$output")" + printf 'progress: state=done transferred=%s\n' "$current" >&2 status_ok 'Download complete' "$(_format_bytes "$current")" return 0 fi @@ -787,6 +851,7 @@ The release may still be publishing. Try again shortly, or pin a known-good vers _render_waiting "${frames[$((attempt % 4))]}" "$delay" else printf ' Waiting for release assets; retrying in %ss\n' "$delay" + printf 'progress: state=waiting retry_in=%s elapsed=%s\n' "$delay" "$elapsed" >&2 fi sleep "$delay" From 3890adabd3a56ea822122353ab2d28019294ffce Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:01:18 -0400 Subject: [PATCH 06/18] feat(tui): show update availability and download progress under the prompt bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer carried a progress slice that nothing dispatched and nothing tested: no producer, no consumer, rendered into the activity row above the composer. It is now the update slice, rendered in the status row under the composer where the request was for it to appear, reusing the context gauge's own bar glyphs so the two read as one design. An availability chip becomes a live download bar and then a restart prompt: `↑ v0.11.0`, `↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%`, `↑ v0.11.0 restart to apply`. An unknown download size renders without a bar rather than inventing a percentage. The mapping from persisted state to the slice is a pure function; the poll that feeds it resolves the install source once, reads both state files off the render path every two seconds, and dispatches only when the result changes. --- apps/pythinker-code/src/tui/pythinker-tui.ts | 73 ++++++ .../src/tui/runtime/footer/footer-model.ts | 79 +++--- .../src/tui/runtime/footer/update-status.ts | 66 +++++ .../test/tui/runtime/footer-model.test.ts | 117 +++++++++ .../tui/runtime/footer/update-status.test.ts | 247 ++++++++++++++++++ 5 files changed, 550 insertions(+), 32 deletions(-) create mode 100644 apps/pythinker-code/src/tui/runtime/footer/update-status.ts create mode 100644 apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index c2c0a5e7..00e4dadc 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -22,6 +22,10 @@ import type { MigrationPlan } from '@pythoughts/migration-legacy'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; +import { readUpdateCache } from '#/cli/update/cache'; +import { readUpdateInstallState } from '#/cli/update/install-state'; +import { detectInstallSource } from '#/cli/update/source'; +import type { InstallSource } from '#/cli/update/types'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { effortColorToken } from '#/tui/utils/thinking-levels'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; @@ -138,7 +142,9 @@ import { type FooterActivity, type FooterEvent, type FooterGoal, + type FooterUpdate, } from './runtime/footer/footer-model'; +import { footerUpdateFromState } from './runtime/footer/update-status'; import { LegacyPiPresentation } from './runtime/legacy-pi-presentation'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; @@ -191,6 +197,13 @@ export interface PythinkerTUIStartupInput { type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +/** Poll cadence for the update cache and install state files. */ +const UPDATE_STATUS_POLL_INTERVAL_MS = 2_000; + +function footerUpdateEquals(a: FooterUpdate, b: FooterUpdate): boolean { + return a.version === b.version && a.state === b.state && a.percent === b.percent; +} + function createInitialAppState(input: PythinkerTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto ? 'auto' @@ -292,6 +305,13 @@ export class PythinkerTUI { } | undefined; private stopKeybindingsWatcher: (() => void) | undefined; + private updateStatusSource: InstallSource | null = null; + private updateStatusTimer: ReturnType | undefined; + private lastDispatchedUpdate: FooterUpdate = { + version: null, + state: null, + percent: null, + }; public onExit?: (exitCode?: number) => Promise; @@ -601,6 +621,57 @@ export class PythinkerTUI { }); } + // Update availability + install progress poll. The install source is + // resolved once — it cannot change mid-session and detecting it repeatedly + // costs a subprocess. Both state files are read off the render path, and a + // quiet session repaints only when the computed update changes. + private startUpdateStatusPolling(): void { + void (async () => { + let source: InstallSource = 'unsupported'; + try { + source = await detectInstallSource(); + } catch { + // Detection failure means the update flow treats this install as unsupported. + } + this.updateStatusSource = source; + if (this.isShuttingDown) return; + await this.pollUpdateStatus(); + if (this.isShuttingDown) return; + this.updateStatusTimer = setInterval(() => { + void this.pollUpdateStatus(); + }, UPDATE_STATUS_POLL_INTERVAL_MS); + // A cosmetic poll must never be the reason the process refuses to exit. + this.updateStatusTimer.unref(); + })(); + } + + private async pollUpdateStatus(): Promise { + if (this.isShuttingDown || this.updateStatusSource === null) return; + try { + const [cache, installState] = await Promise.all([ + readUpdateCache(), + readUpdateInstallState(), + ]); + const update = footerUpdateFromState( + this.state.appState.version, + this.updateStatusSource, + cache, + installState, + ); + if (footerUpdateEquals(this.lastDispatchedUpdate, update)) return; + this.lastDispatchedUpdate = update; + this.dispatchFooter({ type: 'update.updated', update }); + } catch { + // An unreadable update state file means "no update to show", never a crash. + } + } + + private stopUpdateStatusPolling(): void { + if (this.updateStatusTimer === undefined) return; + clearInterval(this.updateStatusTimer); + this.updateStatusTimer = undefined; + } + private async refreshProviderModelsInBackground(): Promise { try { const result = await this.authFlow.refreshProviderModels(); @@ -617,6 +688,7 @@ export class PythinkerTUI { } private async finishStartup(shouldReplayHistory: boolean): Promise { + this.startUpdateStatusPolling(); if (this.startupNotice !== undefined) { this.showStatus(this.startupNotice); this.startupNotice = undefined; @@ -763,6 +835,7 @@ export class PythinkerTUI { this.editorKeyboard.clearPendingExit(); this.stopKeybindingsWatcher?.(); this.stopKeybindingsWatcher = undefined; + this.stopUpdateStatusPolling(); for (const dispose of this.reverseRpcDisposers) { dispose(); } diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index 3db8b5e7..48e805fe 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -78,10 +78,18 @@ export interface FooterCompaction { readonly label: string | null; } -export interface FooterTerminalProgress { - readonly active: boolean; +export type FooterUpdateState = + | 'available' + | 'downloading' + | 'waiting' + | 'ready' + | 'failed'; + +export interface FooterUpdate { + readonly version: string | null; + readonly state: FooterUpdateState | null; + /** Null means indeterminate — render without a bar rather than inventing a percentage. */ readonly percent: number | null; - readonly label: string | null; } export type FooterBtwPhase = 'closed' | 'running' | 'done' | 'failed'; @@ -146,7 +154,7 @@ export interface FooterState { readonly subagents: FooterSubagentCounts; readonly compaction: FooterCompaction; readonly transientHint: string | null; - readonly terminalProgress: FooterTerminalProgress; + readonly update: FooterUpdate; readonly btw: FooterBtwState; readonly status: FooterStatus; readonly composer: FooterComposerState; @@ -174,10 +182,7 @@ export type FooterEvent = readonly compaction: FooterCompaction; } | { readonly type: 'transient-hint.updated'; readonly hint: string | null } - | { - readonly type: 'terminal-progress.updated'; - readonly progress: FooterTerminalProgress; - } + | { readonly type: 'update.updated'; readonly update: FooterUpdate } | { readonly type: 'btw.updated'; readonly btw: FooterBtwState } | { readonly type: 'status.updated'; readonly changes: Partial } | { @@ -272,7 +277,7 @@ export function createFooterState( subagents: { active: 0, queued: 0, completed: 0, failed: 0 }, compaction: { active: false, label: null }, transientHint: null, - terminalProgress: { active: false, percent: null, label: null }, + update: { version: null, state: null, percent: null }, btw: { phase: 'closed', turnCount: 0 }, status: { ...DEFAULT_STATUS, ...status }, composer: { textLength: 0, placeholder: 'Composer' }, @@ -304,8 +309,8 @@ export function reduceFooterState( return freezeState({ ...state, compaction: event.compaction }); case 'transient-hint.updated': return freezeState({ ...state, transientHint: event.hint }); - case 'terminal-progress.updated': - return freezeState({ ...state, terminalProgress: event.progress }); + case 'update.updated': + return freezeState({ ...state, update: event.update }); case 'btw.updated': return freezeState({ ...state, btw: event.btw }); case 'status.updated': @@ -416,12 +421,6 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { state.activity.label?.trim() || defaultActivityLabel(state.activity.phase); spinnerActive = state.activity.spinnerActive; - } else if ( - state.terminalProgress.active && - state.terminalProgress.label !== null - ) { - primary = state.terminalProgress.label.trim(); - spinnerActive = true; } if (spinnerActive && primary.length > 0) { @@ -430,8 +429,6 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { } const indicators: string[] = []; - const progress = formatTerminalProgress(state.terminalProgress); - if (progress !== null) indicators.push(progress); if (state.queue.count > 0) { indicators.push( `[${String(nonNegativeInteger(state.queue.count))} queued]`, @@ -472,6 +469,8 @@ function selectStatusItems( statusLine: StatusLineConfig, ): string[] { const items: string[] = []; + const update = formatUpdate(state.update); + if (update !== null) items.push(update); const model = normalizeSingleLine(state.status.model); if (statusLine.showModel && model.length > 0) { const effortSuffix = @@ -608,19 +607,35 @@ function formatSessionSpend(spend: number | undefined): string | null { : `$${spend.toFixed(6).replace(/\.?0+$/, '')}`; } -function formatTerminalProgress( - progress: FooterTerminalProgress, -): string | null { - if (!progress.active) return null; - const parts = ['progress']; - if (progress.percent !== null && Number.isFinite(progress.percent)) { - parts.push( - `${String(Math.round(Math.min(100, Math.max(0, progress.percent))))}%`, - ); +/** `↑ v0.11.0` — or `↓` while the download is in flight. */ +function formatUpdate(update: FooterUpdate): string | null { + const version = update.version; + const state = update.state; + if (version === null || state === null) return null; + const base = `${state === 'available' || state === 'ready' || state === 'failed' ? '↑' : '↓'} v${version}`; + switch (state) { + case 'available': + return base; + case 'downloading': { + const percent = update.percent; + if (percent === null || !Number.isFinite(percent)) return base; + const rounded = Math.round(Math.min(100, Math.max(0, percent))); + const filled = Math.min( + CONTEXT_BAR_CELLS, + Math.round((rounded / 100) * CONTEXT_BAR_CELLS), + ); + const bar = + CONTEXT_BAR_FILLED.repeat(filled) + + CONTEXT_BAR_EMPTY.repeat(CONTEXT_BAR_CELLS - filled); + return `${base} ${bar} ${String(rounded)}%`; + } + case 'waiting': + return `${base} waiting`; + case 'ready': + return `${base} restart to apply`; + case 'failed': + return `${base} failed`; } - const label = normalizeSingleLine(progress.label ?? ''); - if (label.length > 0) parts.push(label); - return `[${parts.join(' ')}]`; } /** @@ -722,7 +737,7 @@ function freezeState(state: FooterState): FooterState { failed: nonNegativeInteger(state.subagents.failed), }), compaction: Object.freeze({ ...state.compaction }), - terminalProgress: Object.freeze({ ...state.terminalProgress }), + update: Object.freeze({ ...state.update }), btw: Object.freeze({ ...state.btw, turnCount: nonNegativeInteger(state.btw.turnCount), diff --git a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts new file mode 100644 index 00000000..0c053ccf --- /dev/null +++ b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts @@ -0,0 +1,66 @@ +import { gt, valid } from 'semver'; + +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; +import type { + InstallSource, + UpdateCache, + UpdateInstallState, +} from '#/cli/update/types'; +import type { FooterUpdate } from './footer-model'; + +/** + * Map the persisted update state onto the footer update slice. + * + * Precedence, highest first: + * 1. an active install for a newer version that is downloading or waiting + * 2. a recorded success for a newer version (needs a restart to apply) + * 3. a recorded failure for a newer version + * 4. an installable target advertised by the update cache + * 5. nothing + * + * Pure: no file reads, no clock, no `process.*` — everything comes in as + * arguments so the poller stays off the render path. + */ +export function footerUpdateFromState( + currentVersion: string, + source: InstallSource, + cache: UpdateCache | null, + installState: UpdateInstallState, +): FooterUpdate { + const active = installState.active; + const progress = active?.progress; + if ( + active !== null && + progress !== undefined && + isNewer(active.version, currentVersion) && + (progress.state === 'downloading' || progress.state === 'waiting') + ) { + return { + version: active.version, + state: progress.state, + percent: progress.percent ?? null, + }; + } + + const success = installState.lastSuccess; + if (success !== null && isNewer(success.version, currentVersion)) { + return { version: success.version, state: 'ready', percent: null }; + } + + const failure = installState.lastFailure; + if (failure !== null && isNewer(failure.version, currentVersion)) { + return { version: failure.version, state: 'failed', percent: null }; + } + + const target = selectUpdateTarget(currentVersion, cache?.latest ?? null); + if (target !== null && isTargetInstallable(source, cache?.manifest ?? null)) { + return { version: target.version, state: 'available', percent: null }; + } + + return { version: null, state: null, percent: null }; +} + +function isNewer(version: string, currentVersion: string): boolean { + if (valid(version) === null || valid(currentVersion) === null) return false; + return gt(version, currentVersion); +} diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index 5a9c6163..093ee995 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -14,6 +14,7 @@ import { type FooterEvent, type FooterStatus, type FooterStatusRowViewModel, + type FooterUpdate, } from '#/tui/runtime/footer/footer-model'; const CLOCK_MS = 90_000; @@ -470,4 +471,120 @@ describe('footer model', () => { /FooterModelCostRates|modelCostRates|formatModelRates|isValidRate|formatRate/, ); }); + + describe('update status row', () => { + function statusRowWithUpdate( + update: FooterUpdate, + statusLine: StatusLineConfig = hideAllStatusItems(), + ): FooterStatusRowViewModel { + const state = foldFooterEvents(createFooterState(), [ + { type: 'update.updated', update }, + ] satisfies readonly FooterEvent[]); + const row = selectFooterViewModel(state, CLOCK_MS, statusLine).rows.find( + (candidate) => candidate.kind === 'status', + ); + if (row?.kind !== 'status') throw new Error('Expected a status row'); + return row; + } + + it.each([ + [ + 'available', + { version: '0.11.0', state: 'available', percent: null }, + '↑ v0.11.0', + ], + [ + 'downloading with percent', + { version: '0.11.0', state: 'downloading', percent: 42 }, + '↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', + ], + [ + 'downloading without percent', + { version: '0.11.0', state: 'downloading', percent: null }, + '↓ v0.11.0', + ], + [ + 'waiting', + { version: '0.11.0', state: 'waiting', percent: null }, + '↓ v0.11.0 waiting', + ], + [ + 'ready', + { version: '0.11.0', state: 'ready', percent: null }, + '↑ v0.11.0 restart to apply', + ], + [ + 'failed', + { version: '0.11.0', state: 'failed', percent: null }, + '↑ v0.11.0 failed', + ], + ] as const)('renders %s first in the status row', (_name, update, expected) => { + expect(statusRowWithUpdate(update).items).toEqual([expected]); + }); + + it.each([ + [0, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], + [100, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], + [-5, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], + [150, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], + ] as const)('clamps percent %s into the eight-cell bar', (percent, expected) => { + const items = statusRowWithUpdate({ + version: '0.11.0', + state: 'downloading', + percent, + }).items; + + expect(items).toEqual([expected]); + }); + + it('adds no item for an empty update and leaves the status row unchanged', () => { + const state = foldFooterEvents(createFooterState(), [ + { type: 'update.updated', update: { version: null, state: null, percent: null } }, + ] satisfies readonly FooterEvent[]); + const base = selectFooterViewModel(createFooterState(), CLOCK_MS).rows.at(-1); + const updated = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); + + expect(updated).toEqual(base); + }); + + it('adds no item when the version is null', () => { + const row = statusRowWithUpdate({ + version: null, + state: 'available', + percent: null, + }); + + expect(row.items).toEqual([]); + }); + + it('keeps the update under the composer and out of the activity row', () => { + const state = foldFooterEvents(createFooterState(), [ + { + type: 'activity.updated', + activity: { + phase: 'thinking', + label: 'Thinking through the change', + spinnerActive: true, + spinnerFrame: '⠹', + }, + }, + { + type: 'update.updated', + update: { version: '0.11.0', state: 'downloading', percent: 42 }, + }, + ] satisfies readonly FooterEvent[]); + const rows = selectFooterViewModel(state, CLOCK_MS).rows; + + expect(rows.map((row) => row.kind)).toEqual(['activity', 'composer', 'status']); + expect(rows[0]).toMatchObject({ + kind: 'activity', + primary: '⠹ Thinking through the change', + indicators: [], + }); + expect(rows[2]).toMatchObject({ + kind: 'status', + items: ['↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', '▱▱▱▱▱▱▱▱ 0%'], + }); + }); + }); }); diff --git a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts new file mode 100644 index 00000000..bd0ee7b4 --- /dev/null +++ b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; + +import type { UpdateCache, UpdateInstallState } from '#/cli/update/types'; +import { footerUpdateFromState } from '#/tui/runtime/footer/update-status'; + +const CURRENT = '0.10.0'; +const NEWER = '0.11.0'; + +const EMPTY = { version: null, state: null, percent: null } as const; + +function installState( + overrides: Partial = {}, +): UpdateInstallState { + return { + active: null, + pending: null, + lastFailure: null, + lastSuccess: null, + ...overrides, + }; +} + +function cache(latest: string | null = NEWER): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest, + manifest: null, + }; +} + +describe('footerUpdateFromState', () => { + it('shows downloading when an active install is newer and downloading', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 42, + }); + }); + + it('shows waiting when an active install is newer and waiting', () => { + const state = installState({ + active: { + version: NEWER, + source: 'homebrew', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'waiting', + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'homebrew', null, state)).toEqual({ + version: NEWER, + state: 'waiting', + percent: null, + }); + }); + + it('ignores an active install without progress and falls through to nothing', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('ignores an active install older than the running version', () => { + const state = installState({ + active: { + version: CURRENT, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('shows ready after a newer version was installed', () => { + const state = installState({ + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'ready', + percent: null, + }); + }); + + it('shows nothing when the last success is the running version', () => { + const state = installState({ + lastSuccess: { + version: CURRENT, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('shows failed after a newer install failed', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 2, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'failed', + percent: null, + }); + }); + + it('prefers the active install over a recorded success', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 10, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 10, + }); + }); + + it('prefers a recorded success over a recorded failure', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 1, + }, + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:03:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'ready', + percent: null, + }); + }); + + it('prefers a recorded failure over an available target', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 1, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', cache(), state)).toEqual({ + version: NEWER, + state: 'failed', + percent: null, + }); + }); + + it('shows available when the cache targets a newer installable version', () => { + expect(footerUpdateFromState(CURRENT, 'native', cache(), installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + }); + + it('shows nothing when the cache target is not installable from this source', () => { + const unavailableCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + platforms: {}, + }, + }; + + expect( + footerUpdateFromState(CURRENT, 'native', unavailableCache, installState()), + ).toEqual(EMPTY); + }); + + it('shows nothing when the cache is null', () => { + expect(footerUpdateFromState(CURRENT, 'native', null, installState())).toEqual(EMPTY); + }); + + it('shows nothing when the cache has no newer latest', () => { + expect(footerUpdateFromState(CURRENT, 'native', cache(CURRENT), installState())).toEqual( + EMPTY, + ); + }); +}); From 7e2b4365e9a497e48b07c3d6ca2bde61f8358add Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:08:49 -0400 Subject: [PATCH 07/18] refactor(update): make the manifest the client's only update source Two compatibility paths, both now actively harmful, both deleted rather than improved. The client fell back to the plain-text /latest endpoint whenever latest.json failed to parse. That endpoint carries no per-platform artifact data, so the fallback turned "cannot verify this platform has a build" into "verified" and re-opened the hole the platforms key exists to close. It also could not fail independently: both files come from the same generator in the same deploy, and the manifest schema already tolerates unknown fields and a malformed platforms value without failing the parse. A bad manifest now keeps the cached answer instead of silently downgrading to an unverifiable one. /latest is still published for install.sh and for clients shipped before the manifest. apps/site/public/ held byte-identical copies of install.sh and install.ps1, which is how a one-sided edit ships silently. There is one checked-in installer now; the site root path keeps working because build-cdn places the file there. fetchLatestFromCdn returned {latest, manifest} where latest was always manifest.version, so the wrapper type is gone with it. --- apps/pythinker-code/src/cli/update/cdn.ts | 61 +- apps/pythinker-code/src/cli/update/refresh.ts | 21 +- apps/pythinker-code/src/constant/app.ts | 8 +- .../test/cli/update/cdn.test.ts | 233 ++-- .../test/cli/update/refresh.test.ts | 17 +- apps/site/public/install.ps1 | 965 ---------------- apps/site/public/install.sh | 1000 ----------------- apps/site/scripts/build-cdn.mjs | 21 +- 8 files changed, 120 insertions(+), 2206 deletions(-) delete mode 100644 apps/site/public/install.ps1 delete mode 100755 apps/site/public/install.sh diff --git a/apps/pythinker-code/src/cli/update/cdn.ts b/apps/pythinker-code/src/cli/update/cdn.ts index b0483846..50ad20cb 100644 --- a/apps/pythinker-code/src/cli/update/cdn.ts +++ b/apps/pythinker-code/src/cli/update/cdn.ts @@ -1,7 +1,7 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; import type { UpdateManifest } from './types'; @@ -55,13 +55,6 @@ export const UpdateManifestSchema = z.object({ .catch(undefined), }); -export interface FetchLatestResult { - /** Raw newest version — what `pythinker upgrade` installs, never rollout-gated. */ - readonly latest: string; - /** Null when the JSON manifest was unavailable and we fell back to plain text. */ - readonly manifest: UpdateManifest | null; -} - async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { @@ -75,30 +68,25 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise } /** - * Fetch the latest published Pythinker Code version from the CDN. + * Fetch the CDN update manifest — the client's only source of update truth. * - * **Throws** on any failure (network error, non-2xx, empty body, non-semver - * text). Callers must catch — `refreshUpdateCache` deliberately lets the - * error propagate so the existing cache stays intact instead of being - * overwritten with a null `latest` on a transient blip. + * **Throws** on any failure (network error, non-2xx, unparseable body). Callers + * must catch: `refreshUpdateCache` deliberately lets the error propagate so the + * existing cache stays intact instead of being overwritten on a transient blip. + * + * There is deliberately no fallback to the plain-text `/latest` endpoint, which + * still exists for `install.sh`. That endpoint carries no per-platform artifact + * data, so falling back to it turns "cannot verify this platform has a build" + * into "verified" and re-opens the hole `platforms` exists to close. It also + * cannot fail independently: both files come from the same generator in the same + * deploy, and the manifest schema already tolerates unknown fields and a + * malformed `platforms` value without failing the parse. * * `fetchImpl` is injectable for tests; defaults to the global `fetch`. */ -export async function fetchLatestVersionFromCdn( +export async function fetchUpdateManifest( fetchImpl: typeof fetch = fetch, -): Promise { - const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_URL); - if (!response.ok) { - throw new Error(`CDN /latest returned HTTP ${response.status}`); - } - const raw = (await response.text()).trim(); - if (valid(raw) === null) { - throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`); - } - return raw; -} - -async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { +): Promise { const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); @@ -106,25 +94,6 @@ async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); - if (manifest !== null) { - return { latest: manifest.version, manifest }; - } - const latest = await fetchLatestVersionFromCdn(fetchImpl); - return { latest, manifest: null }; -} - export type ArtifactAvailability = 'available' | 'unavailable'; /** diff --git a/apps/pythinker-code/src/cli/update/refresh.ts b/apps/pythinker-code/src/cli/update/refresh.ts index 938a4a0f..dbc842c1 100644 --- a/apps/pythinker-code/src/cli/update/refresh.ts +++ b/apps/pythinker-code/src/cli/update/refresh.ts @@ -1,14 +1,13 @@ import { writeUpdateCache } from './cache'; -import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; -import { type UpdateCache } from './types'; +import { fetchUpdateManifest } from './cdn'; +import { type UpdateCache, type UpdateManifest } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the latest version + rollout manifest. **Throws** on any - * failure — callers (including the default background invocation in - * preflight) must catch. Errors intentionally skip `writeCache` so a - * transient CDN blip does not overwrite a previously known `latest` with - * `null`. */ - readonly fetchLatest: () => Promise; + /** Resolves with the CDN update manifest. **Throws** on any failure — callers + * (including the default background invocation in preflight) must catch. + * Errors intentionally skip `writeCache` so a transient CDN blip does not + * overwrite a previously known `latest` with `null`. */ + readonly fetchManifest: () => Promise; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -17,16 +16,16 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { const resolved: RefreshUpdateCacheDeps = { - fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), + fetchManifest: overrides.fetchManifest ?? (() => fetchUpdateManifest()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const { latest, manifest } = await resolved.fetchLatest(); + const manifest = await resolved.fetchManifest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), - latest, + latest: manifest.version, manifest, }; await resolved.writeCache(cache); diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index f6af29a2..265516c8 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -51,10 +51,10 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const PYTHINKER_CODE_CDN_BASE = 'https://code.pythinker.com/pythinker-code'; -export const PYTHINKER_CODE_CDN_LATEST_URL = `${PYTHINKER_CODE_CDN_BASE}/latest`; -// Rollout manifest consumed by update checks; the plain-text `/latest` above -// stays unchanged forever — already-shipped clients hard-fail on non-semver -// bodies, and the CDN install scripts read it for fresh installs. +// The only update source this client reads. The plain-text `/latest` endpoint +// still exists on the CDN for install.sh and for clients shipped before the +// manifest, but it carries no per-platform artifact data, so reading it here +// would report an unverifiable target as verified. export const PYTHINKER_CODE_CDN_LATEST_JSON_URL = `${PYTHINKER_CODE_CDN_BASE}/latest.json`; export const PYTHINKER_CODE_TIPS_BANNER_URL = 'https://cdn.pythinker.com/pythinker-code-tips/tips.json'; export const PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL = `${PYTHINKER_CODE_CDN_BASE}/plugins/marketplace.json`; diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index e6fbab2c..fee1f0a1 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -1,27 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { - fetchLatestFromCdn, - fetchLatestVersionFromCdn, - manifestArtifactAvailability, -} from '#/cli/update/cdn'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; - -function mockFetchOk(body: string): typeof fetch { - return vi.fn(async () => ({ - ok: true, - status: 200, - text: async () => body, - })) as unknown as typeof fetch; -} - -function mockFetchStatus(status: number): typeof fetch { - return vi.fn(async () => ({ - ok: status >= 200 && status < 300, - status, - text: async () => '', - })) as unknown as typeof fetch; -} +import { fetchUpdateManifest, manifestArtifactAvailability } from '#/cli/update/cdn'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; type Route = { readonly status?: number; readonly body?: string } | Error; @@ -53,52 +33,17 @@ const MANIFEST_BODY = JSON.stringify({ ], }); -describe('fetchLatestVersionFromCdn', () => { - it('returns the trimmed semver returned by CDN /latest', async () => { - const f = mockFetchOk(' 0.5.0\n'); - await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); - expect(f).toHaveBeenCalledWith( - PYTHINKER_CODE_CDN_LATEST_URL, - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it('throws when response is non-2xx', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchStatus(404))).rejects.toThrow(/HTTP 404/); - }); - - it('throws when body is not valid semver', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchOk('not-a-version'))).rejects.toThrow( - /invalid semver/, - ); - }); - - it('throws when body is empty', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchOk(' '))).rejects.toThrow(/invalid semver/); - }); - - it('propagates the underlying fetch error', async () => { - const f = vi.fn(async () => { - throw new Error('network down'); - }) as unknown as typeof fetch; - await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); - }); -}); - -describe('fetchLatestFromCdn', () => { +describe('fetchUpdateManifest', () => { it('parses latest.json and returns the manifest', async () => { const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); - await expect(fetchLatestFromCdn(f)).resolves.toEqual({ - latest: '2.0.0', - manifest: { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [ - { percent: 30, delaySeconds: 0 }, - { percent: 30, delaySeconds: 43_200 }, - { percent: 40, delaySeconds: 86_400 }, - ], - }, + await expect(fetchUpdateManifest(f)).resolves.toEqual({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], }); expect(f).toHaveBeenCalledWith( PYTHINKER_CODE_CDN_LATEST_JSON_URL, @@ -116,8 +61,8 @@ describe('fetchLatestFromCdn', () => { futureField: { nested: true }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.manifest).toEqual({ + const result = await fetchUpdateManifest(f); + expect(result).toEqual({ version: '2.0.0', publishedAt: '2026-06-12T00:00:00.000Z', rollout: [], @@ -130,8 +75,8 @@ describe('fetchLatestFromCdn', () => { publishedAt: '2026-06-12T00:00:00.000Z', }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.manifest?.rollout).toEqual([]); + const result = await fetchUpdateManifest(f); + expect(result.rollout).toEqual([]); }); it('drops a platforms entry with an invalid sha256 but keeps the manifest', async () => { @@ -146,11 +91,10 @@ describe('fetchLatestFromCdn', () => { }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.latest).toBe('2.0.0'); - expect(result.manifest?.version).toBe('2.0.0'); - expect(result.manifest?.platforms).toBeUndefined(); - expect(manifestArtifactAvailability(result.manifest, 'darwin-arm64')).toBe('available'); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); }); it('drops a platforms entry with a non-URL url but keeps the manifest', async () => { @@ -162,11 +106,10 @@ describe('fetchLatestFromCdn', () => { }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.latest).toBe('2.0.0'); - expect(result.manifest?.version).toBe('2.0.0'); - expect(result.manifest?.platforms).toBeUndefined(); - expect(manifestArtifactAvailability(result.manifest, 'darwin-arm64')).toBe('available'); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); }); it('carries a well-formed platforms record onto the parsed manifest', async () => { @@ -186,9 +129,9 @@ describe('fetchLatestFromCdn', () => { }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.latest).toBe('2.0.0'); - expect(result.manifest?.platforms).toEqual({ + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toEqual({ 'darwin-arm64': { url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', sha256: 'a'.repeat(64), @@ -200,87 +143,55 @@ describe('fetchLatestFromCdn', () => { }); }); - const fallbackCases: ReadonlyArray = [ - ['latest.json is missing (HTTP 404)', { status: 404 }], - ['latest.json fetch throws', new Error('network down')], - ['body is not valid JSON', { body: 'not json {' }], - ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], - ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], - ['a batch percent is out of range', { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 150, delaySeconds: 0 }], - }), - }], - ['a batch delay is negative', { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 100, delaySeconds: -1 }], - }), - }], + // No fallback: the plain-text `/latest` carries no per-platform artifact data, + // so reading it after a bad manifest would report an unverifiable target as + // verified. Every one of these must reject and leave the cached answer alone. + const rejectCases: ReadonlyArray = [ + ['latest.json is missing (HTTP 404)', { status: 404 }, /HTTP 404/], + ['latest.json fetch throws', new Error('network down'), /network down/], + ['body is not valid JSON', { body: 'not json {' }, /JSON/i], + [ + 'version is not semver', + { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }, + /invalid semver/, + ], + [ + 'publishedAt is unparseable', + { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }, + /invalid timestamp/, + ], + [ + 'a batch percent is out of range', + { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }, + /./, + ], + [ + 'a batch delay is negative', + { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }, + /./, + ], ]; - for (const [name, route] of fallbackCases) { - it(`falls back to plain /latest when ${name}`, async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route, - [PYTHINKER_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, - }); - await expect(fetchLatestFromCdn(f)).resolves.toEqual({ - latest: '1.9.0', - manifest: null, - }); + for (const [name, route, message] of rejectCases) { + it(`rejects when ${name}`, async () => { + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route }); + await expect(fetchUpdateManifest(f)).rejects.toThrow(message); }); } - it('throws when both latest.json and plain /latest fail', async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, - [PYTHINKER_CODE_CDN_LATEST_URL]: { status: 500 }, - }); - await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); - }); - - it('propagates the plain /latest error when the fallback also breaks', async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), - [PYTHINKER_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, - }); - await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); - }); - - it('falls back to plain /latest when latest.json hangs past the request timeout', async () => { - vi.useFakeTimers(); - try { - const f = vi.fn(async (input: string | URL, init?: RequestInit) => { - if (String(input) === PYTHINKER_CODE_CDN_LATEST_JSON_URL) { - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { - reject(new Error('aborted')); - }, { once: true }); - }); - } - if (String(input) === PYTHINKER_CODE_CDN_LATEST_URL) { - return { ok: true, status: 200, text: async () => '1.9.0\n' }; - } - return { ok: false, status: 404, text: async () => '' }; - }) as unknown as typeof fetch; - - const result = fetchLatestFromCdn(f); - await vi.advanceTimersByTimeAsync(3_000); - - await expect(result).resolves.toEqual({ - latest: '1.9.0', - manifest: null, - }); - } finally { - vi.useRealTimers(); - } - }); - - it('rejects when plain /latest also hangs past the request timeout', async () => { + it('rejects when latest.json hangs past the request timeout', async () => { vi.useFakeTimers(); try { const f = vi.fn(async (_input: string | URL, init?: RequestInit) => { @@ -291,9 +202,9 @@ describe('fetchLatestFromCdn', () => { }); }) as unknown as typeof fetch; - const result = fetchLatestFromCdn(f); + const result = fetchUpdateManifest(f); const expectation = expect(result).rejects.toThrow(/aborted/); - await vi.advanceTimersByTimeAsync(6_000); + await vi.advanceTimersByTimeAsync(3_000); await expectation; } finally { diff --git a/apps/pythinker-code/test/cli/update/refresh.test.ts b/apps/pythinker-code/test/cli/update/refresh.test.ts index ceb1306f..f0061006 100644 --- a/apps/pythinker-code/test/cli/update/refresh.test.ts +++ b/apps/pythinker-code/test/cli/update/refresh.test.ts @@ -17,7 +17,7 @@ describe('refreshUpdateCache', () => { it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), + fetchManifest: async () => MANIFEST, writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -31,28 +31,23 @@ describe('refreshUpdateCache', () => { expect(writeCache).toHaveBeenCalledWith(result); }); - it('writes a null manifest when the fetch fell back to plain text', async () => { + it('takes `latest` from the manifest rather than a separate field', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), + fetchManifest: async () => ({ ...MANIFEST, version: '0.6.0' }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); - expect(result).toEqual({ - source: 'cdn', - checkedAt: '2026-05-20T12:34:56.000Z', - latest: '0.5.0', - manifest: null, - }); - expect(writeCache).toHaveBeenCalledWith(result); + expect(result.latest).toBe('0.6.0'); + expect(result.manifest?.version).toBe('0.6.0'); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { const writeCache = vi.fn(async () => {}); await expect( refreshUpdateCache({ - fetchLatest: async () => { + fetchManifest: async () => { throw new Error('network down'); }, writeCache, diff --git a/apps/site/public/install.ps1 b/apps/site/public/install.ps1 deleted file mode 100644 index 0c64344f..00000000 --- a/apps/site/public/install.ps1 +++ /dev/null @@ -1,965 +0,0 @@ -# Pythinker Code — native Windows installer. -# -# Downloads the native single-file binary (pythinker-code-win32-.zip) -# from the GitHub Release matching the CDN's latest version, verifies its -# SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker -# (added to the user PATH). -# -# Usage: -# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -# -# To pin a version when running the hosted script, set: -# $env:PYTHINKER_VERSION = "0.6.0"; irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -# -# Or run the script directly: -# .\install.ps1 -Version 0.6.0 -# -# Terminal controls: -# $env:PYTHINKER_NO_ANIMATION = "1" # Disable motion, keep concise output. -# $env:NO_COLOR = "1" # Disable ANSI colors. - -[CmdletBinding()] -param( - [string]$Version = $env:PYTHINKER_VERSION, - [switch]$Help -) - -# Invoke the implementation in a child scope. This matters for the hosted -# `irm ... | iex` form: functions, preferences, and temporary variables must -# not leak into the caller's interactive PowerShell session. -& { - param( - [string]$RequestedVersion, - [bool]$ShowHelp - ) - - $ErrorActionPreference = "Stop" - Set-StrictMode -Version 2.0 - - $Repo = "Pythoughts-labs/pythinker-code" - $CdnLatestUrl = "https://code.pythinker.com/pythinker-code/latest" - $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" - $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" - - # Network timeouts, in seconds. The installer owns retry (helpers re-invoke - # up to 3 times with backoff), so no client-side retry is used and each bound - # covers exactly one attempt. - # - Metadata requests (CDN version, GitHub API, checksum): 30s total. - # - Archive download: 600s total. Both share a 10s connect cap. - $ConnectTimeoutSeconds = 10 - $MetadataTimeoutSeconds = 30 - $ArchiveTimeoutSeconds = 600 - - $previousOutputEncoding = $null - $previousSecurityProtocol = $null - $httpClient = $null - $installMutex = $null - $mutexHeld = $false - $tempDir = $null - $stagingBinary = $null - $backupBinary = $null - $targetPath = $null - - try { $previousOutputEncoding = [Console]::OutputEncoding } catch {} - try { $previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol } catch {} - - try { - [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) - } catch {} - - # Add TLS 1.2 without discarding newer protocols selected by the host. - try { - $currentProtocols = [Net.ServicePointManager]::SecurityProtocol - $tls12 = [Net.SecurityProtocolType]::Tls12 - if (($currentProtocols -band $tls12) -eq 0) { - [Net.ServicePointManager]::SecurityProtocol = $currentProtocols -bor $tls12 - } - } catch {} - - function Test-EnvironmentVariablePresent([string]$Name) { - return $null -ne [Environment]::GetEnvironmentVariable($Name, 'Process') - } - - function Test-InteractiveTerminal { - try { - if ([Console]::IsOutputRedirected) { return $false } - $null = $Host.UI.RawUI.WindowSize - return $true - } catch { - return $false - } - } - - function Test-AnsiSupport { - if (-not (Test-InteractiveTerminal)) { return $false } - - $term = [Environment]::GetEnvironmentVariable('TERM', 'Process') - if ($term -and $term -ieq 'dumb') { return $false } - - try { - if ([bool]$Host.UI.SupportsVirtualTerminal) { return $true } - } catch {} - - if (Test-EnvironmentVariablePresent 'WT_SESSION') { return $true } - if (Test-EnvironmentVariablePresent 'ANSICON') { return $true } - if ($env:ConEmuANSI -eq 'ON') { return $true } - if ($term -and $term -match '(?i)(xterm|ansi|screen|cygwin|msys|vt100)') { return $true } - - return $false - } - - $interactiveTerminal = Test-InteractiveTerminal - $ansiSupported = Test-AnsiSupport - $useColor = $ansiSupported -and -not (Test-EnvironmentVariablePresent 'NO_COLOR') - $useAnimation = $ansiSupported ` - -and $interactiveTerminal ` - -and -not (Test-EnvironmentVariablePresent 'CI') ` - -and -not (Test-EnvironmentVariablePresent 'PYTHINKER_NO_ANIMATION') - - $ESC = [char]27 - $NAVY = $FACE = $ACCENT = $TIP = $EYE = $BAR = $DIM = $BOLD = $RESET = $SHINE = $SOFT = $ERROR_COLOR = "" - if ($useColor) { - $NAVY = "$ESC[38;5;24m" - $FACE = "$ESC[38;5;255m" - $ACCENT = "$ESC[38;5;147m" - $TIP = "$ESC[38;5;216m" - $EYE = "$ESC[38;5;189m" - $BAR = "$ESC[38;5;250m" - $DIM = "$ESC[2m" - $BOLD = "$ESC[1m" - $RESET = "$ESC[0m" - $SHINE = "$ESC[38;5;231m" - $SOFT = "$ESC[38;5;111m" - $ERROR_COLOR = "$ESC[38;5;203m" - } - - $HIDE_CURSOR = "" - $SHOW_CURSOR = "" - $CLEAR_LINE = "" - if ($useAnimation) { - $HIDE_CURSOR = "$ESC[?25l" - $SHOW_CURSOR = "$ESC[?25h" - $CLEAR_LINE = "$ESC[2K" - } - - function Stop-Installer([string]$Message) { - throw "Pythinker Code install failed: $Message" - } - - function Show-Usage { - @" -Pythinker Code — native Windows installer. - -Downloads the native single-file binary (pythinker-code-win32-.zip) -from the GitHub Release matching the CDN's latest version, verifies its -SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker -(added to the user PATH). - -Usage: - irm $InstallPs1Url | iex - - # Pin a version: - `$env:PYTHINKER_VERSION = "0.6.0"; irm $InstallPs1Url | iex - - # Or run directly: - .\install.ps1 -Version 0.6.0 - -Terminal controls: - `$env:PYTHINKER_NO_ANIMATION = "1" # Disable motion. - `$env:NO_COLOR = "1" # Disable ANSI colors. - -Unix / macOS / Linux users: - curl -fsSL $InstallShUrl | bash -"@ - } - - function Get-TerminalWidth { - $width = 80 - try { $width = [int]$Host.UI.RawUI.WindowSize.Width } catch {} - return [Math]::Max(48, [Math]::Min(120, $width)) - } - - function Get-AnimationDelay([string]$EnvironmentName, [int]$DefaultMilliseconds) { - $raw = [Environment]::GetEnvironmentVariable($EnvironmentName, 'Process') - if (-not $raw) { return $DefaultMilliseconds } - - try { - $milliseconds = [int]([double]$raw * 1000) - return [Math]::Max(0, [Math]::Min(2000, $milliseconds)) - } catch { - return $DefaultMilliseconds - } - } - - function Write-Logo { - $frameDelay = Get-AnimationDelay 'PYTHINKER_LOGO_FRAME_DELAY' 45 - $taglineDelay = Get-AnimationDelay 'PYTHINKER_LOGO_STAGGER_DELAY' 14 - - $logo = @( - " ${TIP}●${RESET}", - " ${NAVY}│${RESET}", - " ${NAVY}▛${RESET}${FACE}▀▀▀▀▀▀▀${RESET}${NAVY}▜${RESET}", - " ${TIP}◖${RESET}${NAVY}█${RESET} ${EYE}◉${RESET} ${EYE}◉${RESET} ${NAVY}█${RESET}${TIP}◗${RESET}", - " ${NAVY}▙▄▄▄${RESET}${FACE}≡${RESET}${NAVY}▄▄▄▟${RESET}" - ) - - Write-Host "" - foreach ($line in $logo) { - Write-Host $line - if ($useAnimation -and $frameDelay -gt 0) { - Start-Sleep -Milliseconds $frameDelay - } - } - - $tagline = "Pythinker Code Think first. Then code." - Write-Host "" - Write-Host -NoNewline " " - if ($useAnimation) { - foreach ($character in $tagline.ToCharArray()) { - Write-Host -NoNewline $character - if ($taglineDelay -gt 0) { Start-Sleep -Milliseconds $taglineDelay } - } - Write-Host "" - } else { - Write-Host $tagline - } - Write-Host "" - } - - function Write-MetadataRow([string]$Label, [string]$Value) { - Write-Host (" {0}{1,-10}{2} {3}" -f $DIM, $Label, $RESET, $Value) - } - - function Write-PhaseOk([string]$Label, [string]$Detail) { - $suffix = if ($Detail) { " ${DIM}$Detail${RESET}" } else { "" } - Write-Host (" ${ACCENT}✓${RESET} {0,-10}{1}" -f $Label, $suffix) - } - - function Write-PhaseInfo([string]$Label, [string]$Detail) { - Write-Host (" ${SOFT}•${RESET} {0,-10} ${DIM}{1}${RESET}" -f $Label, $Detail) - } - - function Write-RetryLine([string]$Label, [int]$Attempt, [int]$DelaySeconds, [string]$Reason) { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}") - } - Write-Host (" ${TIP}↻${RESET} {0,-10} retry {1}/3 in {2}s ${DIM}{3}${RESET}" -f $Label, $Attempt, $DelaySeconds, $Reason) - } - - function Format-ByteSize([long]$Bytes) { - if ($Bytes -lt 1024) { return "$Bytes B" } - if ($Bytes -lt 1MB) { return ("{0:N1} KB" -f ($Bytes / 1KB)) } - if ($Bytes -lt 1GB) { return ("{0:N1} MB" -f ($Bytes / 1MB)) } - return ("{0:N2} GB" -f ($Bytes / 1GB)) - } - - function Write-DownloadStarted([string]$Label) { - Write-Host (" ${SOFT}↓${RESET} {0,-10} ${DIM}starting…${RESET}" -f $Label) - } - - function Write-DownloadProgress( - [long]$ReceivedBytes, - $TotalBytes, - [double]$ElapsedSeconds, - [int]$FrameIndex - ) { - if (-not $useAnimation) { return } - - $spinnerFrames = @('●', '◐', '◓', '◑', '◒') - $spinner = $spinnerFrames[$FrameIndex % $spinnerFrames.Length] - $terminalWidth = Get-TerminalWidth - $barWidth = [Math]::Max(12, [Math]::Min(40, $terminalWidth - 44)) - $rate = if ($ElapsedSeconds -gt 0.05) { [long]($ReceivedBytes / $ElapsedSeconds) } else { 0 } - $rateText = if ($rate -gt 0) { "$(Format-ByteSize $rate)/s" } else { "—/s" } - - if ($null -ne $TotalBytes -and [long]$TotalBytes -gt 0) { - $total = [long]$TotalBytes - $percent = [Math]::Min(100, [Math]::Floor(($ReceivedBytes * 100.0) / $total)) - $filled = [int][Math]::Floor(($percent * $barWidth) / 100) - $empty = $barWidth - $filled - $barText = ("━" * $filled) + ("─" * $empty) - $metrics = "{0,3}% {1}/{2} {3}" -f $percent, (Format-ByteSize $ReceivedBytes), (Format-ByteSize $total), $rateText - $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $metrics" - } else { - $position = $FrameIndex % $barWidth - $left = "─" * $position - $rightCount = [Math]::Max(0, $barWidth - $position - 1) - $right = "─" * $rightCount - $barText = "${left}${SHINE}◆${RESET}${BAR}${right}" - $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $(Format-ByteSize $ReceivedBytes) $rateText" - } - - Write-Host -NoNewline ("`r${CLEAR_LINE}${line}") - } - - function Write-DownloadComplete([long]$Bytes, [double]$ElapsedSeconds) { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}") - } - - $duration = [Math]::Max(0.01, $ElapsedSeconds) - $averageRate = [long]($Bytes / $duration) - Write-Host (" ${ACCENT}✓${RESET} {0,-10} {1} ${DIM}in {2:N1}s · {3}/s${RESET}" -f 'Download', (Format-ByteSize $Bytes), $duration, (Format-ByteSize $averageRate)) - } - - function New-InstallerHttpClient { - try { - Add-Type -AssemblyName System.Net.Http -ErrorAction Stop - } catch { - Stop-Installer "System.Net.Http is unavailable: $($_.Exception.Message)" - } - - $handler = New-Object System.Net.Http.HttpClientHandler - $handler.AllowAutoRedirect = $true - # Connect cap: 10s. HttpClientHandler.ConnectTimeout is available on - # PowerShell 7 (System.Net.Http on .NET Core) and on Windows PowerShell 5.1 - # hosts with .NET Framework 4.7.2+; the guard below skips it on older .NET - # Framework hosts, where the operation timeout still bounds the whole call. - try { - $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) - } catch { - # Older .NET Framework without ConnectTimeout: nothing to set; the - # operation timeout below still bounds the call. - } - $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler - # Operation timeout: 30s, and never reassigned — the setter throws once the - # client has sent its first request. For the metadata calls, which read with - # ResponseContentRead, this covers the whole operation (request, headers and - # body) on both PowerShell 7 and Windows PowerShell 5.1. The archive - # download bounds itself with a cancellation token; see Download-File. - $client.Timeout = [TimeSpan]::FromSeconds($MetadataTimeoutSeconds) - [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') - [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') - return $client - } - - function Get-HttpTextOnce($Client, [string]$Uri, [string]$Description) { - $response = $null - try { - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() - if (-not $response.IsSuccessStatusCode) { - $status = [int]$response.StatusCode - throw "$Description failed with HTTP $status $($response.ReasonPhrase)" - } - return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() - } finally { - if ($null -ne $response) { $response.Dispose() } - } - } - - function Get-HttpText($Client, [string]$Uri, [string]$Description) { - $lastError = $null - for ($attempt = 1; $attempt -le 3; $attempt++) { - try { - return Get-HttpTextOnce $Client $Uri $Description - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt 3) { - $delay = [Math]::Pow(2, $attempt - 1) - Write-RetryLine $Description ($attempt + 1) ([int]$delay) $lastError - Start-Sleep -Seconds $delay - } - } - } - throw "$Description failed after 3 attempts: $lastError" - } - - function Get-HttpJson($Client, [string]$Uri, [switch]$AllowNotFound) { - $response = $null - try { - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() - $status = [int]$response.StatusCode - if ($AllowNotFound -and $status -eq 404) { return $null } - if (-not $response.IsSuccessStatusCode) { - throw "GitHub API failed with HTTP $status $($response.ReasonPhrase)" - } - $json = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() - return $json | ConvertFrom-Json - } finally { - if ($null -ne $response) { $response.Dispose() } - } - } - - function Download-File($Client, [string]$Uri, [string]$Destination, [string]$Label) { - $lastError = $null - - for ($attempt = 1; $attempt -le 3; $attempt++) { - $partialPath = "$Destination.part" - $response = $null - $inputStream = $null - $outputStream = $null - $stopwatch = $null - $attemptCts = $null - $received = [long]0 - $frameIndex = 0 - - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue - - if (-not $useAnimation) { - Write-DownloadStarted $Label - } - - try { - if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } - - # Archive download: bounded by a cancellation token, not by - # HttpClient.Timeout. Two reasons the property cannot do this job. - # First, its setter throws InvalidOperationException once the client has - # sent a request, and metadata calls have already run by the time we get - # here. Second, with ResponseHeadersRead it only bounds the wait for the - # headers, never the streaming body — so a connection that accepts and - # then stops would hang the installer forever, with its pid still - # recorded as the active update. - # One token covers the header wait and every read below. - # Total ceiling only, no per-read stall guard: curl's --speed-time - # equivalent needs a token per read. Add it if a 600s trickle ever - # shows up in the wild. - $attemptCts = New-Object System.Threading.CancellationTokenSource - $attemptCts.CancelAfter([TimeSpan]::FromSeconds($ArchiveTimeoutSeconds)) - - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $attemptCts.Token).GetAwaiter().GetResult() - if (-not $response.IsSuccessStatusCode) { - $status = [int]$response.StatusCode - throw "$Label failed with HTTP $status $($response.ReasonPhrase)" - } - - $totalBytes = $response.Content.Headers.ContentLength - $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $outputStream = [System.IO.File]::Open( - $partialPath, - [System.IO.FileMode]::Create, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None - ) - - $buffer = New-Object byte[] 131072 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - $lastRenderMilliseconds = [long]-1000 - - while ($true) { - $read = $inputStream.ReadAsync($buffer, 0, $buffer.Length, $attemptCts.Token).GetAwaiter().GetResult() - if ($read -le 0) { break } - - $outputStream.Write($buffer, 0, $read) - $received += $read - - if ($useAnimation -and ($stopwatch.ElapsedMilliseconds - $lastRenderMilliseconds) -ge 80) { - Write-DownloadProgress $received $totalBytes $stopwatch.Elapsed.TotalSeconds $frameIndex - $lastRenderMilliseconds = $stopwatch.ElapsedMilliseconds - $frameIndex++ - } - } - - $outputStream.Flush($true) - $outputStream.Dispose() - $outputStream = $null - $inputStream.Dispose() - $inputStream = $null - $response.Dispose() - $response = $null - $stopwatch.Stop() - - if ($null -ne $totalBytes -and [long]$totalBytes -gt 0 -and $received -ne [long]$totalBytes) { - throw "$Label was truncated: expected $totalBytes bytes, received $received" - } - if ($received -le 0) { throw "$Label returned an empty file" } - - [System.IO.File]::Move($partialPath, $Destination) - Write-DownloadComplete $received $stopwatch.Elapsed.TotalSeconds - return - } catch { - $lastError = $_.Exception.Message - } finally { - if ($null -ne $outputStream) { $outputStream.Dispose() } - if ($null -ne $inputStream) { $inputStream.Dispose() } - if ($null -ne $response) { $response.Dispose() } - if ($null -ne $attemptCts) { $attemptCts.Dispose() } - if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") - } - } - - if ($attempt -lt 3) { - $delay = [Math]::Pow(2, $attempt - 1) - Write-RetryLine $Label ($attempt + 1) ([int]$delay) $lastError - Start-Sleep -Seconds $delay - } - } - - Stop-Installer "$Label failed after 3 attempts: $lastError" - } - - function Test-Version([string]$Candidate) { - return $Candidate -match '^\d+\.\d+\.\d+$' - } - - function Get-ReleaseTag([string]$ResolvedVersion) { - return "@pythoughts/pythinker-code@$ResolvedVersion" - } - - function Get-EncodedReleaseTag([string]$ResolvedVersion) { - return [uri]::EscapeDataString((Get-ReleaseTag $ResolvedVersion)) - } - - function Test-ReleaseHasAsset($Release, [string]$AssetName) { - if ($null -eq $Release) { return $false } - if ($Release.draft -or $Release.prerelease) { return $false } - $names = @($Release.assets | ForEach-Object { [string]$_.name }) - return (($names -contains $AssetName) -and ($names -contains "$AssetName.sha256")) - } - - function Get-LatestVersion($Client) { - try { - $raw = Get-HttpText $Client $CdnLatestUrl 'CDN latest version' - $candidate = ([string]$raw).Trim().Trim('"') - if (Test-Version $candidate) { return $candidate } - } catch { - Write-PhaseInfo 'Version' 'CDN unavailable; using GitHub release metadata' - } - - $latestApi = "https://api.github.com/repos/$Repo/releases/latest" - try { - $latest = Get-HttpJson $Client $latestApi - $tag = [string]$latest.tag_name - if ($tag -match '^@pythoughts/pythinker-code@(\d+\.\d+\.\d+)$') { - return $Matches[1] - } - Stop-Installer "could not parse latest release tag '$tag' from GitHub" - } catch { - Stop-Installer "could not resolve the latest version: $($_.Exception.Message)" - } - } - - function Wait-ReleaseAssets($Client, [string]$ResolvedVersion, [string]$AssetName) { - $api = "https://api.github.com/repos/$Repo/releases/tags/$(Get-EncodedReleaseTag $ResolvedVersion)" - $delay = 4 - $elapsed = 0 - $maxElapsed = 360 - $frame = 0 - $lastError = $null - - while ($true) { - try { - $release = Get-HttpJson $Client $api -AllowNotFound - if (Test-ReleaseHasAsset $release $AssetName) { - if ($useAnimation) { Write-Host -NoNewline ("`r${CLEAR_LINE}") } - Write-PhaseOk 'Release' 'assets ready' - return - } - } catch { - $lastError = $_.Exception.Message - if ($lastError -match 'HTTP (401|403)') { - Stop-Installer $lastError - } - } - - if ($elapsed -ge $maxElapsed) { - $detail = if ($lastError) { " Last error: $lastError" } else { "" } - Stop-Installer "release assets for $ResolvedVersion were not available after ${maxElapsed}s.$detail" - } - - if ($useAnimation) { - $waitFrames = @('◐', '◓', '◑', '◒') - for ($remaining = $delay; $remaining -gt 0; $remaining--) { - $glyph = $waitFrames[$frame % $waitFrames.Length] - Write-Host -NoNewline ("`r${CLEAR_LINE} ${ACCENT}${glyph}${RESET} Release ${DIM}waiting for assets · retry in ${remaining}s${RESET}") - Start-Sleep -Seconds 1 - $elapsed++ - $frame++ - if ($elapsed -ge $maxElapsed) { break } - } - } else { - Write-Host (" ${SOFT}•${RESET} Release waiting for assets; retrying in ${delay}s") - Start-Sleep -Seconds $delay - $elapsed += $delay - } - - $delay = [Math]::Min($delay * 2, 60) - } - } - - function Read-ExpectedHash([string]$Path, [string]$ExpectedFileName) { - $candidates = @() - - foreach ($line in Get-Content -LiteralPath $Path) { - $trimmed = ([string]$line).Trim() - if (-not $trimmed) { continue } - - if ($trimmed -match '^(?[A-Fa-f0-9]{64})\s+\*?(?.+?)\s*$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $Matches.name.Trim() - } - continue - } - - if ($trimmed -match '^SHA256\s*\((?.+?)\)\s*=\s*(?[A-Fa-f0-9]{64})$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $Matches.name.Trim() - } - continue - } - - if ($trimmed -match '^(?[A-Fa-f0-9]{64})$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $null - } - } - } - - $namedMatches = @($candidates | Where-Object { - $_.Name -and ([System.IO.Path]::GetFileName([string]$_.Name) -ieq $ExpectedFileName) - }) - - if ($namedMatches.Count -eq 1) { return [string]$namedMatches[0].Hash } - - $unnamedMatches = @($candidates | Where-Object { -not $_.Name }) - if ($candidates.Count -eq 1 -and $unnamedMatches.Count -eq 1) { - return [string]$unnamedMatches[0].Hash - } - - Stop-Installer "checksum file did not contain a SHA-256 entry for '$ExpectedFileName'" - } - - function Expand-VerifiedBinary([string]$ArchivePath, [string]$DestinationPath) { - try { - Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop - Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop - } catch { - Stop-Installer "ZIP support is unavailable: $($_.Exception.Message)" - } - - $archive = $null - $entryStream = $null - $destinationStream = $null - - try { - $archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - $files = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) }) - - if ($files.Count -ne 1) { - Stop-Installer "archive must contain exactly one root file named pythinker.exe; found $($files.Count) files" - } - - $entry = $files[0] - $entryPath = ([string]$entry.FullName).Replace('\', '/') - if ($entryPath -cne 'pythinker.exe') { - Stop-Installer "archive must contain exactly one root file named pythinker.exe; found '$entryPath'" - } - if ([long]$entry.Length -le 0) { - Stop-Installer "archive contained an empty pythinker.exe" - } - - $entryStream = $entry.Open() - $destinationStream = [System.IO.File]::Open( - $DestinationPath, - [System.IO.FileMode]::CreateNew, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None - ) - $entryStream.CopyTo($destinationStream) - $destinationStream.Flush($true) - } finally { - if ($null -ne $destinationStream) { $destinationStream.Dispose() } - if ($null -ne $entryStream) { $entryStream.Dispose() } - if ($null -ne $archive) { $archive.Dispose() } - } - } - - function Move-FileWithRetry( - [string]$Source, - [string]$Destination, - [string]$Description, - [int]$Attempts = 6 - ) { - $lastError = $null - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - try { - [System.IO.File]::Move($Source, $Destination) - return - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt $Attempts) { - Start-Sleep -Milliseconds ([Math]::Min(1500, 200 * $attempt)) - } - } - } - throw "$Description failed after $Attempts attempts: $lastError" - } - - function Remove-FileWithRetry([string]$Path, [int]$Attempts = 5) { - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - if (-not (Test-Path -LiteralPath $Path)) { return $true } - try { - Remove-Item -LiteralPath $Path -Force -ErrorAction Stop - return $true - } catch { - if ($attempt -lt $Attempts) { Start-Sleep -Milliseconds (250 * $attempt) } - } - } - return -not (Test-Path -LiteralPath $Path) - } - - function Repair-InterruptedInstall([string]$BinaryPath) { - $directory = [System.IO.Path]::GetDirectoryName($BinaryPath) - $leaf = [System.IO.Path]::GetFileName($BinaryPath) - $backups = @(Get-ChildItem -LiteralPath $directory -Filter "$leaf.old-*" -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTimeUtc -Descending) - - if (-not (Test-Path -LiteralPath $BinaryPath) -and $backups.Count -gt 0) { - Move-FileWithRetry $backups[0].FullName $BinaryPath 'recovery of the previous executable' - Write-PhaseOk 'Recovery' 'restored an interrupted prior update' - $backups = @($backups | Select-Object -Skip 1) - } - - if (Test-Path -LiteralPath $BinaryPath) { - foreach ($backup in $backups) { - [void](Remove-FileWithRetry $backup.FullName 2) - } - } - - foreach ($stale in Get-ChildItem -LiteralPath $directory -Filter "$leaf.new-*" -File -ErrorAction SilentlyContinue) { - [void](Remove-FileWithRetry $stale.FullName 2) - } - } - - function Normalize-PathEntry([string]$PathEntry) { - if ([string]::IsNullOrWhiteSpace($PathEntry)) { return "" } - - $clean = $PathEntry.Trim().Trim('"') - $expanded = [Environment]::ExpandEnvironmentVariables($clean) - try { $expanded = [System.IO.Path]::GetFullPath($expanded) } catch {} - return $expanded.TrimEnd([char[]]@('\', '/')) - } - - function Test-PathContains([string]$PathValue, [string]$Entry) { - $normalizedEntry = Normalize-PathEntry $Entry - foreach ($candidate in ($PathValue -split ';')) { - if ((Normalize-PathEntry $candidate) -ieq $normalizedEntry) { return $true } - } - return $false - } - - function Add-InstallDirectoryToPath([string]$InstallDirectory) { - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $added = $false - - if (-not (Test-PathContains $userPath $InstallDirectory)) { - $newPath = if ($userPath) { "$InstallDirectory;$userPath" } else { $InstallDirectory } - [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') - $added = $true - } - - if (-not (Test-PathContains $env:PATH $InstallDirectory)) { - $env:PATH = "$InstallDirectory;$env:PATH" - } - - return $added - } - - function Get-NativeArchitecture { - try { - $registry = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction Stop - if ($registry.PROCESSOR_ARCHITECTURE) { return [string]$registry.PROCESSOR_ARCHITECTURE } - } catch {} - - if ($env:PROCESSOR_ARCHITEW6432) { return [string]$env:PROCESSOR_ARCHITEW6432 } - return [string]$env:PROCESSOR_ARCHITECTURE - } - - function Print-Intro([string]$ResolvedVersion, [string]$PlatformDisplay, [string]$AssetName, [string]$Action) { - Write-Logo - Write-MetadataRow 'Version' $ResolvedVersion - Write-MetadataRow 'Platform' $PlatformDisplay - Write-MetadataRow 'Package' $AssetName - Write-MetadataRow 'Action' $Action - Write-Host "" - } - - function Print-Done([string]$ResolvedVersion, [string]$BinaryPath, [bool]$PathWasAdded) { - $separatorWidth = [Math]::Max(36, [Math]::Min(58, (Get-TerminalWidth) - 4)) - $separator = "─" * $separatorWidth - - Write-Host "" - Write-Host " ${BAR}${separator}${RESET}" - Write-Host " ${ACCENT}${BOLD}✓ Pythinker Code $ResolvedVersion is ready${RESET}" - Write-Host "" - Write-Host " ${DIM}Run${RESET} ${BOLD}pythinker${RESET}" - Write-Host " ${DIM}Installed${RESET} $BinaryPath" - if ($PathWasAdded) { - Write-Host " ${DIM}PATH${RESET} Added for this user and this session" - } else { - Write-Host " ${DIM}PATH${RESET} Already configured" - } - Write-Host " ${BAR}${separator}${RESET}" - Write-Host "" - } - - try { - if ($ShowHelp) { - Show-Usage - return - } - - if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - Stop-Installer "this installer is for Windows. Use: curl -fsSL $InstallShUrl | bash" - } - - $httpClient = New-InstallerHttpClient - - $resolvedVersion = ([string]$RequestedVersion).Trim() - if ($resolvedVersion.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { - $resolvedVersion = $resolvedVersion.Substring(1) - } - if (-not $resolvedVersion) { - $resolvedVersion = Get-LatestVersion $httpClient - } - if (-not (Test-Version $resolvedVersion)) { - Stop-Installer "invalid version '$resolvedVersion'; expected X.Y.Z" - } - - $nativeArchitecture = (Get-NativeArchitecture).ToUpperInvariant() - switch ($nativeArchitecture) { - 'ARM64' { $archLabel = 'arm64' } - 'AMD64' { $archLabel = 'x64' } - default { Stop-Installer "unsupported Windows architecture '$nativeArchitecture' (need x64 or arm64)" } - } - - $localAppData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) - if (-not $localAppData) { $localAppData = $env:LOCALAPPDATA } - if (-not $localAppData) { Stop-Installer 'could not resolve LOCALAPPDATA' } - - $installDir = Join-Path $localAppData 'Programs\Pythinker' - New-Item -ItemType Directory -Path $installDir -Force | Out-Null - $targetPath = Join-Path $installDir 'pythinker.exe' - - $mutexUser = ([Environment]::UserName -replace '[^A-Za-z0-9_.-]', '_') - $mutexName = "Local\PythinkerCodeInstaller-$mutexUser" - $installMutex = New-Object System.Threading.Mutex($false, $mutexName) - try { - $mutexHeld = $installMutex.WaitOne(0) - } catch [System.Threading.AbandonedMutexException] { - $mutexHeld = $true - } - if (-not $mutexHeld) { - Stop-Installer 'another Pythinker installer or update is already running' - } - - Repair-InterruptedInstall $targetPath - $action = if (Test-Path -LiteralPath $targetPath) { 'Upgrade' } else { 'Install' } - - $asset = "pythinker-code-win32-$archLabel.zip" - $baseUrl = "https://github.com/$Repo/releases/download/$(Get-EncodedReleaseTag $resolvedVersion)" - $installerUrl = "$baseUrl/$asset" - $shaUrl = "$installerUrl.sha256" - - Print-Intro $resolvedVersion "Windows $archLabel" $asset $action - Wait-ReleaseAssets $httpClient $resolvedVersion $asset - - $tempRoot = [System.IO.Path]::GetTempPath() - $tempDir = Join-Path $tempRoot ("pythinker-install-" + [System.Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $tempDir | Out-Null - $installerPath = Join-Path $tempDir $asset - $shaPath = "$installerPath.sha256" - - Download-File $httpClient $installerUrl $installerPath 'Download' - $checksumText = Get-HttpText $httpClient $shaUrl 'Checksum' - [System.IO.File]::WriteAllText($shaPath, $checksumText, [System.Text.Encoding]::ASCII) - - $expectedHash = Read-ExpectedHash $shaPath $asset - $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installerPath).Hash.ToLowerInvariant() - if ($expectedHash -ne $actualHash) { - Stop-Installer "SHA-256 mismatch: expected $expectedHash, got $actualHash" - } - Write-PhaseOk 'Verify' ("SHA-256 {0}…" -f $actualHash.Substring(0, 12)) - - $transactionId = [System.Guid]::NewGuid().ToString('N') - $stagingBinary = Join-Path $installDir "pythinker.exe.new-$transactionId" - Expand-VerifiedBinary $installerPath $stagingBinary - - if (Test-Path -LiteralPath $targetPath) { - $backupBinary = Join-Path $installDir "pythinker.exe.old-$transactionId" - try { - Move-FileWithRetry $targetPath $backupBinary 'moving the existing executable aside' - } catch { - Stop-Installer "could not prepare the current installation for update: $($_.Exception.Message)" - } - } - - try { - Move-FileWithRetry $stagingBinary $targetPath 'installing the new executable' - $stagingBinary = $null - } catch { - $installError = $_.Exception.Message - $rollbackError = $null - - # Roll back the previous executable whenever the new same-volume rename - # cannot complete. The user is never intentionally left without a binary. - if ($backupBinary -and (Test-Path -LiteralPath $backupBinary) -and -not (Test-Path -LiteralPath $targetPath)) { - try { - Move-FileWithRetry $backupBinary $targetPath 'rollback of the previous executable' - $backupBinary = $null - } catch { - $rollbackError = $_.Exception.Message - } - } - - if ($rollbackError) { - Stop-Installer "could not install the new executable ($installError); rollback also failed ($rollbackError)" - } - Stop-Installer "could not install the new executable: $installError" - } - - if ($backupBinary -and (Test-Path -LiteralPath $backupBinary)) { - [void](Remove-FileWithRetry $backupBinary 5) - if (-not (Test-Path -LiteralPath $backupBinary)) { $backupBinary = $null } - } - Write-PhaseOk 'Install' $targetPath - - $pathWasAdded = Add-InstallDirectoryToPath $installDir - if ($pathWasAdded) { - Write-PhaseOk 'PATH' 'added for this user' - } else { - Write-PhaseOk 'PATH' 'already configured' - } - - Print-Done $resolvedVersion $targetPath $pathWasAdded - } finally { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") - } - - if ($null -ne $httpClient) { $httpClient.Dispose() } - - if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { - Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - - if ($stagingBinary -and (Test-Path -LiteralPath $stagingBinary)) { - [void](Remove-FileWithRetry $stagingBinary 2) - } - - # A backup is safe to remove only after the target exists. If rollback did - # not complete, preserve the backup for manual recovery instead of deleting it. - if ($backupBinary -and $targetPath -and (Test-Path -LiteralPath $targetPath) -and (Test-Path -LiteralPath $backupBinary)) { - [void](Remove-FileWithRetry $backupBinary 2) - } - - if ($mutexHeld -and $null -ne $installMutex) { - try { $installMutex.ReleaseMutex() } catch {} - } - if ($null -ne $installMutex) { $installMutex.Dispose() } - - if ($null -ne $previousOutputEncoding) { - try { [Console]::OutputEncoding = $previousOutputEncoding } catch {} - } - if ($null -ne $previousSecurityProtocol) { - try { [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol } catch {} - } - } -} $Version ([bool]$Help) diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh deleted file mode 100755 index b46ee1b1..00000000 --- a/apps/site/public/install.sh +++ /dev/null @@ -1,1000 +0,0 @@ -#!/usr/bin/env bash -# Pythinker Code — polished native curl-bash installer. -# -# Downloads the native single-file binary (Node SEA) for the current OS and -# architecture, verifies its SHA-256 checksum, and installs it at: -# ~/.local/bin/pythinker -# -# Usage: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -# -# Pin a version: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 -# -# Choose an install prefix: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker -# -# Supported release targets: -# linux-x64, linux-arm64, darwin-arm64, darwin-x64 -# -# Windows: -# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -set -euo pipefail - -VERSION="" -INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" -NO_COLOR="${NO_COLOR:-}" - -REPO="Pythoughts-labs/pythinker-code" -CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" - -# Network timeout policy. The script owns retry — _download_with_progress and -# _download_quiet_with_retry re-invoke the helpers up to 3 times with backoff — -# so curl and wget must not add their own retry: curl's --max-time counter -# resets on every --retry attempt, which would let one logical attempt run far -# beyond its budget. With retry left to the script, --max-time bounds exactly -# one attempt, which is what the retry loops expect. -# -# Metadata requests (_content_length, _fetch): 10s connect, 30s total. -# Archive downloads (_download_quiet, _start_download): 10s connect, 600s total, -# plus a stall guard that aborts when throughput stays under 1024 bytes/s for -# 30s — a connection that is alive but crawling would otherwise burn the whole -# 600s budget. -# wget gets `-T` and nothing else on purpose. GNU's --connect-timeout / -# --read-timeout / --tries do not exist in BusyBox wget, which is the only wget -# on Alpine-class systems, and an unrecognized option there aborts the install -# outright — turning a working fallback into a hard failure. `-T` is understood -# by both: GNU treats it as dns+connect+read at once, BusyBox as the network -# read timeout. It is an inactivity bound, not a total one, so it is sized for -# "this connection is dead", not for the whole transfer. -CURL_META_OPTS=(--connect-timeout 10 --max-time 30) -WGET_META_OPTS=(-T 30) -CURL_ARCHIVE_OPTS=(--connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30) -WGET_ARCHIVE_OPTS=(-T 60) - -# Operational globals are populated by main(). Keeping rendering helpers at -# file scope makes the installer sourceable for regression tests and tooling. -target="" -platform_display="" -tag_encoded="" -archive="" -archive_url="" -sha_url="" -bin_dir="" -install_path="" -TMP_DIR="" -DOWNLOAD_PID="" - -# UI globals are initialized to empty so helper functions are safe before -# _init_ui is called (for example, when the file is sourced by a test). -_anim="" -_cursor_hidden="" -ROBOT="" -FACE="" -ACCENT="" -TIP="" -EYE="" -SUCCESS="" -WARNING="" -ERROR_COLOR="" -MUTED="" -BORDER="" -BOLD="" -DIM="" -RESET="" - -usage() { - cat <<'EOF_USAGE' -Pythinker Code — native curl-bash installer. - -Downloads the native single-file binary for your OS and architecture, -verifies its SHA-256 checksum, and installs it at: - ~/.local/bin/pythinker - -Usage: - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash - -Pin a specific version: - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 - -Use a custom install prefix (default: $HOME/.local): - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker - -Supported targets: - linux-x64 Linux x86_64 - linux-arm64 Linux ARM64 - darwin-arm64 macOS Apple Silicon - darwin-x64 macOS Intel - -Environment: - PYTHINKER_INSTALL_PREFIX Default install prefix - PYTHINKER_NO_ANIMATION Disable terminal animation when non-empty - PYTHINKER_TERM_WIDTH Override detected width - NO_COLOR Disable ANSI colors and animation - -Windows: - irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -EOF_USAGE -} - -_parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --version) - [[ -n "${2:-}" ]] || { - printf '%s\n' '--version requires a value' >&2 - return 2 - } - VERSION="$2" - shift 2 - ;; - --prefix) - [[ -n "${2:-}" ]] || { - printf '%s\n' '--prefix requires a value' >&2 - return 2 - } - INSTALL_PREFIX="$2" - shift 2 - ;; - -h|--help) - usage - return 10 - ;; - *) - printf 'unknown argument: %s\n' "$1" >&2 - return 2 - ;; - esac - done -} - -_init_ui() { - # Reset first so repeated calls while sourced are deterministic. - _anim="" - ROBOT=""; FACE=""; ACCENT=""; TIP=""; EYE=""; SUCCESS="" - WARNING=""; ERROR_COLOR=""; MUTED=""; BORDER="" - BOLD=""; DIM=""; RESET="" - - if [[ -t 1 && -z "$NO_COLOR" && "${TERM:-}" != "dumb" ]]; then - # Terminal-default foreground plus restrained neutral/pastel accents. - ROBOT=$'\033[38;5;248m' - FACE=$'\033[39m' - ACCENT=$'\033[38;5;141m' - TIP=$'\033[38;5;173m' - EYE=$'\033[38;5;147m' - SUCCESS=$'\033[38;5;114m' - WARNING=$'\033[38;5;179m' - ERROR_COLOR=$'\033[38;5;203m' - MUTED=$'\033[38;5;245m' - BORDER=$'\033[38;5;245m' - BOLD=$'\033[1m' - DIM=$'\033[2m' - RESET=$'\033[0m' - fi - - if [[ -t 1 \ - && -z "$NO_COLOR" \ - && "${TERM:-}" != "dumb" \ - && -z "${PYTHINKER_NO_ANIMATION:-}" \ - && -z "${CI:-}" ]]; then - _anim=1 - fi -} - -_hide_cursor() { - [[ -n "$_anim" ]] || return 0 - [[ -z "$_cursor_hidden" ]] || return 0 - printf '\033[?25l' - _cursor_hidden=1 -} - -_show_cursor() { - [[ -n "$_cursor_hidden" ]] || return 0 - printf '\033[?25h' - _cursor_hidden="" -} - -_cleanup() { - if [[ -n "$DOWNLOAD_PID" ]] && kill -0 "$DOWNLOAD_PID" 2>/dev/null; then - kill "$DOWNLOAD_PID" 2>/dev/null || true - wait "$DOWNLOAD_PID" 2>/dev/null || true - fi - DOWNLOAD_PID="" - - _show_cursor || true - - if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then - rm -rf "$TMP_DIR" - fi -} - -fail() { - _show_cursor || true - if [[ -n "$_anim" ]]; then - _clear_active_line - fi - printf ' %s✗%s %s\n' "$ERROR_COLOR" "$RESET" "$1" >&2 - exit 1 -} - -# The explicit width argument is optional; callers other than _wrap_text omit it -# and rely on detection. -# shellcheck disable=SC2120 -_terminal_columns() { - local explicit="${1:-}" - local detected="" - - if [[ "$explicit" =~ ^[0-9]+$ ]] && (( explicit > 0 )); then - printf '%s' "$explicit" - return 0 - fi - - if [[ "${PYTHINKER_TERM_WIDTH:-}" =~ ^[0-9]+$ ]] \ - && (( PYTHINKER_TERM_WIDTH > 0 )); then - printf '%s' "$PYTHINKER_TERM_WIDTH" - return 0 - fi - - if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] && (( COLUMNS > 0 )); then - printf '%s' "$COLUMNS" - return 0 - fi - - if [[ -t 1 && "${TERM:-}" != "dumb" ]] \ - && command -v tput >/dev/null 2>&1; then - detected="$(tput cols 2>/dev/null || true)" - if [[ "$detected" =~ ^[0-9]+$ ]] && (( detected > 0 )); then - printf '%s' "$detected" - return 0 - fi - fi - - printf '80' -} - -_progress_bar_width() { - local columns="${1:-$(_terminal_columns)}" - local width - - if (( columns >= 80 )); then - width=44 - elif (( columns >= 55 )); then - width=$((columns - 32)) - (( width > 44 )) && width=44 - else - width=0 - fi - - printf '%s' "$width" -} - -_repeat_char() { - local char="$1" count="$2" result="" i - for ((i=0; i 50 )) && width=50 - (( width < 1 )) && width=1 - _repeat_char '─' "$width" -} - -_format_bytes() { - local bytes="${1:-0}" - [[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0 - LC_ALL=C awk -v bytes="$bytes" 'BEGIN { - if (bytes < 1024) { - printf "%d B", bytes - } else if (bytes < 1048576) { - printf "%.1f KB", bytes / 1024 - } else if (bytes < 1073741824) { - printf "%.1f MB", bytes / 1048576 - } else { - printf "%.1f GB", bytes / 1073741824 - } - }' -} - -_format_byte_pair() { - local current="${1:-0}" total="${2:-0}" - [[ "$current" =~ ^[0-9]+$ ]] || current=0 - [[ "$total" =~ ^[0-9]+$ ]] || total=0 - LC_ALL=C awk -v current="$current" -v total="$total" 'BEGIN { - unit = "B"; divisor = 1 - if (total >= 1073741824) { - unit = "GB"; divisor = 1073741824 - } else if (total >= 1048576) { - unit = "MB"; divisor = 1048576 - } else if (total >= 1024) { - unit = "KB"; divisor = 1024 - } - - if (divisor == 1) { - printf "%d/%d %s", current, total, unit - } else { - printf "%.1f/%.1f %s", current / divisor, total / divisor, unit - } - }' -} - -_display_path() { - local path="$1" - if [[ -n "${HOME:-}" && "$path" == "$HOME" ]]; then - printf '~' - elif [[ -n "${HOME:-}" && "$path" == "$HOME/"* ]]; then - printf '~%s' "${path#"$HOME"}" - else - printf '%s' "$path" - fi -} - -_current_file_size() { - local file="$1" - if [[ -f "$file" ]]; then - wc -c < "$file" | tr -d '[:space:]' - else - printf '0' - fi -} - -_content_length() { - local url="$1" - command -v curl >/dev/null 2>&1 || return 1 - curl -fsIL "${CURL_META_OPTS[@]}" "$url" 2>/dev/null \ - | awk 'tolower($1) == "content-length:" { - gsub("\r", "", $2) - bytes = $2 - } - END { - if (bytes ~ /^[0-9]+$/) print bytes - }' -} - -_download_percent() { - local output="$1" total="$2" size percent - [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) || return 1 - size="$(_current_file_size "$output")" - [[ "$size" =~ ^[0-9]+$ ]] || size=0 - percent=$((size * 100 / total)) - (( percent > 99 )) && percent=99 - (( percent < 0 )) && percent=0 - printf '%s' "$percent" -} - -_clear_active_line() { - [[ -n "$_anim" ]] || return 0 - printf '\r\033[2K' -} - -_render_progress_determinate() { - local percent="$1" current="$2" total="$3" frame="$4" - local columns width filled empty filled_bar empty_bar pair - - [[ "$percent" =~ ^[0-9]+$ ]] || percent=0 - (( percent > 100 )) && percent=100 - (( percent < 0 )) && percent=0 - - columns="$(_terminal_columns)" - width="$(_progress_bar_width "$columns")" - pair="$(_format_byte_pair "$current" "$total")" - - _clear_active_line - - if (( width == 0 )); then - # Below 55 columns, keep the display percentage-only to avoid wrapping. - printf ' %s%s%s Downloading %3d%%' \ - "$ACCENT" "$frame" "$RESET" "$percent" - return 0 - fi - - filled=$((percent * width / 100)) - empty=$((width - filled)) - filled_bar="$(_repeat_char '█' "$filled")" - empty_bar="$(_repeat_char '░' "$empty")" - - printf ' %s%s%s Downloading %s%s%s%s%s%s %3d%%' \ - "$ACCENT" "$frame" "$RESET" \ - "$ACCENT" "$filled_bar" "$RESET" \ - "$BORDER" "$empty_bar" "$RESET" \ - "$percent" - - # At 80 columns the 44-cell bar fits, but byte details can wrap. Add them - # only when there is enough room for the largest common value pair. - if (( columns >= 88 )); then - printf ' %s' "$pair" - fi -} - -_render_progress_indeterminate() { - local frame="$1" current="$2" - local columns received - columns="$(_terminal_columns)" - received="$(_format_bytes "$current")" - _clear_active_line - - if (( columns < 45 )); then - printf ' %s%s%s Downloading %s' \ - "$ACCENT" "$frame" "$RESET" "$received" - else - printf ' %s%s%s Downloading %sReceiving package…%s %s' \ - "$ACCENT" "$frame" "$RESET" "$MUTED" "$RESET" "$received" - fi -} - -_render_waiting() { - local frame="$1" delay="$2" - local columns - columns="$(_terminal_columns)" - _clear_active_line - - if (( columns < 55 )); then - printf ' %s%s%s Waiting; retry in %ss' \ - "$ACCENT" "$frame" "$RESET" "$delay" - else - printf ' %s%s%s Waiting %sRelease assets are publishing; retry in %ss%s' \ - "$ACCENT" "$frame" "$RESET" "$MUTED" "$delay" "$RESET" - fi -} - -status_ok() { - local label="$1" detail="${2:-}" - printf ' %s✓%s %s' "$SUCCESS" "$RESET" "$label" - if [[ -n "$detail" ]]; then - printf ' %s%s%s' "$MUTED" "$detail" "$RESET" - fi - printf '\n' -} - -status_warn() { - local label="$1" detail="${2:-}" - printf ' %s!%s %s' "$WARNING" "$RESET" "$label" - if [[ -n "$detail" ]]; then - printf ' %s%s%s' "$MUTED" "$detail" "$RESET" - fi - printf '\n' -} - -print_logo_art() { - printf ' %s●%s\n' "$TIP" "$RESET" - printf ' %s│%s\n' "$ROBOT" "$RESET" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$EYE" "$RESET" "$EYE" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" -} - -_print_brand() { - printf '\n %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" - printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" -} - -print_logo_static() { - printf '\n' - print_logo_art - _print_brand -} - -print_logo_animated() { - local delay="${PYTHINKER_LOGO_FRAME_DELAY:-0.07}" - - printf '\n' - _hide_cursor - - # Each micro-animation rewrites only the line currently being composed. - printf ' %s·%s' "$MUTED" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s●%s\n' "$TIP" "$RESET" - - printf ' %s│%s\n' "$ROBOT" "$RESET" - sleep "$delay" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - sleep "$delay" - - printf ' %s◖%s%s█%s %s·%s %s·%s %s█%s%s◗%s' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$MUTED" "$RESET" "$MUTED" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$EYE" "$RESET" "$EYE" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - sleep "$delay" - - printf '\n %sPYTHINKER CODE%s' "$DIM" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" - printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" - - _show_cursor -} - -print_intro() { - local destination - destination="$(_display_path "$install_path")" - - if [[ -n "$_anim" ]]; then - print_logo_animated - else - print_logo_static - fi - - printf ' %s%-12s%s %s\n' "$MUTED" 'Version' "$RESET" "$VERSION" - printf ' %s%-12s%s %s\n' "$MUTED" 'Platform' "$RESET" "$platform_display" - printf ' %s%-12s%s %s\n' "$MUTED" 'Destination' "$RESET" "$destination" - printf '\n' -} - -print_done() { - local sep destination - sep="$(_separator)" - destination="$(_display_path "$install_path")" - - printf '\n %s%s%s\n\n' "$BORDER" "$sep" "$RESET" - printf ' %s%sReady to think, plan, and build.%s\n\n' \ - "$BOLD" "$FACE" "$RESET" - printf ' %sInstalled at%s %s\n' "$MUTED" "$RESET" "$destination" - printf ' %sStart with%s %s%s$ pythinker%s\n\n' \ - "$MUTED" "$RESET" "$BOLD" "$ACCENT" "$RESET" -} - -_fetch() { - local url="$1" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "${CURL_META_OPTS[@]}" "$url" - elif command -v wget >/dev/null 2>&1; then - wget -qO- "${WGET_META_OPTS[@]}" "$url" - else - return 127 - fi -} - -_download_quiet() { - local url="$1" output="$2" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" - elif command -v wget >/dev/null 2>&1; then - wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" - else - return 127 - fi -} - -_start_download() { - local url="$1" output="$2" - DOWNLOAD_PID="" - - if command -v curl >/dev/null 2>&1; then - curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" & - elif command -v wget >/dev/null 2>&1; then - wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" & - else - return 127 - fi - - DOWNLOAD_PID=$! -} - -# Machine-readable progress for the parent process. The background installer -# has no TTY, so stdout stays human-only (and is discarded by the spawn) and -# stderr carries the protocol: one newline-terminated line per update. -_emit_download_progress() { - local percent="${1:-}" current="$2" total="$3" - if [[ -n "$percent" ]]; then - printf 'progress: state=downloading percent=%s transferred=%s total=%s\n' \ - "$percent" "$current" "$total" >&2 - else - printf 'progress: state=downloading transferred=%s\n' "$current" >&2 - fi -} - -# One download attempt with a live progress display. Returns non-zero on -# transport failure, an empty file, or a size short of Content-Length. -_download_attempt_with_progress() { - local url="$1" output="$2" - local total="" pid="" current=0 percent="" last_percent="-1" i=0 last_emit_i=-100 frame_index=0 - local -a frames=('◐' '◓' '◑' '◒') - - rm -f "$output" - - if [[ -z "$_anim" ]]; then - # Background install: no TTY, so no ANSI. Poll the same way as the - # animated branch, but report machine-readable lines on stderr instead of - # rendering a bar. One line per second at most, and only when the integer - # percent moved; the parent records these at most every 2s, so the - # protocol stays far below the parent's throttle. - if command -v curl >/dev/null 2>&1; then - total="$(_content_length "$url" || true)" - fi - - # `|| {...}` and not `if ! …`: after `if ! cmd`, `$?` inside the branch is - # the negation's 0, so the real failure code would be reported as success. - _start_download "$url" "$output" || { - local start_rc=$? - printf 'progress: state=failed\n' >&2 - return "$start_rc" - } - pid="$DOWNLOAD_PID" - - while kill -0 "$pid" 2>/dev/null; do - current="$(_current_file_size "$output")" - - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then - percent="$(_download_percent "$output" "$total" || printf '0')" - else - percent="" - fi - - # An unknown size has no percent to change, so it emits on the interval - # alone — otherwise a wget-only host would show one line and then look - # frozen for the whole download. - if (( i - last_emit_i >= 9 )) && [[ -z "$percent" || "$percent" != "$last_percent" ]]; then - _emit_download_progress "$percent" "$current" "$total" - last_percent="$percent" - last_emit_i="$i" - fi - - sleep 0.12 - i=$((i + 1)) - done - - if ! wait "$pid"; then - DOWNLOAD_PID="" - printf 'progress: state=failed\n' >&2 - return 1 - fi - DOWNLOAD_PID="" - - if ! _validate_download "$output" "$total"; then - printf 'progress: state=failed\n' >&2 - return 1 - fi - - current="$(_current_file_size "$output")" - printf 'progress: state=done transferred=%s\n' "$current" >&2 - status_ok 'Download complete' "$(_format_bytes "$current")" - return 0 - fi - - if command -v curl >/dev/null 2>&1; then - total="$(_content_length "$url" || true)" - fi - - _start_download "$url" "$output" || return $? - pid="$DOWNLOAD_PID" - _hide_cursor - - while kill -0 "$pid" 2>/dev/null; do - frame_index=$((i % 4)) - current="$(_current_file_size "$output")" - - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then - percent="$(_download_percent "$output" "$total" || printf '0')" - _render_progress_determinate \ - "$percent" "$current" "$total" "${frames[$frame_index]}" - else - _render_progress_indeterminate "${frames[$frame_index]}" "$current" - fi - - sleep 0.12 - i=$((i + 1)) - done - - if ! wait "$pid"; then - DOWNLOAD_PID="" - _show_cursor - _clear_active_line - return 1 - fi - DOWNLOAD_PID="" - - if ! _validate_download "$output" "$total"; then - _show_cursor - _clear_active_line - return 1 - fi - - current="$(_current_file_size "$output")" - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then - _render_progress_determinate 100 "$current" "$total" '✓' - printf '\n' - else - _clear_active_line - fi - - _show_cursor - status_ok 'Download complete' "$(_format_bytes "$current")" -} - -# Reject empty downloads, and short downloads when Content-Length is known. -# A truncated archive would fail checksum verification anyway, but catching -# it here lets the retry loop recover instead of aborting the install. -_validate_download() { - local output="$1" total="${2:-}" - local size - size="$(_current_file_size "$output")" - [[ "$size" =~ ^[0-9]+$ ]] && (( size > 0 )) || return 1 - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) && (( size != total )); then - return 1 - fi - return 0 -} - -_download_with_progress() { - local url="$1" output="$2" - local attempt delay - - for attempt in 1 2 3; do - if _download_attempt_with_progress "$url" "$output"; then - return 0 - fi - rm -f "$output" - if (( attempt < 3 )); then - delay=$((2 ** (attempt - 1))) - status_warn 'Download failed' "retry $((attempt + 1))/3 in ${delay}s" - sleep "$delay" - fi - done - return 1 -} - -_download_quiet_with_retry() { - local label="$1" url="$2" output="$3" - local attempt delay - - for attempt in 1 2 3; do - if _download_quiet "$url" "$output" && _validate_download "$output" ""; then - return 0 - fi - rm -f "$output" - if (( attempt < 3 )); then - delay=$((2 ** (attempt - 1))) - status_warn "$label failed" "retry $((attempt + 1))/3 in ${delay}s" - sleep "$delay" - fi - done - return 1 -} - -_detect_target() { - local os arch - os="$(uname -s)" - arch="$(uname -m)" - - case "$os/$arch" in - Linux/x86_64|Linux/amd64) - target='linux-x64' - platform_display='Linux · x86_64' - ;; - Linux/aarch64|Linux/arm64) - target='linux-arm64' - platform_display='Linux · ARM64' - ;; - Darwin/arm64) - target='darwin-arm64' - platform_display='macOS · Apple Silicon' - ;; - Darwin/x86_64) - target='darwin-x64' - platform_display='macOS · Intel' - ;; - MINGW*/*|MSYS*/*|CYGWIN*/*) - fail $'On Windows, use the PowerShell installer:\n powershell -c "irm https://code.pythinker.com/pythinker-code/install.ps1 | iex"' - ;; - *) - fail "unsupported target: $os/$arch" - ;; - esac -} - -_resolve_version() { - local api payload - - command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ - || fail 'need curl or wget to fetch release metadata' - - if [[ -z "$VERSION" ]]; then - VERSION="$(_fetch "$CDN_LATEST_URL" 2>/dev/null \ - | tr -d '[:space:]' || true)" - - if ! printf '%s' "$VERSION" \ - | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - api="https://api.github.com/repos/${REPO}/releases/latest" - payload="$(_fetch "$api")" \ - || fail "could not reach $CDN_LATEST_URL or $api" - VERSION="$(printf '%s' "$payload" \ - | sed -nE 's/.*"tag_name": *"@pythoughts\/pythinker-code@([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' \ - | head -n 1)" - fi - fi - - printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ - || fail "invalid version '$VERSION'; expected X.Y.Z" -} - -release_has_assets() { - local api body - api="https://api.github.com/repos/${REPO}/releases/tags/${tag_encoded}" - body="$(_fetch "$api" 2>/dev/null)" || return 1 - printf '%s' "$body" | grep -Fq "\"${archive}\"" \ - && printf '%s' "$body" | grep -Fq "\"${archive}.sha256\"" -} - -_wait_for_release_assets() { - local attempt=0 delay=4 elapsed=0 max_elapsed=360 - local -a frames=('◐' '◓' '◑' '◒') - - until release_has_assets; do - if (( elapsed >= max_elapsed )); then - fail "release assets for ${VERSION} are unavailable after about ${max_elapsed}s: ${archive_url} -The release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" - fi - - if [[ -n "$_anim" ]]; then - _render_waiting "${frames[$((attempt % 4))]}" "$delay" - else - printf ' Waiting for release assets; retrying in %ss\n' "$delay" - printf 'progress: state=waiting retry_in=%s elapsed=%s\n' "$delay" "$elapsed" >&2 - fi - - sleep "$delay" - if [[ -n "$_anim" ]]; then - _clear_active_line - fi - - attempt=$((attempt + 1)) - elapsed=$((elapsed + delay)) - delay=$((delay * 2)) - (( delay > 120 )) && delay=120 - done - - if [[ -n "$_anim" ]] && (( attempt > 0 )); then - _clear_active_line - fi -} - -_verify_checksum() { - local checksum_file="$1" payload_file="$2" - local expected actual - - expected="$(awk 'NR == 1 {print $1}' "$checksum_file" \ - | tr '[:upper:]' '[:lower:]')" - printf '%s' "$expected" | grep -Eq '^[0-9a-f]{64}$' \ - || fail 'the release checksum file is malformed' - - if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$payload_file" | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - actual="$(shasum -a 256 "$payload_file" | awk '{print $1}')" - else - fail 'need sha256sum or shasum to verify the download' - fi - - actual="$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" - [[ "$expected" == "$actual" ]] \ - || fail "SHA-256 mismatch: expected $expected, got $actual" - - status_ok 'Checksum verified' -} - -_extract_and_install() { - local payload="$TMP_DIR/pythinker" - - mkdir -p "$bin_dir" - # Sweep staged leftovers from a previous interrupted run. - rm -f "$install_path".tmp.* 2>/dev/null || true - - if command -v unzip >/dev/null 2>&1; then - unzip -oq "$TMP_DIR/$archive" -d "$TMP_DIR" - elif command -v tar >/dev/null 2>&1 \ - && tar -tf "$TMP_DIR/$archive" >/dev/null 2>&1; then - tar -C "$TMP_DIR" -xf "$TMP_DIR/$archive" - else - fail "need unzip (or bsdtar) to extract $archive" - fi - - [[ -f "$payload" ]] \ - || fail "archive did not contain a regular file named 'pythinker'" - command -v install >/dev/null 2>&1 \ - || fail "need the 'install' command to place the executable" - - # Stage next to the target, then rename into place. `install` alone - # truncate-writes the destination: overwriting a currently running - # `pythinker` fails with ETXTBSY on Linux and can leave a half-written - # binary on any platform. rename() replaces the path atomically and is - # legal even while the old inode is still executing. - local staged="$install_path.tmp.$$" - if ! install -m 0755 "$payload" "$staged"; then - rm -f "$staged" - fail "could not stage the executable in $(_display_path "$bin_dir")" - fi - if ! mv -f "$staged" "$install_path"; then - rm -f "$staged" - fail "could not move the executable into place at $(_display_path "$install_path")" - fi - status_ok 'Installed successfully' "$(_display_path "$install_path")" -} - -_print_path_guidance() { - case ":$PATH:" in - *":$bin_dir:"*) - return 0 - ;; - esac - - printf '\n' - status_warn \ - 'PATH update required' \ - "$(_display_path "$bin_dir") is not currently on PATH" - printf ' %sBash or Zsh%s\n' "$MUTED" "$RESET" - # $PATH stays literal on purpose — this line is shell config for the user to copy. - # shellcheck disable=SC2016 - printf ' export PATH="%s:$PATH"\n' "$bin_dir" - printf ' %sFish%s\n' "$MUTED" "$RESET" - printf ' fish_add_path "%s"\n' "$bin_dir" -} - -main() { - local parse_status=0 - - _parse_args "$@" || parse_status=$? - if (( parse_status == 10 )); then - return 0 - elif (( parse_status != 0 )); then - return "$parse_status" - fi - - _init_ui - trap _cleanup EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - - _detect_target - _resolve_version - - tag_encoded="%40pythoughts%2Fpythinker-code%40${VERSION}" - archive="pythinker-code-${target}.zip" - archive_url="https://github.com/${REPO}/releases/download/${tag_encoded}/${archive}" - sha_url="${archive_url}.sha256" - bin_dir="$INSTALL_PREFIX/bin" - install_path="$bin_dir/pythinker" - - print_intro - _wait_for_release_assets - - TMP_DIR="$(mktemp -d -t pythinker-install.XXXXXX)" - _download_with_progress "$archive_url" "$TMP_DIR/$archive" \ - || fail "download failed after 3 attempts: $archive_url" - _download_quiet_with_retry 'Checksum download' "$sha_url" "$TMP_DIR/$archive.sha256" \ - || fail "checksum download failed after 3 attempts: $sha_url" - - _verify_checksum "$TMP_DIR/$archive.sha256" "$TMP_DIR/$archive" - _extract_and_install - _print_path_guidance - print_done -} - -# `curl … | bash` feeds the script over stdin, where BASH_SOURCE is empty and -# $0 is "bash". Defaulting to $0 keeps the piped install (the documented entry -# point) running main, still runs main when the file is executed directly, and -# still skips it when the script is sourced. -if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then - main "$@" -fi diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index 67d690ff..9d21e32f 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -207,6 +207,10 @@ await rm(outDir, { recursive: true, force: true }); await cp(siteDist, outDir, { recursive: true }); const channelRoot = join(outDir, 'pythinker-code'); await mkdir(channelRoot, { recursive: true }); +// Plain-text `/latest`: install.sh reads it for a fresh install, and clients +// shipped before latest.json existed still poll it. Current clients read only +// the manifest, so this file must keep being written but must never become the +// place a new field lands. await writeFile(join(channelRoot, 'latest'), `${version}\n`); const platforms = await resolvePlatformArtifacts(version); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ @@ -215,14 +219,15 @@ await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ platforms: platforms ?? undefined, rollout: [], }, null, 2)}\n`); -await cp( - join(repoRoot, 'apps/pythinker-web/public/install.sh'), - join(channelRoot, 'install.sh'), -); -await cp( - join(repoRoot, 'apps/pythinker-web/public/install.ps1'), - join(channelRoot, 'install.ps1'), -); +// One checked-in installer, served at both paths. `apps/site/public/` used to +// hold its own byte-identical copy, which meant a one-sided edit shipped +// silently; the site root keeps working because the file is placed here at +// build time instead of being duplicated in the tree. +for (const name of ['install.sh', 'install.ps1']) { + const source = join(repoRoot, 'apps/pythinker-web/public', name); + await cp(source, join(channelRoot, name)); + await cp(source, join(outDir, name)); +} await copyPlugins(repoRoot, outDir); if (!skipRg) await downloadRipgrep(repoRoot, outDir); From be3479f2d226741df1efe43809c05485556356aa Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:11:49 -0400 Subject: [PATCH 08/18] fix(update): bound every install lease, and state the rule once A live pid used to hold either lease forever with no ceiling, so a recycled pid wedged every update path permanently and nothing expired it. The rule now lives in one place. Two files each carried their own copy of isProcessRunning and their own age arithmetic, which is how they drifted apart: the lock file capped a pid-less lease at 30 minutes while the active record capped it at 6 hours, and neither capped a live one at all. isLeaseFresh takes the ceilings as arguments, so the two leases keep their different pid-less windows without keeping different implementations. hasFreshActiveInstall moves next to the record it reads, so the foreground upgrade command can ask the same question instead of growing a third copy. The preflight test mocked the whole install-state module, which silently replaced the predicate under test with undefined; it now fakes only the file IO. --- .../src/cli/update/install-lock.ts | 38 ++------- .../src/cli/update/install-state.ts | 21 +++++ apps/pythinker-code/src/cli/update/lease.ts | 56 +++++++++++++ .../src/cli/update/preflight.ts | 39 ++------- .../test/cli/update/install-lock.test.ts | 83 ++++++++++++++++++- .../test/cli/update/preflight.test.ts | 50 ++++++++--- 6 files changed, 210 insertions(+), 77 deletions(-) create mode 100644 apps/pythinker-code/src/cli/update/lease.ts diff --git a/apps/pythinker-code/src/cli/update/install-lock.ts b/apps/pythinker-code/src/cli/update/install-lock.ts index 3242587d..8f27d4f0 100644 --- a/apps/pythinker-code/src/cli/update/install-lock.ts +++ b/apps/pythinker-code/src/cli/update/install-lock.ts @@ -4,8 +4,13 @@ import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; -const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; -const UPDATE_INSTALL_LOCK_CLOCK_SKEW_MS = 5 * 60 * 1000; +import { isLeaseFresh, type LeaseLimits } from './lease'; + +const LOCK_LEASE_LIMITS: LeaseLimits = { + pidCeilingMs: 6 * 60 * 60 * 1000, + pidlessTtlMs: 30 * 60 * 1000, + clockSkewMs: 5 * 60 * 1000, +}; export interface UpdateInstallLockRequest { readonly version: string; @@ -36,23 +41,6 @@ function isAlreadyExists(error: unknown): boolean { ); } -/** - * Liveness probe via `kill(pid, 0)`. EPERM means the process exists but is - * owned by another user, which is still "running" for lock purposes. - */ -function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPERM'; - } -} - async function readLockSnapshot(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf-8'); @@ -80,18 +68,8 @@ async function readLockSnapshot(filePath: string): Promise } } -/** - * A recorded pid overrides timestamps: a live owner keeps the lock no - * matter how old it is. PID-less (legacy) locks expire by age, with a - * clock-skew tolerance so a small rollback cannot orphan a live install. - */ function isStaleLock(snapshot: LockSnapshot, now: Date): boolean { - if (snapshot.pid !== undefined) return !isProcessRunning(snapshot.pid); - if (snapshot.startedAt === undefined) return true; - const startedAt = Date.parse(snapshot.startedAt); - if (!Number.isFinite(startedAt)) return true; - const age = now.getTime() - startedAt; - return age < -UPDATE_INSTALL_LOCK_CLOCK_SKEW_MS || age > UPDATE_INSTALL_LOCK_STALE_MS; + return !isLeaseFresh(snapshot, LOCK_LEASE_LIMITS, now); } function hasSameOwner(current: LockSnapshot, expected: LockSnapshot): boolean { diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index be4e49d5..b95e440d 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -3,8 +3,29 @@ import { z } from 'zod'; import { getUpdateInstallStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { isLeaseFresh, type LeaseLimits } from './lease'; import { emptyUpdateInstallState, type InstallSource, type UpdateInstallProgress, type UpdateInstallState } from './types'; +const ACTIVE_LEASE_LIMITS: LeaseLimits = { + pidCeilingMs: 6 * 60 * 60 * 1000, + pidlessTtlMs: 6 * 60 * 60 * 1000, + clockSkewMs: 5 * 60 * 1000, +}; + +/** + * Whether an install is still in flight. It lives here, next to the record it + * reads, because both the preflight and the foreground upgrade command must + * answer it the same way — a second copy of this predicate is how the + * foreground paths came to ignore the lease at all. + */ +export function hasFreshActiveInstall( + state: UpdateInstallState, + now: Date = new Date(), +): boolean { + const active = state.active; + return active !== null && isLeaseFresh(active, ACTIVE_LEASE_LIMITS, now); +} + const InstallSourceSchema: z.ZodType = z.enum([ 'npm-global', 'pnpm-global', diff --git a/apps/pythinker-code/src/cli/update/lease.ts b/apps/pythinker-code/src/cli/update/lease.ts new file mode 100644 index 00000000..371a02b4 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/lease.ts @@ -0,0 +1,56 @@ +/** + * One rule for "is this install lease still held", shared by the install lock + * file and the active-install record. + * + * Both used to answer it with their own copy of `isProcessRunning` and their own + * age arithmetic, and they drifted: a live pid used to hold either lease forever, + * with no ceiling, so a recycled pid wedged every update path permanently. + */ + +export interface LeaseRecord { + /** Owner process id; absent in leases written before pids were recorded. */ + readonly pid?: number; + readonly startedAt?: string; +} + +export interface LeaseLimits { + /** Ceiling on a lease whose owner pid is still alive. The OS reuses pids. */ + readonly pidCeilingMs: number; + /** Ceiling on a lease with no recorded pid, where age is the only signal. */ + readonly pidlessTtlMs: number; + /** A clock rollback within this tolerance must not orphan a live install. */ + readonly clockSkewMs: number; +} + +/** + * Liveness probe via `kill(pid, 0)`. EPERM means the process exists but is + * owned by another user, which is still "running" for lease purposes. + */ +export function isProcessRunning(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === 'object' + && error !== null + && 'code' in error + && error.code === 'EPERM'; + } +} + +/** + * A lease is held while its owner is alive *and* it is younger than the + * ceiling. A lease with no usable timestamp can never age out, so it is never + * fresh; a far-future timestamp is not fresh either. + */ +export function isLeaseFresh(record: LeaseRecord, limits: LeaseLimits, now: Date): boolean { + const startedAt = record.startedAt === undefined ? Number.NaN : Date.parse(record.startedAt); + if (!Number.isFinite(startedAt)) return false; + const age = now.getTime() - startedAt; + if (age < -limits.clockSkewMs) return false; + if (record.pid !== undefined) { + return isProcessRunning(record.pid) && age < limits.pidCeilingMs; + } + return age < limits.pidlessTtlMs; +} diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index a1964614..7ea696f9 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -18,7 +18,12 @@ import { loadTuiConfig } from '#/tui/config'; import { readUpdateCache } from './cache'; import { formatErrorMessage } from './format-error'; import { tryAcquireUpdateInstallLock } from './install-lock'; -import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + emptyUpdateInstallState, + hasFreshActiveInstall, + readUpdateInstallState, + writeUpdateInstallState, +} from './install-state'; import { CHANGELOG_URL, promptForInstallChoice, @@ -62,8 +67,6 @@ export interface RunUpdatePreflightOptions { } const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; -const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; -const AUTO_INSTALL_ACTIVE_CLOCK_SKEW_MS = 5 * 60 * 1000; const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000; const UPDATE_HELPER_ENV = 'PYTHINKER_CODE_UPDATE_HELPER'; @@ -398,36 +401,6 @@ function failureAttemptsFor( return failure.attempts; } -function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPERM'; - } -} - -/** - * An active record still counts as a lease when the recorded installer - * pid is alive (liveness beats the TTL), or — for legacy pid-less - * records — when its timestamp is within the TTL (with clock-skew - * tolerance). Far-future timestamps never count as fresh. - */ -function hasFreshActiveInstall(state: UpdateInstallState): boolean { - const active = state.active; - if (active === null) return false; - const startedAt = Date.parse(active.startedAt); - if (!Number.isFinite(startedAt)) return false; - const age = Date.now() - startedAt; - if (age < -AUTO_INSTALL_ACTIVE_CLOCK_SKEW_MS) return false; - if (active.pid !== undefined) return isProcessRunning(active.pid); - return age < AUTO_INSTALL_ACTIVE_TTL_MS; -} - async function showPendingBackgroundInstallNotice( state: UpdateInstallState, currentVersion: string, diff --git a/apps/pythinker-code/test/cli/update/install-lock.test.ts b/apps/pythinker-code/test/cli/update/install-lock.test.ts index b597936d..aef85b1f 100644 --- a/apps/pythinker-code/test/cli/update/install-lock.test.ts +++ b/apps/pythinker-code/test/cli/update/install-lock.test.ts @@ -44,12 +44,91 @@ describe('update install lock', () => { await third?.release(); }); - it('does not reclaim an old lock while its owner process is alive', async () => { + it('does not reclaim a lock with a live pid just under the 6-hour pid ceiling', async () => { writeLock({ version: '0.5.0', ownerId: 'live-owner', pid: process.pid, - startedAt: '2026-01-01T00:00:00.000Z', + startedAt: '2026-08-03T00:00:00.000Z', + }); + + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T05:59:00.000Z'), + })).resolves.toBeNull(); + }); + + it('reclaims a lock with a live pid just over the 6-hour pid ceiling', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'live-owner', + pid: process.pid, + startedAt: '2026-08-03T00:00:00.000Z', + }); + + const lock = await tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T06:01:00.000Z'), + }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('reclaims a lock with a live pid and no startedAt', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'untimestamped-owner', + pid: process.pid, + }); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('expires a pid-less lock at 30 minutes, not the 6-hour pid ceiling', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'legacy-owner', + startedAt: '2026-08-03T00:00:00.000Z', + }); + + // 29 minutes: still honoured. + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:29:00.000Z'), + })).resolves.toBeNull(); + + // 31 minutes: stale, well before the pid ceiling. + const lock = await tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:31:00.000Z'), + }); + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('honours a lock 2 minutes in the future while its owner process is alive', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'skewed-owner', + pid: process.pid, + startedAt: '2026-08-03T00:02:00.000Z', + }); + + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:00:00.000Z'), + })).resolves.toBeNull(); + }); + + it('honours a pid-less lock 2 minutes in the future', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'skewed-legacy-owner', + startedAt: '2026-08-03T00:02:00.000Z', }); await expect(tryAcquireUpdateInstallLock({ diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 0b9419a6..15f00874 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -51,16 +51,18 @@ vi.mock('../../../src/cli/update/install-lock', () => ({ tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, })); -vi.mock('../../../src/cli/update/install-state', () => ({ - emptyUpdateInstallState: () => ({ - active: null, - pending: null, - lastFailure: null, - lastSuccess: null, - }), - readUpdateInstallState: mocks.readUpdateInstallState, - writeUpdateInstallState: mocks.writeUpdateInstallState, -})); +// Only the file IO is faked: `hasFreshActiveInstall` is the lease rule under +// test in several cases below, so it must be the real one. +vi.mock('../../../src/cli/update/install-state', async () => { + const actual = await vi.importActual< + typeof import('../../../src/cli/update/install-state.js') + >('../../../src/cli/update/install-state'); + return { + ...actual, + readUpdateInstallState: mocks.readUpdateInstallState, + writeUpdateInstallState: mocks.writeUpdateInstallState, + }; +}); vi.mock('../../../src/tui/config', async () => { const actual = await vi.importActual( @@ -921,13 +923,13 @@ describe('runUpdatePreflight', () => { expect(promptForInstallChoice).not.toHaveBeenCalled(); }); - it('blocks a changed target while the previous target installer pid remains alive past the PID-less TTL', async () => { + it('blocks a changed target while the previous target installer pid remains alive within the TTL', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ active: { version: '0.5.0', source: 'npm-global', - startedAt: new Date(Date.now() - 7 * 60 * 60 * 1_000).toISOString(), + startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 - 60_000)).toISOString(), pid: process.pid, }, })); @@ -942,6 +944,30 @@ describe('runUpdatePreflight', () => { expect(promptForInstallChoice).not.toHaveBeenCalled(); }); + it('recovers a changed target after the previous installer pid outlives the TTL ceiling', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 + 60_000)).toISOString(), + pid: process.pid, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@pythoughts/pythinker-code@0.6.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + }); + it('tolerates a small clock rollback while the recorded installer pid is alive', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ From a2544f67bd0501b2d9bd515016ad9c4ca6f43da8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:25:35 -0400 Subject: [PATCH 09/18] fix(update): reconcile an abandoned install so a doomed version parks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background installer's terminal state write lives in the parent process, and the product tells the user to close that terminal. When they do, the active record stays behind, no failure is recorded, and the attempt counter never advances — so the version that cannot succeed is retried on every launch. A real state file showed attempts: 1 after hours of retrying. Startup now reconciles an active record whose lease has expired into one more recorded failure and clears it, which lets the existing threshold park the version. No new counter, no bookkeeping at spawn time. failureAttemptsFor moves next to the record it reads, so install-state owns the lease rule, the failure counter and the reconciliation together, and preflight is left as the orchestrator that calls them. --- .../src/cli/update/install-state.ts | 61 ++++++- .../src/cli/update/preflight.ts | 28 +-- .../test/cli/update/install-state.test.ts | 167 +++++++++++++++++- .../test/cli/update/preflight.test.ts | 86 ++++++++- 4 files changed, 307 insertions(+), 35 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index b95e440d..c9a7539e 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -4,7 +4,7 @@ import { getUpdateInstallStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; import { isLeaseFresh, type LeaseLimits } from './lease'; -import { emptyUpdateInstallState, type InstallSource, type UpdateInstallProgress, type UpdateInstallState } from './types'; +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallOperation, type UpdateInstallProgress, type UpdateInstallState, type UpdateTarget } from './types'; const ACTIVE_LEASE_LIMITS: LeaseLimits = { pidCeilingMs: 6 * 60 * 60 * 1000, @@ -26,6 +26,65 @@ export function hasFreshActiveInstall( return active !== null && isLeaseFresh(active, ACTIVE_LEASE_LIMITS, now); } +/** + * The number of recorded failures for a target. Threshold gates omit + * `operation`: any failure kind at the limit parks the version. Increment + * sites pass their operation so a counter never resumes from another + * operation's attempts. Legacy records without `operation` count toward any + * operation. + */ +export function failureAttemptsFor( + state: UpdateInstallState, + target: UpdateTarget, + operation?: UpdateInstallOperation, +): number { + const failure = state.lastFailure; + if (failure?.version !== target.version) return 0; + if ( + operation !== undefined && + failure.operation !== undefined && + failure.operation !== operation + ) { + return 0; + } + return failure.attempts; +} + +const ABANDONED_INSTALL_MESSAGE = + 'The background install was abandoned: the process that started it exited before recording an outcome.'; + +/** + * One startup reconciliation for an install record whose owner never recorded + * an outcome. The background installer's terminal state write lives in the + * parent process, so when the parent dies the `active` record stays behind and + * no failure is recorded; without this, a version that cannot succeed is + * retried on every launch instead of being parked by the failure counter. + * + * A fresh record is a live lease and is left alone; an abandoned one is + * cleared and recorded as one more failure for its version and operation, so + * the existing threshold logic parks the version after enough of them. + */ +export async function reconcileAbandonedInstall( + state: UpdateInstallState, + now: Date = new Date(), +): Promise { + const active = state.active; + if (active === null || hasFreshActiveInstall(state, now)) return state; + const reconciled: UpdateInstallState = { + ...state, + active: null, + lastFailure: { + version: active.version, + failedAt: now.toISOString(), + attempts: failureAttemptsFor(state, { version: active.version }, active.operation) + 1, + operation: active.operation, + message: ABANDONED_INSTALL_MESSAGE, + }, + }; + await writeUpdateInstallState(reconciled).catch(() => {}); + return reconciled; +} + const InstallSourceSchema: z.ZodType = z.enum([ 'npm-global', 'pnpm-global', diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index 7ea696f9..b2a8a37c 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -20,8 +20,10 @@ import { formatErrorMessage } from './format-error'; import { tryAcquireUpdateInstallLock } from './install-lock'; import { emptyUpdateInstallState, + failureAttemptsFor, hasFreshActiveInstall, readUpdateInstallState, + reconcileAbandonedInstall, writeUpdateInstallState, } from './install-state'; import { @@ -46,7 +48,6 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, - type UpdateInstallOperation, type UpdateInstallProgress, type UpdateInstallState, type UpdateCache, @@ -380,27 +381,6 @@ function nowIso(): string { return new Date().toISOString(); } -function failureAttemptsFor( - state: UpdateInstallState, - target: UpdateTarget, - operation?: UpdateInstallOperation, -): number { - const failure = state.lastFailure; - if (failure?.version !== target.version) return 0; - // Threshold gates omit `operation`: any failure kind at the limit parks the - // version. Increment sites pass their operation so a counter never resumes - // from another operation's attempts. Legacy records without `operation` - // count toward any operation. - if ( - operation !== undefined && - failure.operation !== undefined && - failure.operation !== operation - ) { - return 0; - } - return failure.attempts; -} - async function showPendingBackgroundInstallNotice( state: UpdateInstallState, currentVersion: string, @@ -1105,7 +1085,8 @@ export async function startManualUpdate( if (!isTargetInstallable(source, cache.manifest)) { return { status: 'up-to-date' }; } - const installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + installState = await reconcileAbandonedInstall(installState); if (hasFreshActiveInstall(installState)) { return { status: 'in-progress', @@ -1238,6 +1219,7 @@ export async function runUpdatePreflight( const deviceId = resolveUpdateDeviceId(); const bypassRollout = isRolloutBypassedByExperimentalEnv(); let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + installState = await reconcileAbandonedInstall(installState); if (isInteractive) { installState = await showPendingBackgroundInstallNotice( installState, diff --git a/apps/pythinker-code/test/cli/update/install-state.test.ts b/apps/pythinker-code/test/cli/update/install-state.test.ts index 71483449..3a78d2dc 100644 --- a/apps/pythinker-code/test/cli/update/install-state.test.ts +++ b/apps/pythinker-code/test/cli/update/install-state.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,9 +7,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { emptyUpdateInstallState, readUpdateInstallState, + reconcileAbandonedInstall, writeUpdateInstallState, } from '#/cli/update/install-state'; -import type { UpdateInstallState } from '#/cli/update/types'; +import type { + UpdateInstallState, + UpdateInstallSuccess, + UpdatePreparedHomebrew, +} from '#/cli/update/types'; import { getUpdateInstallStateFile } from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -95,3 +100,161 @@ describe('update install state', () => { await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); }); }); + +describe('reconcileAbandonedInstall', () => { + const now = new Date('2026-04-23T09:00:00.000Z'); + const fixedNowIso = now.toISOString(); + + function doomedActiveInstall(): UpdateInstallState { + return { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + // Outside any plausible pid range: the owner is gone. + pid: 999_999_999, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + } + + it('clears an abandoned active record and records the first failure attempt', async () => { + const reconciled = await reconcileAbandonedInstall(doomedActiveInstall(), now); + + expect(reconciled).toEqual({ + active: null, + pending: null, + lastFailure: { + version: '0.5.0', + failedAt: fixedNowIso, + attempts: 1, + message: expect.any(String), + }, + lastSuccess: null, + }); + // The reconciled state is what the next launch reads. + await expect(readUpdateInstallState()).resolves.toEqual(reconciled); + }); + + it('reaches the parking threshold after two abandoned installs of the same version', async () => { + const first = await reconcileAbandonedInstall(doomedActiveInstall(), now); + expect(first.lastFailure?.attempts).toBe(1); + + // The next launch records a fresh active record on top of the previous + // failure, exactly like the background lifecycle does. + const second = await reconcileAbandonedInstall( + { ...doomedActiveInstall(), lastFailure: first.lastFailure }, + now, + ); + expect(second.lastFailure?.attempts).toBe(2); + }); + + it('leaves an active record with a live installer pid exactly as it is', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + pid: process.pid, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + const reconciled = await reconcileAbandonedInstall(state); + + expect(reconciled).toBe(state); + expect(existsSync(getUpdateInstallStateFile())).toBe(false); + }); + + it('leaves a state without an active record exactly as it is', async () => { + const state = emptyUpdateInstallState(); + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled).toBe(state); + expect(existsSync(getUpdateInstallStateFile())).toBe(false); + }); + + it('starts a fresh failure counter when an abandoned prepare follows install failures', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'homebrew', + operation: 'prepare', + startedAt: '2026-04-23T08:00:00.000Z', + pid: 999_999_999, + }, + pending: null, + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-22T08:00:00.000Z', + attempts: 2, + operation: 'install', + message: 'npm exited with code 1', + }, + lastSuccess: null, + }; + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled.lastFailure).toEqual({ + version: '0.5.0', + failedAt: fixedNowIso, + attempts: 1, + operation: 'prepare', + message: expect.any(String), + }); + }); + + it('leaves lastSuccess and pending untouched while reconciling', async () => { + const lastSuccess: UpdateInstallSuccess = { + version: '0.4.9', + installedAt: '2026-04-21T08:00:00.000Z', + notifiedAt: null, + }; + const pending: UpdatePreparedHomebrew = { + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.6.0', + preparedAt: '2026-04-22T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.6.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/cache/pythinker-code-0.6.0.tgz', + }; + const state: UpdateInstallState = { + ...doomedActiveInstall(), + pending, + lastSuccess, + }; + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled.lastSuccess).toBe(lastSuccess); + expect(reconciled.pending).toBe(pending); + }); + + it('returns the reconciled state even when persisting it fails', async () => { + // Plant a file where the data directory would be created, so the state + // write fails with ENOTDIR and startup must not break. + const blocked = join(dir, 'blocked'); + writeFileSync(blocked, 'not a directory', 'utf-8'); + process.env['PYTHINKER_CODE_HOME'] = blocked; + + await expect(reconcileAbandonedInstall(doomedActiveInstall(), now)).resolves.toEqual({ + active: null, + pending: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + }), + lastSuccess: null, + }); + }); +}); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 15f00874..7ab0bb9a 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -28,6 +28,7 @@ import { DEFAULT_STATUS_LINE_CONFIG, type TuiConfig, } from '#/tui/config'; +import { getUpdateInstallStateFile } from '#/utils/paths'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -40,6 +41,8 @@ const mocks = vi.hoisted(() => ({ refreshUpdateCache: vi.fn(), resolveUpdateDeviceId: vi.fn(), appendRolloutDecisionLog: vi.fn(), + readJsonFile: vi.fn(), + writeJsonFile: vi.fn(), spawn: vi.fn(), })); @@ -64,6 +67,15 @@ vi.mock('../../../src/cli/update/install-state', async () => { }; }); +// The reconciliation lives inside install-state.ts and calls its own module's +// writer directly, which the module mock above cannot rewire. Mocking the +// persistence layer catches those writes too — and keeps them off the real +// home directory. +vi.mock('../../../src/utils/persistence', () => ({ + readJsonFile: mocks.readJsonFile, + writeJsonFile: mocks.writeJsonFile, +})); + vi.mock('../../../src/tui/config', async () => { const actual = await vi.importActual( '../../../src/tui/config.js', @@ -386,6 +398,8 @@ describe('runUpdatePreflight', () => { beforeEach(() => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.readJsonFile.mockResolvedValue(null); + mocks.writeJsonFile.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); @@ -1055,6 +1069,51 @@ describe('runUpdatePreflight', () => { ); }); + it('parks a doomed version after two abandoned installs are reconciled', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + let persisted: UpdateInstallState = installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + pid: 999_999_999, + }, + }); + // The reconciliation writes through install-state's own writer, which the + // module mock cannot intercept — capture that path via the persistence + // mock so both write routes feed the same simulated state file. + mocks.writeUpdateInstallState.mockImplementation( + async (state: UpdateInstallState) => { persisted = state; }, + ); + mocks.writeJsonFile.mockImplementation( + async (_filePath: string, _schema: unknown, value: UpdateInstallState) => { persisted = value; }, + ); + mocks.readUpdateInstallState.mockImplementation(async () => persisted); + // Each launch's installer dies without recording an outcome, so the next + // launch finds only an abandoned active record. + mocks.spawn.mockImplementation( + () => Object.assign(new EventEmitter(), { pid: 999_999_999, unref: vi.fn() }), + ); + const { options } = captureOutput(); + + // First launch: the abandoned record is reconciled to one attempt and the + // version is still attempted in the background. + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(persisted.lastFailure?.attempts).toBe(1); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + + // Second launch: the counter reaches the parking threshold and the + // automatic path refuses to start the installer again — assert the + // refusal, not just the counter. + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(persisted.lastFailure?.attempts).toBe(2); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledTimes(1); + }); + it('recovers a changed target from a far-future active timestamp', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ @@ -1496,7 +1555,7 @@ describe('runUpdatePreflight', () => { expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); }); - it('infers a background update success notice when the active install version is now running', async () => { + it('records an abandoned install as a failure instead of inferring a success notice', async () => { mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); mocks.readUpdateInstallState.mockResolvedValue(installState({ active: { @@ -1510,15 +1569,22 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - expect(stdout.join('')).toContain('Pythinker Code updated to v0.5.0'); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: null, - lastFailure: null, - lastSuccess: expect.objectContaining({ - version: '0.5.0', - notifiedAt: expect.any(String), + // A stale active record is an abandoned install, not an inferred success: + // it is reconciled into a recorded failure and never shows the notice. + expect(stdout.join('')).toBe(''); + expect(mocks.writeJsonFile).toHaveBeenCalledWith( + getUpdateInstallStateFile(), + expect.anything(), + expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + message: expect.stringContaining('abandoned'), + }), }), - })); + expect.objectContaining({ durable: true }), + ); }); it('tracks update_prompted telemetry', async () => { @@ -1979,6 +2045,8 @@ describe('startManualUpdate', () => { beforeEach(() => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.readJsonFile.mockResolvedValue(null); + mocks.writeJsonFile.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); From fbd23578b0aee695ac425512f5f1d238f92e4d5e Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:32:25 -0400 Subject: [PATCH 10/18] fix(update): hold the install lock on the two foreground paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pythinker upgrade` imported neither the lock nor the install state, and the preflight prompt path fell through a guard that returns before the active-record check. Either could run a foreground install while a detached background one was already writing the same executable, and neither recorded its outcome — so a stale active record kept misleading the state machine afterwards. Both now refuse when an install is in flight, take the lock only after the prompt resolves (holding it across an interactive wait would block the background path for as long as the prompt sits unanswered), release it in a finally, and write lastSuccess or lastFailure with the same shapes and the same attempt counter the background path uses. hasFreshActiveInstall is imported, not injected: a pure predicate that never varies per call site does not need a seam, and the test was passing the real one anyway. --- apps/pythinker-code/src/cli/sub/upgrade.ts | 89 +++++++++++ .../src/cli/update/preflight.ts | 36 +++++ .../test/cli/update/preflight.test.ts | 89 +++++++++++ apps/pythinker-code/test/cli/upgrade.test.ts | 149 +++++++++++++++++- 4 files changed, 362 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/cli/sub/upgrade.ts b/apps/pythinker-code/src/cli/sub/upgrade.ts index 22279ee6..e2b545bd 100644 --- a/apps/pythinker-code/src/cli/sub/upgrade.ts +++ b/apps/pythinker-code/src/cli/sub/upgrade.ts @@ -2,6 +2,15 @@ import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import { track as trackTelemetry, type TelemetryProperties } from '@pythoughts/pythinker-telemetry'; import { refreshUpdateCache } from '#/cli/update/refresh'; +import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import type { UpdateInstallLockHandle, UpdateInstallLockRequest } from '#/cli/update/install-lock'; +import { + emptyUpdateInstallState, + failureAttemptsFor, + hasFreshActiveInstall, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; import { detectInstallSource } from '#/cli/update/source'; import { @@ -20,6 +29,8 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateCache, + type UpdateInstallState, + type UpdateTarget, } from '#/cli/update/types'; interface WritableLike { @@ -40,6 +51,11 @@ export interface UpgradeDeps { readonly promptForInstallChoice: ( options: InstallPromptOptions, ) => Promise; + readonly readUpdateInstallState: () => Promise; + readonly writeUpdateInstallState: (state: UpdateInstallState) => Promise; + readonly tryAcquireUpdateInstallLock: ( + request: UpdateInstallLockRequest, + ) => Promise; readonly platform: NodeJS.Platform; readonly stdout: WritableLike; readonly stderr: WritableLike; @@ -145,6 +161,19 @@ export async function handleUpgrade( return 0; } + // The foreground install holds the update-install lock for its whole run: + // another live installer (usually a detached background one) must never be + // raced by this path, which writes the same executable. A fresh active + // record or a held lock means an install is already in flight — refuse. + const installState = await deps.readUpdateInstallState().catch(() => emptyUpdateInstallState()); + if (hasFreshActiveInstall(installState)) { + return refuseForegroundInstall(deps, currentVersion, target, source, installState.active?.version); + } + const lock = await deps.tryAcquireUpdateInstallLock({ version: target.version }); + if (lock === null) { + return refuseForegroundInstall(deps, currentVersion, target, source, undefined); + } + try { trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { current_version: currentVersion, @@ -152,6 +181,16 @@ export async function handleUpgrade( source, }); await deps.installUpdate(source, target.version, deps.platform); + await deps.writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: null, + lastSuccess: { + version: target.version, + installedAt: nowIso(), + notifiedAt: null, + }, + }).catch(() => {}); trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { current_version: currentVersion, target_version: target.version, @@ -165,6 +204,18 @@ export async function handleUpgrade( deps.stdout.write(renderInstallSuccessMessage(target)); return 0; } catch (error) { + const attempts = failureAttemptsFor(installState, target, 'install') + 1; + await deps.writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + operation: 'install', + message: formatErrorMessage(error), + }, + }).catch(() => {}); trackUpgradeEvent(deps.track, 'upgrade_command_failed', { current_version: currentVersion, target_version: target.version, @@ -183,6 +234,8 @@ export async function handleUpgrade( `${formatErrorMessage(error)}\n`, ); return 1; + } finally { + await lock.release().catch(() => {}); } } @@ -192,6 +245,9 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), installUpdate: overrides.installUpdate ?? installUpdateForeground, promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, + readUpdateInstallState: overrides.readUpdateInstallState ?? (() => readUpdateInstallState()), + writeUpdateInstallState: overrides.writeUpdateInstallState ?? writeUpdateInstallState, + tryAcquireUpdateInstallLock: overrides.tryAcquireUpdateInstallLock ?? tryAcquireUpdateInstallLock, platform: overrides.platform ?? process.platform, stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, @@ -205,6 +261,39 @@ function formatDisplayVersion(version: string): string { return version.startsWith('v') ? version : `v${version}`; } +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Refuse the foreground install because another install is already in + * flight. The active-record case names the version being installed; the + * lock-held case cannot know it, so the message stays generic. + */ +function refuseForegroundInstall( + deps: UpgradeDeps, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + activeVersion: string | undefined, +): number { + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + target_version: target.version, + source, + stage: 'install', + reason: 'another update install is already in progress', + }); + const suffix = activeVersion === undefined + ? '' + : ` (${formatDisplayVersion(activeVersion)})`; + deps.stderr.write( + `error: another update install is already in progress${suffix}; ` + + 'try again once it finishes.\n', + ); + return 1; +} + function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index b2a8a37c..7f74fb68 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -1337,6 +1337,11 @@ export async function runUpdatePreflight( return 'continue'; } + // An install that is already in flight must not be prompted for a second + // time: the user would get a confusing double message for work that is + // already running elsewhere. + if (hasFreshActiveInstall(installState)) return 'continue'; + const choice = await promptInstall( currentVersion, userVisibleTarget, @@ -1346,16 +1351,47 @@ export async function runUpdatePreflight( ); if (choice === 'skip') return 'continue'; + // Take the lock only after the prompt resolves: holding it across an + // indefinite interactive wait would block the background path as long as + // the prompt sits unanswered. A null handle means another installer is + // already running — do not install on top of it. + const lock = await tryAcquireUpdateInstallLock({ version: userVisibleTarget.version }); + if (lock === null) return 'continue'; + try { await installUpdate(source, userVisibleTarget.version, platform); + await writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: null, + lastSuccess: { + version: userVisibleTarget.version, + installedAt: nowIso(), + notifiedAt: null, + }, + }).catch(() => {}); stdout.write(renderInstallSuccessMessage(userVisibleTarget)); return 'exit'; } catch (error) { + const attempts = failureAttemptsFor(installState, userVisibleTarget, 'install') + 1; + await writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: { + version: userVisibleTarget.version, + failedAt: nowIso(), + attempts, + operation: 'install', + message: formatErrorMessage(error), + }, + }).catch(() => {}); stderr.write( `warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` + `${formatErrorMessage(error)}\n`, ); return 'continue'; + } finally { + await lock.release().catch(() => {}); } } catch { return 'continue'; diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 7ab0bb9a..471b5c2a 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -807,6 +807,95 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); + it('does not prompt for a foreground install while a fresh active install is running', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + }); + + it('acquires the install lock only after the prompt resolves and releases it afterwards', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const release = vi.fn().mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }); + let resolvePrompt: ((value: 'install') => void) | undefined; + const prompt = new Promise<'install'>((resolve) => { resolvePrompt = resolve; }); + mocks.promptForInstallChoice.mockReturnValue(prompt); + const { stdout, options } = captureOutput(); + + const running = runUpdatePreflight('0.4.0', options); + + expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + resolvePrompt?.('install'); + await expect(running).resolves.toBe('exit'); + expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); + expect(release).toHaveBeenCalledOnce(); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: { + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }, + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('releases the lock and records the failure when the foreground install fails', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(1); + const release = vi.fn().mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(stderr.join('')).toContain('warning: failed to install'); + expect(release).toHaveBeenCalledOnce(); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + operation: 'install', + failedAt: expect.any(String), + }), + lastSuccess: null, + })); + }); + it('warns and continues when spawn exits non-zero, without claiming success', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); diff --git a/apps/pythinker-code/test/cli/upgrade.test.ts b/apps/pythinker-code/test/cli/upgrade.test.ts index 89efab24..12f9b2c4 100644 --- a/apps/pythinker-code/test/cli/upgrade.test.ts +++ b/apps/pythinker-code/test/cli/upgrade.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { handleUpgrade } from '#/cli/sub/upgrade'; +import { emptyUpdateInstallState } from '#/cli/update/install-state'; +import type { UpdateInstallLockHandle } from '#/cli/update/install-lock'; import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; -import type { InstallSource, UpdateCache } from '#/cli/update/types'; +import type { InstallSource, UpdateCache, UpdateInstallState } from '#/cli/update/types'; function cacheWith( version: string | null, @@ -68,6 +70,9 @@ function createDeps(overrides: { readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise; readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise; + readonly readUpdateInstallState?: () => Promise; + readonly writeUpdateInstallState?: (state: UpdateInstallState) => Promise; + readonly tryAcquireUpdateInstallLock?: () => Promise; } = {}) { const installUpdate = overrides.installUpdate ?? @@ -85,6 +90,16 @@ function createDeps(overrides: { promptForInstallChoice: overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), installUpdate, + readUpdateInstallState: + overrides.readUpdateInstallState ?? vi.fn().mockResolvedValue(emptyUpdateInstallState()), + writeUpdateInstallState: + overrides.writeUpdateInstallState ?? vi.fn().mockResolvedValue(undefined), + tryAcquireUpdateInstallLock: + overrides.tryAcquireUpdateInstallLock ?? + vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }), track: vi.fn(), logger: { info: vi.fn(), @@ -308,4 +323,136 @@ describe('handleUpgrade', () => { })); expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); }); + + it('refuses the install while a fresh active install for another version is running', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + readUpdateInstallState: vi.fn().mockResolvedValue({ + ...emptyUpdateInstallState(), + active: { + version: '0.5.1', + source: 'npm-global', + startedAt: new Date().toISOString(), + }, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('0.5.1'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + }); + + it('refuses the install when another process holds the install lock', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue(null), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(stderr.join('')).not.toBe(''); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + }); + + it('takes the install lock, installs, and records the success', async () => { + const { stdout, writable } = captureOutput(); + const release = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(release).toHaveBeenCalledOnce(); + expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: { + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }, + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('releases the lock and records the failure when the foreground install fails', async () => { + const { stderr, writable } = captureOutput(); + const release = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }), + readUpdateInstallState: vi.fn().mockResolvedValue({ + ...emptyUpdateInstallState(), + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 1, + operation: 'install', + }, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(release).toHaveBeenCalledOnce(); + expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 2, + operation: 'install', + failedAt: expect.any(String), + message: 'npm exited with code 1', + }), + })); + expect(stderr.join('')).toContain( + 'warning: failed to install @pythoughts/pythinker-code@0.5.0: npm exited with code 1', + ); + }); + + it('never writes an active record for the foreground install', async () => { + const { writable } = captureOutput(); + const writeUpdateInstallState = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + writeUpdateInstallState, + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(writeUpdateInstallState).toHaveBeenCalledTimes(1); + expect(writeUpdateInstallState.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ + active: null, + })); + }); }); From 548fd00e7ac11e0401f7217814e037937763a556 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:37:19 -0400 Subject: [PATCH 11/18] fix(update): say which version is installing and which one follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported screenshot: the banner said v0.11.0 was available, /update answered "Update to v0.10.0 already in progress". Two surfaces, two versions, no explanation, so the update read as stuck. The in-progress result now carries both — installingVersion, named so it cannot be mistaken for the target, plus the target when it is strictly newer — and the notice becomes "Installing v0.10.0 — v0.11.0 will follow" with a body that says the running install finishes first. The single-version case keeps its wording. Not implemented on purpose: killing the running installer to switch targets. Its lease is now bounded and startup reconciles an abandoned one, so the wait is finite; killing a live installer that is writing the executable is the most dangerous edit in the report for the smallest gain. --- .../src/cli/update/preflight.ts | 29 +++++-- apps/pythinker-code/src/tui/commands/info.ts | 12 ++- .../test/cli/update/preflight.test.ts | 53 +++++++++++- .../tui/commands/update-preferences.test.ts | 80 +++++++++++++++++++ 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index 7f74fb68..896ec9b0 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import type { Readable } from 'node:stream'; -import { gte, valid } from 'semver'; +import { gt, gte, valid } from 'semver'; import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import type { TelemetryProperties } from '@pythoughts/pythinker-telemetry'; @@ -698,6 +698,17 @@ function preparedVersionCoversTarget(preparedVersion: string, targetVersion: str return valid(preparedVersion) !== null && valid(targetVersion) !== null && gte(preparedVersion, targetVersion); } +/** + * Whether the target is strictly newer than the version an active install is + * working on — the newer update can only start after the running one finishes. + */ +function targetSupersedesInstallingVersion( + installingVersion: string, + targetVersion: string, +): boolean { + return valid(installingVersion) !== null && valid(targetVersion) !== null && gt(targetVersion, installingVersion); +} + async function startBackgroundHomebrewPreparation( state: UpdateInstallState, currentVersion: string, @@ -1047,7 +1058,9 @@ export type ManualUpdateResult = | { readonly status: 'started'; readonly version: string; readonly installOnRestart: boolean } | { readonly status: 'in-progress'; - readonly version: string; + readonly installingVersion: string; + /** Present only when it is newer than the version being installed. */ + readonly targetVersion?: string; readonly installOnRestart: boolean; readonly readyToInstall: boolean; } @@ -1088,9 +1101,13 @@ export async function startManualUpdate( let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); installState = await reconcileAbandonedInstall(installState); if (hasFreshActiveInstall(installState)) { + const installingVersion = installState.active?.version ?? target.version; return { status: 'in-progress', - version: installState.active?.version ?? target.version, + installingVersion, + targetVersion: targetSupersedesInstallingVersion(installingVersion, target.version) + ? target.version + : undefined, installOnRestart: installState.active?.source === 'homebrew', readyToInstall: false, }; @@ -1113,7 +1130,7 @@ export async function startManualUpdate( } return { status: 'in-progress', - version: pending.version, + installingVersion: pending.version, installOnRestart: true, readyToInstall: true, }; @@ -1151,7 +1168,7 @@ export async function startManualUpdate( if (!started) { return { status: 'in-progress', - version: target.version, + installingVersion: target.version, installOnRestart: true, readyToInstall: false, }; @@ -1179,7 +1196,7 @@ export async function startManualUpdate( if (!started) { return { status: 'in-progress', - version: target.version, + installingVersion: target.version, installOnRestart: false, readyToInstall: false, }; diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index 8db3ffd6..3354706b 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -261,8 +261,18 @@ export async function handleUpdateCommand( ); return; case 'in-progress': + // The target is present only when it is newer than the version the + // running install is working on; everything else keeps the old wording. + if (result.targetVersion !== undefined) { + host.showNotice( + `Installing v${result.installingVersion} — v${result.targetVersion} will follow`, + `The running install of v${result.installingVersion} finishes first; ` + + `v${result.targetVersion} installs after the next start.`, + ); + return; + } host.showNotice( - `Update to v${result.version} already in progress`, + `Update to v${result.installingVersion} already in progress`, result.installOnRestart ? result.readyToInstall ? 'Close this terminal and open a new one to install it.' diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 471b5c2a..7f8e7428 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -2283,7 +2283,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', - version: '0.5.0', + installingVersion: '0.5.0', installOnRestart: true, readyToInstall: true, }); @@ -2302,13 +2302,62 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', - version: '0.5.0', + installingVersion: '0.5.0', installOnRestart: false, readyToInstall: false, }); expect(mocks.spawn).not.toHaveBeenCalled(); }); + it('reports both the running older install and the newer target it will follow', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.10.0', source: 'npm-global', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.9.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.10.0', + targetVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not claim the target supersedes an active install of a newer version', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.11.0', source: 'npm-global', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.9.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('keeps installOnRestart for a fresh homebrew active install', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.5.0', source: 'homebrew', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.5.0', + installOnRestart: true, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + it('falls back to the manual command after repeated background failures', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); diff --git a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts index e12012c4..7197efd7 100644 --- a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts +++ b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts @@ -8,12 +8,14 @@ import { handleOutputStyleCommand, handlePermissionsCommand, } from '#/tui/commands/config'; +import { handleUpdateCommand } from '#/tui/commands/info'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; const mocks = vi.hoisted(() => ({ disableTelemetry: vi.fn(), saveTuiConfig: vi.fn(), + startManualUpdate: vi.fn(), })); vi.mock('@pythoughts/pythinker-telemetry', async (importOriginal) => ({ @@ -31,6 +33,16 @@ vi.mock('../../../src/tui/config', async () => { }; }); +vi.mock('../../../src/cli/update/preflight', async (importOriginal) => { + const actual = await vi.importActual( + '../../../src/cli/update/preflight.js', + ); + return { + ...actual, + startManualUpdate: mocks.startManualUpdate, + }; +}); + describe('update preference commands', () => { it('persists telemetry opt-out and stops collection immediately', async () => { const host = { @@ -230,6 +242,74 @@ describe('output style commands', () => { }); }); +describe('update command', () => { + function makeHost() { + const host = { + state: { appState: { version: '0.9.0' } }, + showStatus: vi.fn(), + showNotice: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost & { + showStatus: ReturnType; + showNotice: ReturnType; + showError: ReturnType; + }; + return host; + } + + it('keeps the existing wording for an in-progress update of the same version', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + installOnRestart: false, + readyToInstall: false, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Update to v0.10.0 already in progress', + 'Close this terminal and open a new one once it completes.', + ); + }); + + it('keeps the homebrew ready-to-install wording for a same-version in-progress update', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + installOnRestart: true, + readyToInstall: true, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Update to v0.10.0 already in progress', + 'Close this terminal and open a new one to install it.', + ); + }); + + it('announces the newer target when the running install is for an older version', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + targetVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Installing v0.10.0 — v0.11.0 will follow', + 'The running install of v0.10.0 finishes first; v0.11.0 installs after the next start.', + ); + }); +}); + describe('permission rule commands', () => { function makeHost() { const session = { From e6d6bcc3b17e982d9e68db1de643dde481691fd1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:46:20 -0400 Subject: [PATCH 12/18] fix(update): decide the target once, after the bounded refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight started a background install from the cached target and only then refreshed and decided again. A real rollout log shows the cached path selecting 0.10.0 twice while latest.json already advertised 0.11.0 — so the app launched a multi-minute installer against a version it was about to stop advertising. The second decision was already the correct one, so it is now the only one: the cached decision just answers "is anything worth refreshing for", and everything after the bounded refresh uses the refreshed target and the refreshed manifest, falling back to the cached pair when the refresh fails or times out. The duplicate install attempt is deleted, and with it refreshInBackground — the bounded refresh has always just run on every surviving path. Costs up to one second before a background install starts when an update is pending. That wait was already paid on this path, just later. --- .../src/cli/update/preflight.ts | 55 +++------ .../test/cli/update/preflight.test.ts | 113 ++++++++++++++++++ 2 files changed, 131 insertions(+), 37 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index 896ec9b0..82788508 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -236,10 +236,6 @@ function renderBackgroundInstallSuccessNotice(version: string): string { return `Pythinker Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; } -function refreshInBackground(): void { - void refreshUpdateCache().catch(() => {}); -} - /** Telemetry properties describing where this device sits in the rollout. */ interface RolloutTelemetry { readonly rollout_bucket: number; @@ -1278,39 +1274,11 @@ export async function runUpdatePreflight( ? 'unsupported' : await detectInstallSource().catch(() => 'unsupported' as const); - // A native install consumes the manifest's platform artifact; without one - // the update cannot succeed, so do not offer or start it. Non-native - // sources install from the registry/formula and are never gated here. - if (!isTargetInstallable(source, cachedManifest)) { - // Still refresh: the next manifest may carry this platform's artifact, - // and returning without one would freeze this client on the cached - // answer forever. - refreshInBackground(); - return 'continue'; - } - - const decision = decideUpdateAction(target, isInteractive, source, platform); - if (decision === 'none') { - refreshInBackground(); - return 'continue'; - } - - if ( - await tryStartAutomaticBackgroundInstall( - installState, - currentVersion, - target, - source, - platform, - options.track, - logger, - rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), - ) - ) { - refreshInBackground(); - return 'continue'; - } - + // The cached target above only decides whether anything is worth + // refreshing for; the bounded refresh below is this launch's single + // decision. Everything after it uses the refreshed target and manifest, + // with the cached pair as the fallback when the refresh fails or times + // out. A null refreshed target means the refresh offers nothing. const userVisibleUpdate = await refreshUserVisibleUpdateTarget( currentVersion, deviceId, @@ -1326,6 +1294,19 @@ export async function runUpdatePreflight( userVisibleUpdate.manifest, bypassRollout, ); + + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so do not offer or start it. Non-native + // sources install from the registry/formula and are never gated here. + if (!isTargetInstallable(source, userVisibleUpdate.manifest)) { + return 'continue'; + } + + const decision = decideUpdateAction(userVisibleTarget, isInteractive, source, platform); + if (decision === 'none') { + return 'continue'; + } + if ( await tryStartAutomaticBackgroundInstall( installState, diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 7f8e7428..27a03053 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -575,6 +575,119 @@ describe('runUpdatePreflight', () => { } }); + it('starts the background install for the refreshed version, never the cached one', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.11.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + expect(mocks.spawn).not.toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + expect.anything(), + ); + }); + + it('starts nothing when the refresh offers no newer version than the current one', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.9.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + }); + + it('falls back to the cached target when the refresh rejects', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockRejectedValue(new Error('offline')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + }); + + it('falls back to the cached target when the refresh hangs past the 1-second budget', async () => { + vi.useFakeTimers(); + try { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockReturnValue(new Promise(() => {})); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + const result = runUpdatePreflight('0.9.0', options); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(result).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + } finally { + vi.useRealTimers(); + } + }); + + it('native: offers and installs nothing when the refreshed manifest omits the running platform', async () => { + const cached = cacheWithManifest(manifestForRunningTarget('0.10.0')); + const refreshed = cacheWithManifest(manifestOmittingRunningTarget('0.11.0')); + mocks.readUpdateCache.mockResolvedValue(cached); + mocks.refreshUpdateCache.mockResolvedValue(refreshed); + mocks.detectInstallSource.mockResolvedValue('native'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('decides from the cache and from the refresh exactly once each per launch', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const phases = mocks.appendRolloutDecisionLog.mock.calls.map((call) => call[0].phase); + expect(phases.filter((phase) => phase === 'startup-cache')).toHaveLength(1); + expect(phases.filter((phase) => phase === 'prompt-refresh')).toHaveLength(1); + expect(phases.filter((phase) => phase === 'background-refresh')).toHaveLength(0); + }); + it('pnpm-global: spawns pnpm add -g', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); From 120284213b2118770b7e1ff81887841aab509c39 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:48:52 -0400 Subject: [PATCH 13/18] refactor(tui): delete the startup banner's update chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip is the surface that produced the reported confusion: it read updates/latest.json directly, was computed once at startup and never recomputed, and knew nothing about the install source or about whether the version it advertised had a build for this platform — so it announced v0.11.0 while /update was talking about v0.10.0. It was also blocking IO in the render path: the gutter re-renders every child every frame, and each frame called readFileSync. The footer now carries a live update chip in the status row under the prompt, computed from real install state and gated on installability. The fix for two surfaces disagreeing is one surface, so the chip and the subtitleChip option it was the only producer of are gone, along with the border branch that existed to place it. --- .../tui/components/chrome/welcome-banner.ts | 62 ++----------------- .../src/tui/components/chrome/welcome.ts | 2 - 2 files changed, 5 insertions(+), 59 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts index 949ed93a..cdf89a07 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts @@ -1,15 +1,11 @@ /** * Welcome banner layout — mirrors the Python shell `_print_welcome_info` design: - * panel title + subtitle chip, robot mark, facts grid, and optional tips column. + * panel title, robot mark, facts grid, and optional tips column. */ -import { readFileSync } from 'node:fs'; - import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import { gt, valid } from 'semver'; import chalk from 'chalk'; -import { getUpdateStateFile } from '#/utils/paths'; import { createGitStatusCache, type GitStatusCache } from '#/utils/git/git-status'; import { currentTheme } from '#/tui/theme'; import type { AppState } from '#/tui/types'; @@ -68,7 +64,6 @@ export interface RenderWelcomeBannerOptions { readonly version: string; readonly infoItems: readonly WelcomeInfoItem[]; readonly copy: WelcomeBannerCopy; - readonly subtitleChip?: string | null; readonly logoLines?: readonly string[]; readonly asciiMode?: boolean; } @@ -293,26 +288,12 @@ function renderTwoColumns( return rows; } -function renderPanelTopBorder(title: string, subtitle: string | null, width: number): string { +function renderPanelTopBorder(title: string, width: number): string { const inner = Math.max(0, width - 2); const titlePart = ` ${title} `; const titleWidth = visibleWidth(titlePart); - - if (!subtitle) { - const dashCount = Math.max(0, inner - titleWidth); - return borderPaint('╭') + titlePart + borderPaint('─'.repeat(dashCount)) + borderPaint('╮'); - } - - const subtitlePart = ` ${subtitle} `; - const subtitleWidth = visibleWidth(subtitlePart); - const dashCount = Math.max(1, inner - titleWidth - subtitleWidth); - return ( - borderPaint('╭') + - titlePart + - borderPaint('─'.repeat(dashCount)) + - subtitlePart + - borderPaint('╮') - ); + const dashCount = Math.max(0, inner - titleWidth); + return borderPaint('╭') + titlePart + borderPaint('─'.repeat(dashCount)) + borderPaint('╮'); } export function buildWelcomeCopy(isLoggedOut: boolean): WelcomeBannerCopy { @@ -366,38 +347,6 @@ export function buildWelcomeTips(): WelcomeInfoItem[] { return WELCOME_TIPS.map((value) => ({ name: 'Tip', value })); } -export function readWelcomeUpdateTarget(currentVersion: string): string | null { - try { - const raw = readFileSync(getUpdateStateFile(), 'utf-8'); - const parsed = JSON.parse(raw) as { latest?: string | null }; - const latest = parsed.latest; - if ( - typeof latest === 'string' && - valid(latest) && - valid(currentVersion) && - gt(latest, currentVersion) - ) { - return latest; - } - } catch { - // Missing or malformed cache — no chip. - } - return null; -} - -export function buildWelcomeSubtitleChip(version: string): string | null { - const updateTarget = readWelcomeUpdateTarget(version); - const palette = currentTheme.palette; - if (updateTarget) { - return paintWithSlashAccent( - `↑ Update available — v${updateTarget} · /update`, - palette.warning, - PYTHINKER_LOGO_COLORS.accent, - ); - } - return null; -} - export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string[] { const safeWidth = Math.max(0, options.width); if (safeWidth < 24) { @@ -490,11 +439,10 @@ export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string const versionTitle = chalk.hex(currentTheme.palette.textMuted)('Pythinker Code') + chalk.hex(currentTheme.palette.textDim)(` v${options.version}`); - const subtitle = options.subtitleChip ?? null; const lines: string[] = [ '', - renderPanelTopBorder(versionTitle, subtitle, panelWidth), + renderPanelTopBorder(versionTitle, panelWidth), borderPaint('│') + ' '.repeat(panelWidth - 2) + borderPaint('│'), ]; diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index 9d145d38..f3a40da9 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -27,7 +27,6 @@ import { asciiGlyphsEnabled, buildWelcomeCopy, buildWelcomeInfoItems, - buildWelcomeSubtitleChip, createWelcomeGitCache, renderWelcomeBanner, } from './welcome-banner'; @@ -81,7 +80,6 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { version: this.state.version, infoItems: buildWelcomeInfoItems(this.state, this.gitCache), copy, - subtitleChip: buildWelcomeSubtitleChip(this.state.version), logoLines, asciiMode: asciiGlyphsEnabled(), }); From ba44f461727b3a4fbaab98aeb3b75d83886173a9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:53:49 -0400 Subject: [PATCH 14/18] feat(update): let a release declare a minimum supported version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged rollout can hold an update back for as long as its batch plan says. That is right for an ordinary release and wrong for one a client cannot skip — a protocol change, a revoked credential, a service that no longer answers the old client. There was no way past the delay. latest.json may now carry minRequiredVersion. A client below it gets the target regardless of rollout eligibility, with reason 'required' so the decision log and telemetry still say where the device sat in the plan, and the footer labels it `↑ v0.11.0 required`. The floor lives in one predicate next to the other manifest questions, so the rollout and the footer cannot disagree about who is below it. An unreadable declaration answers false: a value we cannot parse must not escalate an update on its own. Setting it is a policy decision, so it is a constant in build-cdn.mjs rather than an env var — it should be visible in the diff that makes it. --- apps/pythinker-code/src/cli/update/cdn.ts | 40 ++++++-- apps/pythinker-code/src/cli/update/rollout.ts | 16 +++ apps/pythinker-code/src/cli/update/types.ts | 6 ++ .../src/tui/runtime/footer/footer-model.ts | 5 +- .../src/tui/runtime/footer/update-status.ts | 12 ++- .../test/cli/update/cdn.test.ts | 26 +++++ .../test/cli/update/rollout.test.ts | 98 +++++++++++++++++++ .../test/tui/runtime/footer-model.test.ts | 5 + .../tui/runtime/footer/update-status.test.ts | 85 ++++++++++++++++ apps/site/scripts/build-cdn.mjs | 6 ++ 10 files changed, 291 insertions(+), 8 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/cdn.ts b/apps/pythinker-code/src/cli/update/cdn.ts index 50ad20cb..0f59d86e 100644 --- a/apps/pythinker-code/src/cli/update/cdn.ts +++ b/apps/pythinker-code/src/cli/update/cdn.ts @@ -1,4 +1,4 @@ -import { valid } from 'semver'; +import { lt, valid } from 'semver'; import { z } from 'zod'; import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; @@ -32,8 +32,8 @@ const UpdateManifestPlatformSchema = z.object({ /** * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown * fields are ignored so future manifest additions never break shipped - * clients (the plain-text `/latest` taught us that hard-failing on - * unexpected content bricks the update path forever). + * clients. Hard-failing on unexpected content bricks the update path for + * every already-installed client, which is unrecoverable from our side. */ export const UpdateManifestSchema = z.object({ version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), @@ -44,15 +44,25 @@ export const UpdateManifestSchema = z.object({ /** * Resolved per-platform artifacts, keyed `-`. A malformed * value drops only this field via `.catch(undefined)` so `version` and - * `publishedAt` still parse — failing the whole manifest would send - * clients to the plain-text `/latest` fallback, which carries no platform - * information at all. + * `publishedAt` still parse — failing the whole manifest would cost the + * client its update over one unreadable field. */ platforms: z .record(z.string(), UpdateManifestPlatformSchema) .readonly() .optional() .catch(undefined), + /** + * Lowest version that can still work against the current services. A + * malformed value drops only this field via `.catch(undefined)` so + * `version` and `publishedAt` still parse — a client below the floor must + * not lose its update because the declaration is unreadable. + */ + minRequiredVersion: z + .string() + .refine((value) => valid(value) !== null, { error: 'invalid semver' }) + .optional() + .catch(undefined), }); async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise { @@ -115,3 +125,21 @@ export function manifestArtifactAvailability( } return Object.hasOwn(manifest.platforms, target) ? 'available' : 'unavailable'; } + +/** + * Whether the running version is below the manifest's declared floor, which + * makes its update mandatory rather than merely available: the staged rollout + * delay exists for ordinary releases, not for one a client cannot skip. + * + * An absent, unreadable or non-semver floor answers false — a declaration we + * cannot understand must not escalate an update on its own. + */ +export function isBelowMinRequiredVersion( + manifest: UpdateManifest | null, + currentVersion: string, +): boolean { + const floor = manifest?.minRequiredVersion; + if (floor === undefined) return false; + if (valid(currentVersion) === null || valid(floor) === null) return false; + return lt(currentVersion, floor); +} diff --git a/apps/pythinker-code/src/cli/update/rollout.ts b/apps/pythinker-code/src/cli/update/rollout.ts index c212a07b..f546724b 100644 --- a/apps/pythinker-code/src/cli/update/rollout.ts +++ b/apps/pythinker-code/src/cli/update/rollout.ts @@ -7,6 +7,7 @@ import { resolvePythinkerHome } from '@pythoughts/pythinker-code-sdk'; import { getUpdateRolloutLogFile } from '#/utils/paths'; +import { isBelowMinRequiredVersion } from './cdn'; import { selectUpdateTarget } from './select'; import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; @@ -72,6 +73,8 @@ export type PassiveUpdateReason = | 'held' /** Gated and the batch delay has elapsed: update is visible. */ | 'eligible' + /** Manifest floor: client is below minRequiredVersion, rollout bypassed. */ + | 'required' /** PYTHINKER_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ | 'experimental'; @@ -139,6 +142,19 @@ export function decidePassiveUpdateTarget( const eligibleAt = Number.isFinite(publishedAt) ? new Date(publishedAt + delaySeconds * 1000).toISOString() : null; + // A client below the manifest floor must take the update now: the staged + // delay exists for ordinary releases, not for one the client cannot skip. + // Bucket, delay and eligibleAt stay populated so the telemetry and the + // decision log still describe where the device sits in the plan. + if (isBelowMinRequiredVersion(manifest, currentVersion)) { + return { + target, + reason: 'required', + bucket, + delaySeconds, + eligibleAt, + }; + } const eligible = isRolloutEligible(manifest, deviceId, now); return { target: eligible ? target : null, diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index 61280637..caf048c1 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -40,6 +40,12 @@ export interface UpdateManifest { * manifests published before artifact addressing shipped. */ readonly platforms?: Readonly>; + /** + * Lowest version that can still work against the current services. A + * client below it must take the update without waiting for its rollout + * batch. Absent on ordinary releases. + */ + readonly minRequiredVersion?: string; } export interface UpdateCache { diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index 48e805fe..802535ee 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -80,6 +80,7 @@ export interface FooterCompaction { export type FooterUpdateState = | 'available' + | 'required' | 'downloading' | 'waiting' | 'ready' @@ -612,10 +613,12 @@ function formatUpdate(update: FooterUpdate): string | null { const version = update.version; const state = update.state; if (version === null || state === null) return null; - const base = `${state === 'available' || state === 'ready' || state === 'failed' ? '↑' : '↓'} v${version}`; + const base = `${state === 'available' || state === 'required' || state === 'ready' || state === 'failed' ? '↑' : '↓'} v${version}`; switch (state) { case 'available': return base; + case 'required': + return `${base} required`; case 'downloading': { const percent = update.percent; if (percent === null || !Number.isFinite(percent)) return base; diff --git a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts index 0c053ccf..6ad8a491 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts @@ -1,5 +1,6 @@ import { gt, valid } from 'semver'; +import { isBelowMinRequiredVersion } from '#/cli/update/cdn'; import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; import type { InstallSource, @@ -54,7 +55,16 @@ export function footerUpdateFromState( const target = selectUpdateTarget(currentVersion, cache?.latest ?? null); if (target !== null && isTargetInstallable(source, cache?.manifest ?? null)) { - return { version: target.version, state: 'available', percent: null }; + // A manifest floor above the running version labels the offer as + // required; the precedence order above still lets a download in flight + // outrank it. + return { + version: target.version, + state: isBelowMinRequiredVersion(cache?.manifest ?? null, currentVersion) + ? 'required' + : 'available', + percent: null, + }; } return { version: null, state: null, percent: null }; diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index fee1f0a1..371b27c6 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -112,6 +112,32 @@ describe('fetchUpdateManifest', () => { expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); }); + it('carries a well-formed minRequiredVersion onto the parsed manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + minRequiredVersion: '1.5.0', + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.minRequiredVersion).toBe('1.5.0'); + }); + + it('drops a malformed minRequiredVersion but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + minRequiredVersion: 'nope', + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.minRequiredVersion).toBeUndefined(); + }); + it('carries a well-formed platforms record onto the parsed manifest', async () => { const body = JSON.stringify({ version: '2.0.0', diff --git a/apps/pythinker-code/test/cli/update/rollout.test.ts b/apps/pythinker-code/test/cli/update/rollout.test.ts index 0664fad1..c88f9e78 100644 --- a/apps/pythinker-code/test/cli/update/rollout.test.ts +++ b/apps/pythinker-code/test/cli/update/rollout.test.ts @@ -246,6 +246,89 @@ describe('decidePassiveUpdateTarget', () => { delaySeconds: 43_200, }); }); + + it('returns the target with reason required while the batch is still held', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'required', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 86_400, + eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), + }); + }); + + it('returns the target with reason required even when already eligible', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 0 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'required', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 0, + }); + }); + + it('applies ordinary eligibility rules when running at minRequiredVersion', () => { + const held = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.0.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: null, reason: 'held' }); + const immediate = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 0 }], + minRequiredVersion: '1.0.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); + }); + + it('applies ordinary eligibility rules when running above minRequiredVersion', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '0.9.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: null, reason: 'held' }); + }); + + it('behaves exactly as before when the manifest declares no minRequiredVersion', () => { + const held = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ + target: null, + reason: 'held', + bucket: rolloutBucket('device-a', '2.0.0'), + }); + const immediate = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); + }); }); describe('appendRolloutDecisionLog', () => { @@ -331,6 +414,21 @@ describe('experimental flag bypass', () => { }); }); + it('still reports experimental under bypass when below minRequiredVersion', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now, true); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + it('still reports not-newer / no-latest under bypass', () => { expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ target: null, diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index 093ee995..095f748a 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -493,6 +493,11 @@ describe('footer model', () => { { version: '0.11.0', state: 'available', percent: null }, '↑ v0.11.0', ], + [ + 'required', + { version: '0.11.0', state: 'required', percent: null }, + '↑ v0.11.0 required', + ], [ 'downloading with percent', { version: '0.11.0', state: 'downloading', percent: 42 }, diff --git a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts index bd0ee7b4..bd33f483 100644 --- a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts @@ -217,6 +217,91 @@ describe('footerUpdateFromState', () => { }); }); + it('shows required when the cached manifest declares a minRequiredVersion above current', () => { + const requiredCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + minRequiredVersion: '0.10.1', + }, + }; + + expect(footerUpdateFromState(CURRENT, 'native', requiredCache, installState())).toEqual({ + version: NEWER, + state: 'required', + percent: null, + }); + }); + + it('keeps available when the declared minRequiredVersion is at or below current', () => { + const baseManifest = { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + }; + const atCurrent: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { ...baseManifest, minRequiredVersion: CURRENT }, + }; + const belowCurrent: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { ...baseManifest, minRequiredVersion: '0.9.0' }, + }; + + expect(footerUpdateFromState(CURRENT, 'native', atCurrent, installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + expect(footerUpdateFromState(CURRENT, 'native', belowCurrent, installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + }); + + it('still shows downloading when a required update is already in flight', () => { + const requiredCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + minRequiredVersion: '0.10.1', + }, + }; + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', requiredCache, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 42, + }); + }); + it('shows nothing when the cache target is not installable from this source', () => { const unavailableCache: UpdateCache = { source: 'cdn', diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index 9d21e32f..b5424059 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -73,6 +73,11 @@ async function resolvePublishedRelease(packageName) { const RELEASE_DOWNLOAD_BASE = 'https://github.com/Pythoughts-labs/pythinker-code/releases/download'; +// Set to a version string only when an older client can no longer work against +// the current services; it makes every client below it take the update without +// waiting for its rollout batch. Reset to null once that release is the floor. +const MIN_REQUIRED_VERSION = null; + /** * Resolve the per-platform native artifacts for a published version. * @@ -216,6 +221,7 @@ const platforms = await resolvePlatformArtifacts(version); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ version, publishedAt, + minRequiredVersion: MIN_REQUIRED_VERSION ?? undefined, platforms: platforms ?? undefined, rollout: [], }, null, 2)}\n`); From 1db6df582aeefb1e4187026d07ba3b8bd83212f0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:58:35 -0400 Subject: [PATCH 15/18] fix(update): tell the user why a parked update failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version parks once the failure counter hits the threshold — the background lifecycle refuses to touch it again. /update answered with a bare manual command that mentioned neither the failures nor their reason, even though the installer's own error text was sitting in lastFailure.message. That is the last piece of "feels stuck, nothing happened": nothing was running, nothing would run, and nothing said so. /update now reports the failure with its attempt count, the recorded reason and the command to run by hand. The reason is collapsed to one line and truncated, because the recorded value is up to 2 KB of installer stderr. No counter reset and no retry: re-running an install that already failed twice is what burned six minutes per launch in the reported case. --- .../src/cli/update/preflight.ts | 27 ++++- apps/pythinker-code/src/tui/commands/info.ts | 25 ++++ .../test/cli/update/preflight.test.ts | 107 +++++++++++++++++- .../tui/commands/update-preferences.test.ts | 59 ++++++++++ 4 files changed, 210 insertions(+), 8 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index 82788508..fa00557e 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -1065,6 +1065,14 @@ export type ManualUpdateResult = readonly version: string; readonly command: string; readonly source: InstallSource; + } + | { + readonly status: 'failed'; + readonly version: string; + readonly attempts: number; + readonly failedAt: string; + readonly message?: string; + readonly command: string; }; /** @@ -1131,14 +1139,23 @@ export async function startManualUpdate( readyToInstall: true, }; } - // Repeated background failures fall back to the copyable command instead of - // claiming "started" for work the background lifecycle would refuse. - if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { + // A version parks once the failure counter hits the threshold: the + // background lifecycle refuses to touch it again, so claiming "started" or + // "in-progress" would be a lie and another retry would only burn another + // launch on an install that already failed. Report the recorded failure + // and the copyable command instead. + const failure = installState.lastFailure; + if ( + failure !== null && + failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD + ) { return { - status: 'manual', + status: 'failed', version: target.version, + attempts: failure.attempts, + failedAt: failure.failedAt, + message: failure.message, command: installCommandFor(source, target.version, platform), - source, }; } diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index 3354706b..08859eef 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -237,6 +237,21 @@ export async function handleHooksCommand( ); } +/** The installer's recorded stderr tail can be ~2 KB; show one line of it. */ +const UPDATE_FAILURE_REASON_MAX_CHARS = 160; + +/** + * Collapse the recorded failure reason onto one line; a long tail is cut to + * a single sensible line and marked as truncated instead of flooding the + * transcript with the installer's full stderr. + */ +function renderUpdateFailureReason(message: string): string | undefined { + const singleLine = message.replaceAll(/\s+/gu, ' ').trim(); + if (singleLine.length === 0) return undefined; + if (singleLine.length <= UPDATE_FAILURE_REASON_MAX_CHARS) return singleLine; + return `${singleLine.slice(0, UPDATE_FAILURE_REASON_MAX_CHARS)}… (truncated)`; +} + export async function handleUpdateCommand( host: SlashCommandHost, args: string, @@ -286,6 +301,16 @@ export async function handleUpdateCommand( `Run: ${result.command}`, ); return; + case 'failed': { + const reason = + result.message === undefined ? undefined : renderUpdateFailureReason(result.message); + host.showError( + `Update to v${result.version} failed after ${result.attempts} attempts.` + + (reason === undefined ? '' : `\nReason: ${reason}`) + + `\nTo update manually, run: ${result.command}`, + ); + return; + } case 'check-failed': host.showError(`Update check failed: ${result.message}`); return; diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 27a03053..2e6b7acb 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -2471,15 +2471,116 @@ describe('startManualUpdate', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('falls back to the manual command after repeated background failures', async () => { + it('reports a parked version as failed with the recorded attempts and reason', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { version: '0.5.0', failedAt: new Date().toISOString(), attempts: 2 }, + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + operation: 'install', + message: 'npm exited with code 1', + }, + })); + + const result = await startManualUpdate('0.4.0'); + expect(result).toEqual({ + status: 'failed', + version: '0.5.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + message: 'npm exited with code 1', + command: 'npm install -g @pythoughts/pythinker-code@0.5.0', + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('still attempts the install one failure below the parked threshold', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 1, + message: 'npm exited with code 1', + }, + })); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('still attempts the install when the parked failures belong to another version', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.4.1', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + message: 'npm exited with code 1', + }, + })); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('reports in-progress when a fresh install runs despite a parked failure', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.5.0', source: 'npm-global', startedAt: new Date().toISOString() }, + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + message: 'npm exited with code 1', + }, + })); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.5.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('omits the reason when the recorded failure carries none', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + }, })); const result = await startManualUpdate('0.4.0'); - expect(result.status).toBe('manual'); + expect(result).toEqual({ + status: 'failed', + version: '0.5.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + command: 'npm install -g @pythoughts/pythinker-code@0.5.0', + }); expect(mocks.spawn).not.toHaveBeenCalled(); }); diff --git a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts index 7197efd7..fb688c83 100644 --- a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts +++ b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts @@ -308,6 +308,65 @@ describe('update command', () => { 'The running install of v0.10.0 finishes first; v0.11.0 installs after the next start.', ); }); + + it('reports a parked version as failed with the recorded reason', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + message: 'npm exited with code 1', + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 2 attempts.\n' + + 'Reason: npm exited with code 1\n' + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); + + it('reports a parked version as failed without a reason line', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 2 attempts.\n' + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); + + it('truncates a very long recorded reason to one line and marks it truncated', async () => { + const host = makeHost(); + const longReason = `npm failed: ${'x'.repeat(5000)}`; + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 3, + failedAt: '2026-08-05T08:00:00.000Z', + message: longReason, + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 3 attempts.\n' + + `Reason: ${longReason.slice(0, 160)}… (truncated)\n` + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); }); describe('permission rule commands', () => { From c67f64eb3c9dc719dac78672c838c20ee6b043ca Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 14:59:56 -0400 Subject: [PATCH 16/18] docs(changeset): describe the update-flow changes --- .changeset/update-flow-reliability.md | 5 +++++ .changeset/update-minimum-supported-version.md | 5 +++++ .changeset/update-status-under-the-prompt.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/update-flow-reliability.md create mode 100644 .changeset/update-minimum-supported-version.md create mode 100644 .changeset/update-status-under-the-prompt.md diff --git a/.changeset/update-flow-reliability.md b/.changeset/update-flow-reliability.md new file mode 100644 index 00000000..a9a6f343 --- /dev/null +++ b/.changeset/update-flow-reliability.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. diff --git a/.changeset/update-minimum-supported-version.md b/.changeset/update-minimum-supported-version.md new file mode 100644 index 00000000..45fa57a6 --- /dev/null +++ b/.changeset/update-minimum-supported-version.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Let a release declare a minimum supported version, so a client below it is offered the update without waiting for its staged rollout batch. diff --git a/.changeset/update-status-under-the-prompt.md b/.changeset/update-status-under-the-prompt.md new file mode 100644 index 00000000..8d02e747 --- /dev/null +++ b/.changeset/update-status-under-the-prompt.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed. From 62ffdfdb98756921349cf9c62895b1a43963b9c2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 15:15:07 -0400 Subject: [PATCH 17/18] test(update): prove the footer poll runs and pin the installer's real bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two seams were verified only by inference. Nothing in the suite drove startUpdateStatusPolling through pollUpdateStatus to dispatchFooter, so the status-row chip could have been dead code with every test still passing — a startup test now drives the real poller against real state files and asserts the rendered row. And the line reader had only ever parsed hand-written fixtures, so it now pins the exact bytes a real install.sh run emitted, which also records the throttle's real behaviour: three lines in one chunk write the first update and the terminal one, not the middle. --- .../test/cli/update/preflight.test.ts | 41 +++++++++ .../test/tui/pythinker-tui-startup.test.ts | 89 ++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 2e6b7acb..6eea731b 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -2058,6 +2058,47 @@ describe('runUpdatePreflight', () => { })); }); + /** + * The exact bytes a real `install.sh` run emitted while downloading the + * 0.9.2 release, captured from its stderr. Pinning them here means the + * emitter and this parser cannot drift apart silently. + */ + it('parses the bytes a real installer run actually emitted', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 0, + 'progress: state=downloading percent=0 transferred=0 total=55795679\n' + + 'progress: state=downloading percent=49 transferred=27103232 total=55795679\n' + + 'progress: state=done transferred=55795679\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + // All three lines arrive in one chunk, so the 2-second write throttle + // keeps the first downloading update and drops the second; the terminal + // state always bypasses the throttle. + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + percent: 0, + transferred: 0, + total: 55_795_679, + }), + }), + })); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'done', transferred: 55_795_679 }), + }), + })); + }); + it('keeps progress lines out of the failure tail and ordinary stderr lines in it', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 721b898c..686a5406 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -78,6 +78,11 @@ interface RuntimeStateDriver extends StartupDriver { closeSession(reason: string): Promise; } +interface UpdatePollDriver extends StartupDriver { + startUpdateStatusPolling(): void; + stopUpdateStatusPolling(): void; +} + interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } @@ -2008,3 +2013,85 @@ describe('startup feature parity baseline', () => { ).toBe(true); }); }); + +describe('footer update status poll', () => { + /** + * The poll is the only thing that puts an update into the footer, and it is + * wired from `finishStartup` — so nothing else in this suite would notice if + * it stopped dispatching. Drive it against real state files. + */ + it('dispatches availability and then live progress into the status row', async () => { + const home = mkdtempSync(join(tmpdir(), 'pk-footer-update-')); + const previousHome = process.env['PYTHINKER_CODE_HOME']; + process.env['PYTHINKER_CODE_HOME'] = home; + const updates = join(home, 'updates'); + mkdirSync(updates, { recursive: true }); + const manifest = { + version: '9.9.9', + publishedAt: '2026-08-07T00:00:00.000Z', + rollout: [], + }; + writeFileSync( + join(updates, 'latest.json'), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-08-07T00:00:00.000Z', + latest: '9.9.9', + manifest, + }), + ); + + const presentation = new RecordingPresentation(); + const driver = new PythinkerTUI( + makeHarness() as never, + makeStartupInput(), + presentation, + ) as unknown as UpdatePollDriver; + + try { + driver.startUpdateStatusPolling(); + await vi.waitFor( + () => { + expect(footerStatusItems(presentation.footerModels.at(-1))).toContain('↑ v9.9.9'); + }, + { timeout: 10_000, interval: 50 }, + ); + + writeFileSync( + join(updates, 'install.json'), + JSON.stringify({ + active: { + version: '9.9.9', + source: 'native', + startedAt: new Date().toISOString(), + pid: process.pid, + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: new Date().toISOString(), + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }), + ); + await vi.waitFor( + () => { + expect(footerStatusItems(presentation.footerModels.at(-1))).toContain( + '↓ v9.9.9 ▰▰▰▱▱▱▱▱ 42%', + ); + }, + { timeout: 10_000, interval: 50 }, + ); + } finally { + driver.stopUpdateStatusPolling(); + driver.state.footer.dispose(); + if (previousHome === undefined) delete process.env['PYTHINKER_CODE_HOME']; + else process.env['PYTHINKER_CODE_HOME'] = previousHome; + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); +}); From 4c1f9f3bd1fe0d6a044427deeb263da85d74fbf8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 16:16:22 -0400 Subject: [PATCH 18/18] fix(update): stop a slow progress write from erasing the install outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state file is a temp file plus rename, so the last rename wins. Progress writes were fire-and-forget, and the installer's terminal state=done line bypasses the write throttle just as the child exits — so on the ordinary success path that write could land after the outcome, restoring active and dropping lastSuccess. The next launch read that as an abandoned install and recorded a failure for a version that had installed cleanly, which parks the version at two attempts. Progress writes now run on one chain, new ones stop once the outcome is settled, and the finalizer drains the chain before writing. Removing the drain fails the new test. Also from review: the CDN reject cases named the field they expect instead of matching any non-empty message, the lock-ordering test now reaches the prompt before asserting no lock was taken (it previously asserted before the first await, so it held wherever the acquisition sat), and two lint nits this branch introduced. --- .../src/cli/update/preflight.ts | 32 +++++++--- .../test/cli/update/cdn.test.ts | 18 +++--- .../test/cli/update/preflight.test.ts | 61 ++++++++++++++++++- .../test/tui/pythinker-tui-startup.test.ts | 6 +- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index fa00557e..c3c68d81 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -841,6 +841,15 @@ async function startBackgroundInstall( let ready = false; let settled = false; let pendingOutcome: { succeeded: boolean; reason: string } | undefined; + // Progress writes are fire-and-forget, and the state file is written as a + // temp file plus rename — so the last rename wins. The installer's terminal + // `state=done` line bypasses the throttle and writes just as the child + // exits, so without ordering that write can land *after* the outcome write + // and restore `active` while dropping `lastSuccess`. The next launch reads + // that as an abandoned install and records a failure for a version that + // installed cleanly. One chain keeps the writes ordered and gives `finish` + // something to drain. + let progressWrites: Promise = Promise.resolve(); const finish = async (succeeded: boolean, reason: string): Promise => { if (!ready) { @@ -849,6 +858,9 @@ async function startBackgroundInstall( } if (settled) return; settled = true; + // `settled` already stops new progress writes; drain the ones in flight so + // none of them renames over the outcome below. + await progressWrites; const attempts = failureAttemptsFor(startedState, target, 'install') + 1; const stderrTail = readStderrTail(); const message = stderrTail === undefined ? reason : `${reason}: ${stderrTail}`; @@ -917,6 +929,9 @@ async function startBackgroundInstall( }); let lastProgressWriteAt = 0; const recordInstallerProgress = (update: UpdateInstallProgress): void => { + // Once the outcome is being written, progress is history: writing it would + // undo the terminal record. + if (settled) return; // Terminal states always persist; intermediate ones at most every 2s. const terminal = update.state === 'done' || update.state === 'failed'; if ( @@ -936,14 +951,17 @@ async function startBackgroundInstall( progress: update, }, }; - writeUpdateInstallState(nextState).catch((error) => { - // A progress write is best-effort; it must never reject the spawn path. - logUpdateWarn(logger, 'could not record installer progress', { - targetVersion: target.version, - source, - error: formatErrorMessage(error), + progressWrites = progressWrites + .then(() => writeUpdateInstallState(nextState)) + .catch((error) => { + // A progress write is best-effort; it must never reject the spawn path + // and must not break the chain for the writes queued behind it. + logUpdateWarn(logger, 'could not record installer progress', { + targetVersion: target.version, + source, + error: formatErrorMessage(error), + }); }); - }); }; const readStderrTail = captureStderrTail(child, recordInstallerProgress); child.once('error', (error) => { void finish(false, formatErrorMessage(error)); }); diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index 371b27c6..5bbaefe4 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -173,18 +173,18 @@ describe('fetchUpdateManifest', () => { // so reading it after a bad manifest would report an unverifiable target as // verified. Every one of these must reject and leave the cached answer alone. const rejectCases: ReadonlyArray = [ - ['latest.json is missing (HTTP 404)', { status: 404 }, /HTTP 404/], - ['latest.json fetch throws', new Error('network down'), /network down/], - ['body is not valid JSON', { body: 'not json {' }, /JSON/i], + ['latest.json is missing (HTTP 404)', { status: 404 }, /HTTP 404/u], + ['latest.json fetch throws', new Error('network down'), /network down/u], + ['body is not valid JSON', { body: 'not json {' }, /JSON/iu], [ 'version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }, - /invalid semver/, + /invalid semver/u, ], [ 'publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }, - /invalid timestamp/, + /invalid timestamp/u, ], [ 'a batch percent is out of range', @@ -195,7 +195,9 @@ describe('fetchUpdateManifest', () => { rollout: [{ percent: 150, delaySeconds: 0 }], }), }, - /./, + // Name the field: `/./` matched any non-empty message, so a JSON.parse + // failure would have satisfied it just as well as the schema rejection. + /percent/u, ], [ 'a batch delay is negative', @@ -206,7 +208,7 @@ describe('fetchUpdateManifest', () => { rollout: [{ percent: 100, delaySeconds: -1 }], }), }, - /./, + /delaySeconds/u, ], ]; @@ -229,7 +231,7 @@ describe('fetchUpdateManifest', () => { }) as unknown as typeof fetch; const result = fetchUpdateManifest(f); - const expectation = expect(result).rejects.toThrow(/aborted/); + const expectation = expect(result).rejects.toThrow(/aborted/u); await vi.advanceTimersByTimeAsync(3_000); await expectation; diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 6eea731b..014fd4fc 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -388,6 +388,14 @@ function progressActiveStates(): unknown[] { .filter((state) => state !== undefined && state !== null && state.active?.progress !== undefined); } +/** The terminal success records written by the finalizer, in order. */ +function successOutcomeStates(): unknown[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null + && state.active === null && state.lastSuccess !== undefined && state.lastSuccess !== null); +} + async function flushBackgroundInstall(): Promise { await new Promise((resolve) => { setImmediate(resolve); @@ -961,6 +969,13 @@ describe('runUpdatePreflight', () => { const running = runUpdatePreflight('0.4.0', options); + // Let the flow actually reach the prompt first. Asserting straight after the + // call was vacuous: nothing had run past the first await, so "no lock yet" + // held wherever the acquisition sat, and the ordering claim in the test name + // went unchecked. + await vi.waitFor(() => { + expect(mocks.promptForInstallChoice).toHaveBeenCalled(); + }); expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); resolvePrompt?.('install'); await expect(running).resolves.toBe('exit'); @@ -1178,7 +1193,7 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), + expect.stringMatching(/^npm(\.cmd)?$/u), ['install', '-g', '@pythoughts/pythinker-code@0.6.0'], { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, ); @@ -2099,6 +2114,50 @@ describe('runUpdatePreflight', () => { })); }); + /** + * The state file is written as a temp file plus rename, so the last rename + * wins. The installer's terminal `state=done` line writes just as the child + * exits, so an unawaited progress write can rename over the outcome — + * restoring `active` and dropping `lastSuccess`. The next launch reads that + * as an abandoned install and records a failure for a version that + * installed cleanly, which at two attempts parks it for good. + */ + it('never lets a slow progress write rename over the install outcome', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + // Hold the progress write open; every other write settles at once. + let releaseProgressWrite: (() => void) | undefined; + mocks.writeUpdateInstallState.mockImplementation( + (state: { active?: { progress?: unknown } | null }) => ( + state.active?.progress === undefined + ? Promise.resolve() + : new Promise((resolve) => { releaseProgressWrite = resolve; }) + ), + ); + mockSpawnExitWithStderr(0, 'progress: state=done transferred=55795679\n'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + // The progress write is still in flight, so the outcome must not be out yet. + expect(progressActiveStates()).toHaveLength(1); + expect(successOutcomeStates()).toEqual([]); + + releaseProgressWrite?.(); + await flushBackgroundInstall(); + await flushBackgroundInstall(); + + expect(successOutcomeStates()).toHaveLength(1); + // The outcome is the last thing written, so it survives on disk. + expect(mocks.writeUpdateInstallState.mock.calls.at(-1)?.[0]).toMatchObject({ + active: null, + lastSuccess: expect.objectContaining({ version: '0.5.0' }), + }); + }); + it('keeps progress lines out of the failure tail and ordinary stderr lines in it', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 686a5406..d73bea0a 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -2022,8 +2022,7 @@ describe('footer update status poll', () => { */ it('dispatches availability and then live progress into the status row', async () => { const home = mkdtempSync(join(tmpdir(), 'pk-footer-update-')); - const previousHome = process.env['PYTHINKER_CODE_HOME']; - process.env['PYTHINKER_CODE_HOME'] = home; + vi.stubEnv('PYTHINKER_CODE_HOME', home); const updates = join(home, 'updates'); mkdirSync(updates, { recursive: true }); const manifest = { @@ -2089,8 +2088,7 @@ describe('footer update status poll', () => { } finally { driver.stopUpdateStatusPolling(); driver.state.footer.dispose(); - if (previousHome === undefined) delete process.env['PYTHINKER_CODE_HOME']; - else process.env['PYTHINKER_CODE_HOME'] = previousHome; + vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); } }, 30_000);