Skip to content

fix(update): make the update flow report the truth and stop wedging - #38

Merged
elkaix merged 19 commits into
mainfrom
fix/update-flow-hardening
Aug 7, 2026
Merged

fix(update): make the update flow report the truth and stop wedging#38
elkaix merged 19 commits into
mainfrom
fix/update-flow-hardening

Conversation

@elkaix

@elkaix elkaix commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Related Issue

No issue — reported directly. The problem is described below.

Problem

A user's terminal showed ↑ Update available — v0.11.0 on the banner while /update answered
Update to v0.10.0 already in progress, and nothing ever changed. Investigating it found a whole class
of defects behind that one screen:

  • The manifest advertised a version, so the client had to guess a GitHub asset URL and poll for it.
    When the guess was wrong it polled for ~6 minutes, on every launch.
  • No installer network call had a timeout, so one hung request wedged every update path with no expiry.
  • A live pid held an install lease forever, with no ceiling, so a recycled pid wedged updates permanently.
  • The background installer's outcome was written by the parent process, and the product tells the user to
    close that terminal — so failures went unrecorded, the attempt counter never advanced, and a version
    that could not succeed was retried on every launch.
  • install.sh rendered a progress bar only on a TTY; in the background it blocked in a single silent
    curl and reported nothing at all, which is why an update in flight looked identical to a wedged one.
  • Two foreground install paths ignored the lock entirely and could run while a detached installer was
    writing the same executable.
  • The banner chip and /update read different files, which is how they came to disagree.

What changed

Fourteen commits, each with tests, in the order they were verified. Grouped:

The channel tells the truth. latest.json now carries the resolved per-platform artifact
(url + sha256), copied from the release's own native manifest, and a native client requires an entry
for its platform before it will advertise or install anything. npm-family sources are exempt — the
published version is their artifact — and that exemption is the case the tests protect hardest.
minRequiredVersion lets a release bypass the staged rollout when a client cannot skip it.

Progress is visible. The installer emits newline-terminated machine progress on stderr — the stream
the parent already pipes — and the parent records it on the install record. The footer status row under
the prompt renders ↑ v0.11.0, ↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%, ↑ v0.11.0 restart to apply, reusing the
context gauge's own bar glyphs. An unknown download size drops the bar rather than inventing a
percentage.

Nothing wedges. Every installer fetch has a connect bound, a per-attempt ceiling and a stall guard
(--retry is deliberately absent: it resets --max-time). One lease.ts states the lease rule once,
with a ceiling on live pids. Startup reconciles an abandoned install into a recorded failure so a doomed
version parks. Both foreground paths hold the lock and write their outcome.

It says what is happening. /update reports the installing version and the newer target that
follows, and reports a parked version's attempt count and recorded reason instead of a bare command.

Deletions rather than additions where the shape allowed: the plain-text /latest fallback (it carried no
platform data, so it reported an unverifiable target as verified), the duplicate install.sh/install.ps1
under apps/site/public/, the duplicated isProcessRunning and four lease constants, the banner's update
chip and its per-frame readFileSync, and one of the two update decisions per launch.

Two scope decisions worth flagging: killing an in-flight installer to switch targets is not
implemented — the lease ceiling and reconciliation make the wait finite, and honest reporting fixes what
the user saw. And a writability precheck was dropped in favour of surfacing the installer's own recorded
error, which covers EACCES, network faults and disk-full alike.

Base is fix/release-cdn-version-truth (#37) because the manifest generator work stacks on it.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

Summary by CodeRabbit

  • New Features

    • Update status now appears beneath the prompt, including availability, download progress, required updates, waiting, and failures.
    • Updates can enforce a minimum supported version.
    • Platform-specific update availability is validated before installation.
  • Bug Fixes

    • Prevented conflicting or abandoned installations from blocking future updates.
    • Added clearer failure messages and retry information.
    • Installers now use connection, metadata, and download timeouts to avoid hanging.

elkaix added 16 commits August 7, 2026 10:39
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.
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.
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.
…latform

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.
…ller

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.
…rompt bar

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.
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.
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.
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.
`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.
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.
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.
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.
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.
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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 720b33cc-b043-4292-a670-12936b82dd7b

📥 Commits

Reviewing files that changed from the base of the PR and between e486a10 and 4c1f9f3.

📒 Files selected for processing (4)
  • apps/pythinker-code/src/cli/update/preflight.ts
  • apps/pythinker-code/test/cli/update/cdn.test.ts
  • apps/pythinker-code/test/cli/update/preflight.test.ts
  • apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts
📝 Walkthrough

Walkthrough

The update flow now uses validated CDN manifests, platform-specific artifact checks, minimum-version enforcement, shared install leases, persisted progress and failures, installer network timeouts, and footer-based TUI status reporting.

Changes

Update flow reliability

Layer / File(s) Summary
Manifest validation and release metadata
apps/pythinker-code/src/cli/update/{types,cdn,refresh,rollout,select}.ts, apps/site/scripts/build-cdn.mjs, apps/pythinker-code/test/cli/update/*
Manifests now include optional platform artifacts and minimum supported versions. CDN fetching uses latest.json only. Native targets require a matching artifact. Clients below the minimum version receive a required update.
Install leases, state, and installation orchestration
apps/pythinker-code/src/cli/update/{lease,install-lock,install-state,preflight}.ts, apps/pythinker-code/src/cli/sub/upgrade.ts, apps/pythinker-code/test/cli/{update/*,upgrade.test.ts}
Install leases use shared freshness rules. Active installs record progress. Abandoned installs become failures. Foreground and explicit installs acquire locks, persist outcomes, and release locks during cleanup.
Installer timeouts and progress events
apps/pythinker-web/public/install.{sh,ps1}, apps/site/scripts/build-cdn.mjs
Installers apply connection, metadata, archive, and stall timeouts. Shell downloads emit structured progress, failure, completion, and retry events.
Prompt update status and TUI polling
apps/pythinker-code/src/tui/{pythinker-tui.ts,commands/info.ts}, apps/pythinker-code/src/tui/runtime/footer/*, apps/pythinker-code/src/tui/components/chrome/*, apps/pythinker-code/test/tui/*
The welcome-banner update chip was removed. The TUI polls update cache and install state, then renders availability, required, download, waiting, ready, and failed states below the prompt.
Release metadata
.changeset/*
Changesets record patch and minor releases for update reliability, minimum-version enforcement, and prompt status reporting.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TUI
  participant UpdateCache
  participant InstallState
  participant Footer
  TUI->>UpdateCache: poll cached manifest
  TUI->>InstallState: poll persisted install state
  TUI->>Footer: derive update status
  Footer-->>TUI: render availability or progress below prompt
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses the required fix prefix, stays within 72 characters, uses imperative wording, and accurately summarizes the update-flow reliability changes.
Description check ✅ Passed The description includes all required sections, explains the problem and implementation, and completes the checklist with tests and changeset details.

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@pythoughts/pythinker-code@4c1f9f3
npx https://pkg.pr.new/@pythoughts/pythinker-code@4c1f9f3

commit: 4c1f9f3

… bytes

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.
@elkaix
elkaix deleted the branch main August 7, 2026 19:48
@elkaix elkaix closed this Aug 7, 2026
@elkaix elkaix reopened this Aug 7, 2026
@elkaix
elkaix changed the base branch from fix/release-cdn-version-truth to main August 7, 2026 19:49
Resolves apps/site/scripts/build-cdn.mjs by keeping both sides: main's
RELEASE_VERSION guard and strict publishedAt from the #37 review, plus this
branch's resolvePlatformArtifacts, MIN_REQUIRED_VERSION and the manifest
fields that name each platform's artifact.

Verified by running the merged script: it writes 0.9.2 with npm's own publish
time and six platform entries with real checksums.
@elkaix

elkaix commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (9)
apps/pythinker-code/test/tui/commands/update-preferences.test.ts (1)

350-369: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a multi-line failure reason.

renderUpdateFailureReason exists to collapse an installer stderr tail onto one line. The current long-reason fixture is a single line of x characters, so the \s+ collapse is never exercised. A real recorded message contains newlines. Add a short multi-line fixture that stays under 160 characters, so the test asserts the collapse without also asserting truncation.

💚 Proposed additional test
+  it('collapses a multi-line recorded reason onto one line', async () => {
+    const host = makeHost();
+    mocks.startManualUpdate.mockResolvedValue({
+      status: 'failed',
+      version: '0.10.0',
+      attempts: 2,
+      failedAt: '2026-08-05T08:00:00.000Z',
+      message: 'npm ERR! code EACCES\nnpm ERR! syscall mkdir\n\n  npm ERR! path /usr/local/lib  ',
+      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 ERR! code EACCES npm ERR! syscall mkdir npm ERR! path /usr/local/lib\n' +
+        'To update manually, run: npm install -g `@pythoughts/pythinker-code`@0.10.0',
+    );
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/test/tui/commands/update-preferences.test.ts` around
lines 350 - 369, Add a separate test case near the existing long-reason test in
the update command tests using a short failure message containing newline
characters and remaining under 160 characters. Mock startManualUpdate with that
message, invoke handleUpdateCommand, and assert showError receives the reason
collapsed to a single line without the truncation suffix.

Source: Path instructions

apps/pythinker-code/src/tui/runtime/footer/footer-model.ts (1)

472-474: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The update item is the only status item with no statusLine gate.

Every other item in selectStatusItems is behind a flag: showModel, showContextBar, showGit, showModes, showElapsed, showGoal, showBackgroundTasks. The update item is pushed unconditionally and takes the first position, so a user who trimmed the status line cannot suppress it. Add a flag to StatusLineConfig and gate the item, or confirm that always-on is the intended product decision for the required/failed states.

♻️ Proposed gate
   const items: string[] = [];
-  const update = formatUpdate(state.update);
-  if (update !== null) items.push(update);
+  if (statusLine.showUpdate) {
+    const update = formatUpdate(state.update);
+    if (update !== null) items.push(update);
+  }

Add showUpdate to StatusLineConfig and default it to true in DEFAULT_STATUS_LINE_CONFIG so existing configurations keep the new indicator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/tui/runtime/footer/footer-model.ts` around lines 472
- 474, Update the status-line configuration to add a showUpdate flag, defaulting
it to true in DEFAULT_STATUS_LINE_CONFIG, and gate the update item in
selectStatusItems before pushing the formatted value. Preserve existing update
formatting and behavior when the flag is enabled.
apps/pythinker-code/src/cli/update/cdn.ts (1)

50-54: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider dropping only the malformed platform entry, not the whole record.

z.record(...).catch(undefined) fails as a unit. One bad entry erases every valid entry, and manifestArtifactAvailability then reports 'available' for every platform. The documented intent is to keep the manifest usable, but a per-entry filter keeps the artifact gate working for the platforms that did parse.

♻️ Proposed per-entry filter
   platforms: z
-    .record(z.string(), UpdateManifestPlatformSchema)
-    .readonly()
+    .record(z.string(), z.unknown())
+    .transform((raw) => {
+      const entries = Object.entries(raw).flatMap(([key, value]) => {
+        const parsed = UpdateManifestPlatformSchema.safeParse(value);
+        return parsed.success ? [[key, parsed.data] as const] : [];
+      });
+      return entries.length === 0 ? undefined : Object.freeze(Object.fromEntries(entries));
+    })
     .optional()
     .catch(undefined),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/cli/update/cdn.ts` around lines 50 - 54, Update the
platforms schema in UpdateManifestPlatformSchema so malformed individual
platform entries are discarded while valid entries remain available, instead of
allowing the record-level catch to replace the entire record with undefined.
Preserve the optional behavior for a missing platforms field and ensure
manifestArtifactAvailability can still evaluate successfully parsed platforms.
apps/pythinker-code/test/cli/upgrade.test.ts (1)

365-365: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the refusal message, not just that stderr is non-empty.

expect(stderr.join('')).not.toBe('') passes for any stderr output. The test would still pass if the code wrote an unrelated error and never reached refuseForegroundInstall. The sibling test at Line 346 asserts specific content.

💚 Proposed stronger assertion
-    expect(stderr.join('')).not.toBe('');
+    expect(stderr.join('')).toContain('another update install is already in progress');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/test/cli/upgrade.test.ts` at line 365, Strengthen the
upgrade refusal test by replacing the generic non-empty stderr assertion with an
assertion that stderr contains the specific refusal message emitted by
refuseForegroundInstall. Match the sibling test’s content-based assertion style
so unrelated stderr output cannot satisfy the test.
apps/pythinker-code/src/cli/update/preflight.ts (2)

626-639: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Bound the partial line buffer.

partial accumulates every byte that arrives without a newline. The installers emit newline-terminated lines, so the practical growth is zero. A malfunctioning installer that writes a long unterminated stream to stderr would grow this string without limit for the whole download.

Cap partial at the tail window so the reader stays allocation-bounded regardless of what the child writes.

♻️ Proposed bound on the partial buffer
   stream.on('data', (chunk: string) => {
     const lines = (partial + chunk).split('\n');
     partial = lines.pop() ?? '';
+    // A child that never emits a newline must not grow this buffer forever.
+    if (partial.length > INSTALLER_STDERR_TAIL_CHARS) {
+      partial = partial.slice(-INSTALLER_STDERR_TAIL_CHARS);
+    }
     for (const line of lines) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/cli/update/preflight.ts` around lines 626 - 639,
Bound the unterminated-line buffer in the stream data handler by truncating
`partial` to at most `INSTALLER_STDERR_TAIL_CHARS` after updating it from
`lines.pop()`. Preserve the existing newline splitting and progress parsing
behavior while ensuring `partial` cannot grow beyond the tail window.

1252-1252: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reconciliation makes the inferred-success branch in showPendingBackgroundInstallNotice unreachable.

reconcileAbandonedInstall runs before showPendingBackgroundInstallNotice and clears every active record that is not a fresh lease. The notice function's second branch requires active !== null and !hasFreshActiveInstall(state) at Lines 410-415. After reconciliation, no state can satisfy both conditions, so Lines 416-441 never execute.

The renamed test at Line 1760 of apps/pythinker-code/test/cli/update/preflight.test.ts confirms the new behavior: a stale active record now produces a recorded failure, not an inferred success notice.

Remove the dead branch so the notice function states only the reachable rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/cli/update/preflight.ts` at line 1252, Remove the
unreachable inferred-success branch from showPendingBackgroundInstallNotice,
including the active-record and !hasFreshActiveInstall(state) path. Keep only
the reachable notice behavior after reconcileAbandonedInstall clears stale
active records, and preserve the existing recorded-failure handling for stale
installs.
apps/pythinker-code/src/cli/sub/upgrade.ts (2)

104-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

upgrade_command_no_update cannot distinguish "up to date" from "no platform build".

This branch emits the same event and the same payload as the genuine up-to-date exit at Lines 93-95. The payload carries only current_version. Telemetry therefore cannot measure how often a published release has no artifact for a user's platform, which is exactly the failure this PR adds a gate for.

Add a discriminating field so the two exits stay separable.

♻️ Proposed telemetry discriminator
   if (!isTargetInstallable(source, cache.manifest)) {
     trackUpgradeEvent(deps.track, 'upgrade_command_no_update', {
       current_version: currentVersion,
+      target_version: target.version,
+      reason: 'no_platform_artifact',
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/cli/sub/upgrade.ts` around lines 104 - 117, Update
the isTargetInstallable branch in the upgrade flow so its
upgrade_command_no_update telemetry payload includes a discriminator identifying
the missing platform artifact case, while leaving the genuine up-to-date event
payload unchanged.

184-218: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The terminal install-state records are built inline at two call sites. The foreground upgrade command and the preflight prompt path each construct the same success record and the same failure record by hand. Both must match UpdateInstallStateSchema in install-state.ts and must match each other, so a change in one file diverges silently from the other. Extract the two builders into install-state.ts, beside the schema they must satisfy.

  • apps/pythinker-code/src/cli/sub/upgrade.ts#L184-L218: replace the inline success and failure objects with the shared builders, and drop the local nowIso at Lines 264-266 that duplicates the one in preflight.ts.
  • apps/pythinker-code/src/cli/update/preflight.ts#L1378-L1402: replace the inline success and failure objects in runUpdatePreflight with the same shared builders.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-code/src/cli/sub/upgrade.ts` around lines 184 - 218, Extract
shared success and failure install-state builders beside
UpdateInstallStateSchema in apps/pythinker-code/src/cli/update/install-state.ts,
ensuring both builders produce schema-valid records with matching fields and
behavior. In apps/pythinker-code/src/cli/sub/upgrade.ts lines 184-218 and
apps/pythinker-code/src/cli/update/preflight.ts lines 1378-1402, replace the
duplicated inline records with these builders; also remove the local nowIso at
lines 264-266 in upgrade.ts and reuse the existing preflight.ts helper.
apps/pythinker-web/public/install.sh (1)

654-656: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use a portable poll interval in both download loops.

BusyBox fractional sleep support depends on FEATURE_FLOAT_SLEEP. When sleep 0.12 fails, the surrounding conditional call suppresses Bash errexit, so the loop spins until the download ends. Probe fractional support once and fall back to 1. Derive the i - last_emit_i >= 9 threshold from the selected interval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/pythinker-web/public/install.sh` around lines 654 - 656, Update both
download loops in install.sh to probe fractional sleep support once, select
either 0.12 or a 1-second fallback, and use that interval for sleeping so failed
fractional sleeps cannot cause a busy loop. Replace the hard-coded i -
last_emit_i >= 9 emission threshold with a value derived from the selected
interval, keeping progress timing consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/pythinker-code/src/cli/update/preflight.ts`:
- Around line 918-948: Update recordInstallerProgress and the surrounding
installer lifecycle to track all in-flight progress-write promises, ignore new
progress after the install is settled, and await/drain every pending write
before writing the terminal state. Preserve best-effort error logging for each
write and ensure the terminal active:null/lastSuccess record is written only
after all progress writes complete. Add a regression test that delays
overlapping progress writes and verifies the terminal state cannot be
overwritten by stale progress.

In `@apps/pythinker-code/test/cli/update/cdn.test.ts`:
- Around line 175-218: Update the regex literals in the rejectCases tests and
related assertions to use the Unicode flag, including the existing HTTP,
network, JSON, semver, and timestamp patterns. Replace the broad any-character
expectations for invalid rollout percent and delaySeconds with patterns matching
their respective field names. First verify Zod 4 includes each field path in the
rejection message, then assert those paths.

In `@apps/pythinker-code/test/cli/update/preflight.test.ts`:
- Line 1181: Update the regular expression in the affected assertion of the
preflight test to include the Unicode flag, matching the sibling npm assertions
and satisfying require-unicode-regexp.
- Around line 962-967: Update the test around runUpdatePreflight so execution is
allowed to reach and suspend at the prompt before asserting
tryAcquireUpdateInstallLock has not been called. Use the existing prompt
mock/control flow to wait for the prompt boundary, then retain the pre-prompt
lock assertion, resolve the prompt, and verify the lock call and final result as
before.

In `@apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts`:
- Around line 2024-2026: Update the test setup around PYTHINKER_CODE_HOME to
save the original process.env object, replace process.env with a copied
environment containing the temporary home, and restore the saved object
unconditionally in the finally block. Remove the conditional cleanup branch in
the affected test.

---

Nitpick comments:
In `@apps/pythinker-code/src/cli/sub/upgrade.ts`:
- Around line 104-117: Update the isTargetInstallable branch in the upgrade flow
so its upgrade_command_no_update telemetry payload includes a discriminator
identifying the missing platform artifact case, while leaving the genuine
up-to-date event payload unchanged.
- Around line 184-218: Extract shared success and failure install-state builders
beside UpdateInstallStateSchema in
apps/pythinker-code/src/cli/update/install-state.ts, ensuring both builders
produce schema-valid records with matching fields and behavior. In
apps/pythinker-code/src/cli/sub/upgrade.ts lines 184-218 and
apps/pythinker-code/src/cli/update/preflight.ts lines 1378-1402, replace the
duplicated inline records with these builders; also remove the local nowIso at
lines 264-266 in upgrade.ts and reuse the existing preflight.ts helper.

In `@apps/pythinker-code/src/cli/update/cdn.ts`:
- Around line 50-54: Update the platforms schema in UpdateManifestPlatformSchema
so malformed individual platform entries are discarded while valid entries
remain available, instead of allowing the record-level catch to replace the
entire record with undefined. Preserve the optional behavior for a missing
platforms field and ensure manifestArtifactAvailability can still evaluate
successfully parsed platforms.

In `@apps/pythinker-code/src/cli/update/preflight.ts`:
- Around line 626-639: Bound the unterminated-line buffer in the stream data
handler by truncating `partial` to at most `INSTALLER_STDERR_TAIL_CHARS` after
updating it from `lines.pop()`. Preserve the existing newline splitting and
progress parsing behavior while ensuring `partial` cannot grow beyond the tail
window.
- Line 1252: Remove the unreachable inferred-success branch from
showPendingBackgroundInstallNotice, including the active-record and
!hasFreshActiveInstall(state) path. Keep only the reachable notice behavior
after reconcileAbandonedInstall clears stale active records, and preserve the
existing recorded-failure handling for stale installs.

In `@apps/pythinker-code/src/tui/runtime/footer/footer-model.ts`:
- Around line 472-474: Update the status-line configuration to add a showUpdate
flag, defaulting it to true in DEFAULT_STATUS_LINE_CONFIG, and gate the update
item in selectStatusItems before pushing the formatted value. Preserve existing
update formatting and behavior when the flag is enabled.

In `@apps/pythinker-code/test/cli/upgrade.test.ts`:
- Line 365: Strengthen the upgrade refusal test by replacing the generic
non-empty stderr assertion with an assertion that stderr contains the specific
refusal message emitted by refuseForegroundInstall. Match the sibling test’s
content-based assertion style so unrelated stderr output cannot satisfy the
test.

In `@apps/pythinker-code/test/tui/commands/update-preferences.test.ts`:
- Around line 350-369: Add a separate test case near the existing long-reason
test in the update command tests using a short failure message containing
newline characters and remaining under 160 characters. Mock startManualUpdate
with that message, invoke handleUpdateCommand, and assert showError receives the
reason collapsed to a single line without the truncation suffix.

In `@apps/pythinker-web/public/install.sh`:
- Around line 654-656: Update both download loops in install.sh to probe
fractional sleep support once, select either 0.12 or a 1-second fallback, and
use that interval for sleeping so failed fractional sleeps cannot cause a busy
loop. Replace the hard-coded i - last_emit_i >= 9 emission threshold with a
value derived from the selected interval, keeping progress timing consistent.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2cdeec1c-fb16-436e-8aa9-6a1610591c0f

📥 Commits

Reviewing files that changed from the base of the PR and between 12069a8 and e486a10.

📒 Files selected for processing (37)
  • .changeset/update-flow-reliability.md
  • .changeset/update-minimum-supported-version.md
  • .changeset/update-status-under-the-prompt.md
  • apps/pythinker-code/src/cli/sub/upgrade.ts
  • apps/pythinker-code/src/cli/update/cdn.ts
  • apps/pythinker-code/src/cli/update/install-lock.ts
  • apps/pythinker-code/src/cli/update/install-state.ts
  • apps/pythinker-code/src/cli/update/lease.ts
  • apps/pythinker-code/src/cli/update/preflight.ts
  • apps/pythinker-code/src/cli/update/refresh.ts
  • apps/pythinker-code/src/cli/update/rollout.ts
  • apps/pythinker-code/src/cli/update/select.ts
  • apps/pythinker-code/src/cli/update/types.ts
  • apps/pythinker-code/src/constant/app.ts
  • apps/pythinker-code/src/tui/commands/info.ts
  • apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts
  • apps/pythinker-code/src/tui/components/chrome/welcome.ts
  • apps/pythinker-code/src/tui/pythinker-tui.ts
  • apps/pythinker-code/src/tui/runtime/footer/footer-model.ts
  • apps/pythinker-code/src/tui/runtime/footer/update-status.ts
  • apps/pythinker-code/test/cli/update/cdn.test.ts
  • apps/pythinker-code/test/cli/update/install-lock.test.ts
  • apps/pythinker-code/test/cli/update/install-state.test.ts
  • apps/pythinker-code/test/cli/update/preflight.test.ts
  • apps/pythinker-code/test/cli/update/refresh.test.ts
  • apps/pythinker-code/test/cli/update/rollout.test.ts
  • apps/pythinker-code/test/cli/update/select.test.ts
  • apps/pythinker-code/test/cli/upgrade.test.ts
  • apps/pythinker-code/test/tui/commands/update-preferences.test.ts
  • apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts
  • apps/pythinker-code/test/tui/runtime/footer-model.test.ts
  • apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts
  • apps/pythinker-web/public/install.ps1
  • apps/pythinker-web/public/install.sh
  • apps/site/public/install.ps1
  • apps/site/public/install.sh
  • apps/site/scripts/build-cdn.mjs
💤 Files with no reviewable changes (3)
  • apps/pythinker-code/src/tui/components/chrome/welcome.ts
  • apps/site/public/install.sh
  • apps/site/public/install.ps1

Comment thread apps/pythinker-code/src/cli/update/preflight.ts
Comment thread apps/pythinker-code/test/cli/update/cdn.test.ts
Comment thread apps/pythinker-code/test/cli/update/preflight.test.ts
Comment thread apps/pythinker-code/test/cli/update/preflight.test.ts Outdated
Comment thread apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts Outdated
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.
@elkaix
elkaix merged commit 44efbc7 into main Aug 7, 2026
12 checks passed
@elkaix
elkaix deleted the fix/update-flow-hardening branch August 7, 2026 20:25
elkaix pushed a commit that referenced this pull request Aug 7, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @pythoughts/pythinker-code@0.12.0

### Minor Changes

- [#35](#35)
[`2ce6b5e`](2ce6b5e)
- Show the plan before a Dynamic Workflow runs, and let a good one be
saved as a command

Manual mode used to approve every `DynamicWorkflow` call outright. That
approval
only ever fired in manual mode — auto and yolo approve earlier in the
chain — so
the one mode whose purpose is to ask was the one mode that never saw
what it was
agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the
approval
carries the fan-out: how many subagents, the task list, the prompt
template, the
worker model, and the summed size of the prompts about to be sent.
"Approve for
this session" is keyed to that workflow's description rather than
granting every
  future `DynamicWorkflow` call.

  `/workflow save <name>` writes the last run back out as a skill under
`.pythinker-code/skills/`, so a fan-out that worked can be re-run by
name.

- [#38](#38)
[`44efbc7`](44efbc7)
- Let a release declare a minimum supported version, so a client below
it is offered the update without waiting for its staged rollout batch.

- [#38](#38)
[`44efbc7`](44efbc7)
- 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.

### Patch Changes

- [#37](#37)
[`12069a8`](12069a8)
- Stop offering updates to versions that were never published: the
update channel now advertises only the release that is actually
available for download.

- [#38](#38)
[`44efbc7`](44efbc7)
- 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.
## @pythoughts/pythinker-code-sdk@0.13.0

### Minor Changes

- [#35](#35)
[`2ce6b5e`](2ce6b5e)
- Show the plan before a Dynamic Workflow runs, and let a good one be
saved as a command

Manual mode used to approve every `DynamicWorkflow` call outright. That
approval
only ever fired in manual mode — auto and yolo approve earlier in the
chain — so
the one mode whose purpose is to ask was the one mode that never saw
what it was
agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the
approval
carries the fan-out: how many subagents, the task list, the prompt
template, the
worker model, and the summed size of the prompts about to be sent.
"Approve for
this session" is keyed to that workflow's description rather than
granting every
  future `DynamicWorkflow` call.

  `/workflow save <name>` writes the last run back out as a skill under
`.pythinker-code/skills/`, so a fan-out that worked can be re-run by
name.
## pythinker-code@0.8.6

### Patch Changes

- Updated dependencies
[[`2ce6b5e`](2ce6b5e)]:
  - @pythoughts/pythinker-code-sdk@0.13.0

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added approval previews for Dynamic Workflow calls in manual mode,
including fan-out details.
  - Added session-scoped workflow approvals.
- Added `/workflow save <name>` to save the latest workflow run as a
reusable skill.
  - Added live update availability and download progress indicators.

- **Bug Fixes**
- Improved update reliability with build checks, network timeouts,
stale-install recovery, and clearer retry messages.

- **Release Updates**
- Released PyThinker Code 0.12.0, VS Code extension 0.8.6, and SDK
0.13.0.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
elkaix added a commit that referenced this pull request Aug 9, 2026
## Related Issue

No issue — reported directly on Windows. The problem is described below.

## Problem

A Windows user on v0.12.0 saw `Updating to v0.13.1 / Installing in the
background`, then
`↑ v0.13.1 restart to apply` under the prompt. Restarting the terminal
still gave v0.12.0, and the
update log recorded the same install as *succeeded* several times over.

`where.exe pythinker` put
`%LOCALAPPDATA%\Programs\Pythinker\pythinker.exe` first, and the
published
0.13.1 Windows binary is correct (its sha256 matches the channel
manifest and it reports 0.13.1), so
the executable that ran was the one the installer targets and the
advertised version was real. What
was wrong is that nothing ever checked. Investigating it found four
defects:

- **A success is recorded from an exit code alone.** The background
finalizer writes `lastSuccess`
when the installer exits 0. No step asks whether the binary that runs
next is the target version,
so an installer that exits 0 without replacing anything advertises
"restart to apply" forever, on
  every launch.
- **`doctor` crashes on every native install.** It reports the package
root, and a packaged binary
  ships no `package.json`, so the command died with
`Error: Could not locate package.json near …` — exactly when a user
needs it most. The same
  lookup sits on the launch path in install-source detection.
- **npm-family auto-update cannot start on Windows.** `npm.cmd`,
`pnpm.cmd` and `yarn.cmd` are
spawned directly, which Node ≥18.20/20.12 refuses (CVE-2024-27980) with
`EINVAL`. The same call
fails in the npm-prefix lookup, so those installs also classify as
`unsupported`.
- **`install.ps1` emits no progress.** `install.sh` writes
machine-readable `progress:` lines on
stderr and the parent renders them; the PowerShell installer wrote none,
so the footer's
downloading state was unreachable on Windows and an update in flight
looked identical to a wedged
  one — the defect #38 set out to close, still open on one platform.

## What changed

**A success now means the new version runs.** After an installer exits
0, the version is verified
against the artifact the installer replaced, and a mismatch is recorded
as a failure carrying the
reason (`… still reports 0.12.0 (expected 0.13.1)`) instead of a
success. Only `native` installs are
verified, by probing `process.execPath --version`: an npm global
reinstall rewrites the directory
this process was loaded from, so nothing readable there proves what the
next launch runs, and a
wrong answer would park a healthy version.

Verification fails **open** — a probe that times out (an antivirus scan
on a fresh unsigned exe is
the realistic case), cannot run, or prints no version records the
success anyway, with a note saying
why it is unproven. `doctor` prints that note next to the recorded
outcome, so the next report of
"it says updated but it did not" is answerable in one command.

**Windows package-manager shims run through the command interpreter.**
`cmd.exe /d /s /c npm.cmd …`,
spelled out as argv rather than `shell: true`, so the exact command line
is visible in the source and
asserted in tests instead of being assembled by Node's string joining.
Same fix in the npm-prefix
lookup that classifies the install source.

**`install.ps1` speaks the progress protocol**, mirroring `install.sh`:
`state=waiting` while release
assets are not up yet, `state=downloading` with percent and byte counts
(one line per second at
most), `state=done`, and a single `state=failed` after the last retry —
not between attempts, which
would drop the footer out of its downloading state and back into a
failure it is about to recover
from.

**`doctor` survives a native install**, reporting the package root only
when there is one, and the
launch-path source detection classifies an unresolvable layout as
`unsupported` rather than throwing.
It also now prints the last recorded update success, which is what would
have shown the original
problem immediately.

One scope decision worth flagging: the interactive `Updated … to X`
message still prints unchanged
when a native probe could not run. The mismatch case — the actual lie —
throws and is reported as a
failure on both foreground paths; the unproven case only loses a line in
a flow the user is watching,
and it is recorded in the install state either way.

## Checklist

- [x] I have read the
[CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md)
document.
- [x] I have linked a related issue, or explained the problem above.
- [x] I have added tests that prove my feature works.
- [x] Ran `gen-changesets` skill, or this PR needs no changeset.
- [x] Ran `gen-docs` skill, or this PR needs no doc update. The
user-facing docs describe the
commands, not `doctor`'s runtime lines, and the update behaviour is
unchanged when an install
      really works.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
  - Fixed crashes when running diagnostics from native installations.
- Improved automatic updates for npm, pnpm, and Yarn installations on
Windows.
- Updates are no longer reported as successful when the installed
version remains unchanged.

- **New Features**
- Added post-update version verification with clearer failure and
unverified status reporting.
- Diagnostics now show the most recent successful update and its status.
- Windows installer downloads now display progress, waiting, completion,
and failure states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant