Skip to content

Commit bceff21

Browse files
authored
fix(update): stop reporting an update that never installed (#46)
## 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 -->
1 parent 82951c6 commit bceff21

18 files changed

Lines changed: 730 additions & 30 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Fix `pythinker doctor` crashing on native installs, and report the last recorded update outcome.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Stop reporting an update as installed when the executable did not change; the version is checked after the installer finishes and a mismatch is recorded as a failure with the reason.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Show download progress under the prompt while a Windows update installs, instead of nothing until it finishes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Fix automatic updates on Windows for npm, pnpm, and yarn installs, which failed to start at all.

apps/pythinker-code/src/cli/sub/doctor.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from '#/cli/update/preflight';
2525
import { detectInstallSource } from '#/cli/update/source';
2626
import type { UpdateInstallFailure } from '#/cli/update/types';
27-
import { getHostPackageRoot, getVersion } from '#/cli/version';
27+
import { findHostPackageRoot, getVersion } from '#/cli/version';
2828
import { getUpdateInstallLogFile } from '#/utils/paths';
2929

3030
interface WritableLike {
@@ -50,7 +50,8 @@ export interface DoctorDeps {
5050
export interface DoctorRuntimeInfo {
5151
readonly version: string;
5252
readonly installSource: string;
53-
readonly packageRoot: string;
53+
/** Absent on a native binary: a packaged install has no `package.json`. */
54+
readonly packageRoot?: string;
5455
readonly executable: string;
5556
readonly installations?: readonly string[];
5657
readonly ripgrep?: RgResolution;
@@ -62,6 +63,7 @@ export interface DoctorRuntimeInfo {
6263
readonly pendingVersion?: string;
6364
readonly pendingRequestedBy?: 'automatic' | 'manual';
6465
readonly activeOperation?: string;
66+
readonly lastSuccess?: string;
6567
readonly lastFailure?: string;
6668
readonly logPath?: string;
6769
};
@@ -191,7 +193,7 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
191193
return {
192194
version: getVersion(),
193195
installSource,
194-
packageRoot: getHostPackageRoot(),
196+
packageRoot: findHostPackageRoot() ?? undefined,
195197
executable: process.execPath,
196198
installations,
197199
ripgrep,
@@ -206,6 +208,14 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
206208
installState.active === null
207209
? undefined
208210
: `${installState.active.operation ?? 'install'} ${installState.active.version}`,
211+
lastSuccess:
212+
installState.lastSuccess === null
213+
? undefined
214+
: `${installState.lastSuccess.version} (installed ` +
215+
`${installState.lastSuccess.installedAt})` +
216+
(installState.lastSuccess.unverified === undefined
217+
? ''
218+
: ` — unverified: ${installState.lastSuccess.unverified}`),
209219
lastFailure:
210220
installState.lastFailure === null
211221
? undefined
@@ -387,7 +397,7 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] {
387397
'Runtime',
388398
` Version: ${info.version}`,
389399
` Install source: ${info.installSource}`,
390-
` Package root: ${info.packageRoot}`,
400+
...(info.packageRoot === undefined ? [] : [` Package root: ${info.packageRoot}`]),
391401
` Executable: ${info.executable}`,
392402
...(installations.length > 1
393403
? [
@@ -414,6 +424,9 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] {
414424
...(info.update.activeOperation === undefined
415425
? []
416426
: [` Update operation: ${info.update.activeOperation}`]),
427+
...(info.update.lastSuccess === undefined
428+
? []
429+
: [` Last update success: ${info.update.lastSuccess}`]),
417430
...(info.update.lastFailure === undefined
418431
? []
419432
: [` Last update failure: ${info.update.lastFailure}`]),

apps/pythinker-code/src/cli/sub/upgrade.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '#/cli/update/install-state';
1414
import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select';
1515
import { detectInstallSource } from '#/cli/update/source';
16+
import type { InstallOutcome } from '#/cli/update/verify-install';
1617
import {
1718
canAutoInstall,
1819
installCommandFor,
@@ -47,7 +48,7 @@ export interface UpgradeDeps {
4748
source: InstallSource,
4849
version: string,
4950
platform: NodeJS.Platform,
50-
) => Promise<void>;
51+
) => Promise<InstallOutcome>;
5152
readonly promptForInstallChoice: (
5253
options: InstallPromptOptions,
5354
) => Promise<InstallPromptChoiceValue>;
@@ -180,7 +181,7 @@ export async function handleUpgrade(
180181
target_version: target.version,
181182
source,
182183
});
183-
await deps.installUpdate(source, target.version, deps.platform);
184+
const outcome = await deps.installUpdate(source, target.version, deps.platform);
184185
await deps.writeUpdateInstallState({
185186
...installState,
186187
active: null,
@@ -189,6 +190,7 @@ export async function handleUpgrade(
189190
version: target.version,
190191
installedAt: nowIso(),
191192
notifiedAt: null,
193+
unverified: outcome.unverified,
192194
},
193195
}).catch(() => {});
194196
trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', {

apps/pythinker-code/src/cli/update/install-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ const UpdateInstallStateSchema: z.ZodType<UpdateInstallState> = z
153153
version: z.string().min(1),
154154
installedAt: z.string().min(1),
155155
notifiedAt: z.string().min(1).nullable(),
156+
unverified: z.string().min(1).optional(),
156157
})
157158
.strict()
158159
.nullable(),

apps/pythinker-code/src/cli/update/preflight.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,17 @@ import {
5656
type UpdateRequestOrigin,
5757
type UpdateTarget,
5858
} from './types';
59+
import {
60+
verifyInstalledVersion,
61+
type InstallOutcome,
62+
type InstallVerification,
63+
} from './verify-install';
5964

6065
export type { UpdatePreflightResult } from './types';
6166

67+
/** Reused for the paths that never reach verification (a failed install). */
68+
const OK_VERIFICATION: InstallVerification = { ok: true };
69+
6270
export interface RunUpdatePreflightOptions {
6371
readonly stdout?: { write(chunk: string): boolean };
6472
readonly stderr?: { write(chunk: string): boolean };
@@ -81,6 +89,33 @@ function bunCommand(platform: NodeJS.Platform): string {
8189
return platform === 'win32' ? 'bun.exe' : 'bun';
8290
}
8391

92+
/**
93+
* Node ≥18.20/20.12 refuses to spawn a `.cmd`/`.bat` file directly
94+
* (CVE-2024-27980) and fails with `EINVAL` — which is every npm-family update
95+
* on Windows: `npm.cmd`, `pnpm.cmd`, `yarn.cmd`. The command interpreter runs
96+
* them instead. It is spelled out as argv rather than `shell: true` so the
97+
* exact command line is visible here (and asserted in tests) instead of being
98+
* assembled by Node's string joining.
99+
*/
100+
function viaCommandInterpreter(command: SpawnCommand): SpawnCommand {
101+
return {
102+
...command,
103+
cmd: process.env['ComSpec'] ?? 'cmd.exe',
104+
args: ['/d', '/s', '/c', command.cmd, ...command.args],
105+
};
106+
}
107+
108+
/** True for the Windows package-manager shims that cannot be spawned directly. */
109+
export function isWindowsShim(cmd: string, platform: NodeJS.Platform): boolean {
110+
if (platform !== 'win32') return false;
111+
const lower = cmd.toLowerCase();
112+
return lower.endsWith('.cmd') || lower.endsWith('.bat');
113+
}
114+
115+
function spawnable(command: SpawnCommand, platform: NodeJS.Platform): SpawnCommand {
116+
return isWindowsShim(command.cmd, platform) ? viaCommandInterpreter(command) : command;
117+
}
118+
84119
export function installCommandFor(
85120
source: InstallSource,
86121
version: string,
@@ -145,11 +180,20 @@ export function spawnForSource(
145180
): SpawnCommand {
146181
switch (source) {
147182
case 'npm-global':
148-
return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
183+
return spawnable(
184+
{ cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] },
185+
platform,
186+
);
149187
case 'pnpm-global':
150-
return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
188+
return spawnable(
189+
{ cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] },
190+
platform,
191+
);
151192
case 'yarn-global':
152-
return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] };
193+
return spawnable(
194+
{ cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] },
195+
platform,
196+
);
153197
case 'bun-global':
154198
return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
155199
case 'homebrew':
@@ -543,7 +587,7 @@ export async function installUpdate(
543587
source: InstallSource,
544588
version: string,
545589
platform: NodeJS.Platform,
546-
): Promise<void> {
590+
): Promise<InstallOutcome> {
547591
const { cmd, args, env } = spawnForSource(source, version, platform);
548592
await new Promise<void>((resolve, reject) => {
549593
const child = spawn(cmd, [...args], {
@@ -560,6 +604,14 @@ export async function installUpdate(
560604
reject(new Error(`${cmd} exited with ${detail}`));
561605
});
562606
});
607+
// Exit code 0 is the installer's opinion; this is the fact. Rejecting here
608+
// routes a silent no-op install into the same failure reporting a crashed
609+
// installer gets, instead of printing "Updated …" over an unchanged binary.
610+
const verification = await verifyInstalledVersion(source, version);
611+
if (!verification.ok) throw new Error(verification.reason);
612+
// Returned so the caller can record *why* a success is unproven; see
613+
// verify-install.ts for the fail-open rule.
614+
return { unverified: verification.unverified };
563615
}
564616

565617
/** Keep the tail only: installers can be chatty, and the state file is small. */
@@ -861,11 +913,19 @@ async function startBackgroundInstall(
861913
// `settled` already stops new progress writes; drain the ones in flight so
862914
// none of them renames over the outcome below.
863915
await progressWrites;
916+
// An installer that exits 0 without replacing the binary must not be
917+
// recorded as a success: the footer would advertise "restart to apply"
918+
// for a version that never runs, on every launch, forever.
919+
const verification = succeeded
920+
? await verifyInstalledVersion(source, target.version)
921+
: OK_VERIFICATION;
922+
const installed = succeeded && verification.ok;
923+
const outcomeReason = verification.ok ? reason : verification.reason;
864924
const attempts = failureAttemptsFor(startedState, target, 'install') + 1;
865925
const stderrTail = readStderrTail();
866-
const message = stderrTail === undefined ? reason : `${reason}: ${stderrTail}`;
926+
const message = stderrTail === undefined ? outcomeReason : `${outcomeReason}: ${stderrTail}`;
867927

868-
const nextState: UpdateInstallState = succeeded
928+
const nextState: UpdateInstallState = installed
869929
? {
870930
...startedState,
871931
active: null,
@@ -874,6 +934,7 @@ async function startBackgroundInstall(
874934
version: target.version,
875935
installedAt: nowIso(),
876936
notifiedAt: null,
937+
unverified: verification.ok ? verification.unverified : undefined,
877938
},
878939
}
879940
: {
@@ -889,14 +950,17 @@ async function startBackgroundInstall(
889950
};
890951
try {
891952
await writeUpdateInstallState(nextState).catch(() => {});
892-
if (succeeded) {
953+
if (installed) {
893954
trackUpdateEvent(track, 'update_background_install_succeeded', {
894955
target_version: target.version,
895956
source,
896957
});
897958
logUpdateInfo(logger, 'background update install succeeded', {
898959
targetVersion: target.version,
899960
source,
961+
// Present when the install was recorded without proof, so a report
962+
// of "it says updated but it did not" is answerable from the log.
963+
unverified: verification.ok ? verification.unverified : undefined,
900964
});
901965
return;
902966
}
@@ -1392,7 +1456,7 @@ export async function runUpdatePreflight(
13921456
if (lock === null) return 'continue';
13931457

13941458
try {
1395-
await installUpdate(source, userVisibleTarget.version, platform);
1459+
const outcome = await installUpdate(source, userVisibleTarget.version, platform);
13961460
await writeUpdateInstallState({
13971461
...installState,
13981462
active: null,
@@ -1401,6 +1465,7 @@ export async function runUpdatePreflight(
14011465
version: userVisibleTarget.version,
14021466
installedAt: nowIso(),
14031467
notifiedAt: null,
1468+
unverified: outcome.unverified,
14041469
},
14051470
}).catch(() => {});
14061471
stdout.write(renderInstallSuccessMessage(userVisibleTarget));

apps/pythinker-code/src/cli/update/source.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,30 @@ function npmCommand(platform: NodeJS.Platform): string {
7676
return platform === 'win32' ? 'npm.cmd' : 'npm';
7777
}
7878

79-
function execFileText(command: string, args: readonly string[]): Promise<string> {
79+
function execFileText(
80+
command: string,
81+
args: readonly string[],
82+
platform: NodeJS.Platform = process.platform,
83+
): Promise<string> {
84+
// `npm.cmd` cannot be spawned directly on Node ≥18.20/20.12
85+
// (CVE-2024-27980): it fails with EINVAL, and every npm-family Windows
86+
// install then classifies as `unsupported` and never auto-updates.
87+
const viaInterpreter = platform === 'win32' && command.toLowerCase().endsWith('.cmd');
88+
const spawnCommand = viaInterpreter ? process.env['ComSpec'] ?? 'cmd.exe' : command;
89+
const spawnArgs = viaInterpreter ? ['/d', '/s', '/c', command, ...args] : [...args];
8090
return new Promise((resolveOutput, reject) => {
81-
execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => {
82-
if (error) {
83-
reject(error);
84-
return;
85-
}
86-
resolveOutput(stdout);
87-
});
91+
execFile(
92+
spawnCommand,
93+
spawnArgs,
94+
{ encoding: 'utf-8', windowsHide: true },
95+
(error, stdout) => {
96+
if (error) {
97+
reject(error);
98+
return;
99+
}
100+
resolveOutput(stdout);
101+
},
102+
);
88103
});
89104
}
90105

@@ -140,14 +155,22 @@ export async function detectInstallSource(
140155
getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot,
141156
getGlobalPrefix:
142157
deps.getGlobalPrefix ??
143-
(() => execFileText(npmCommand(platform), ['prefix', '-g']).then((text) => text.trim())),
158+
(() =>
159+
execFileText(npmCommand(platform), ['prefix', '-g'], platform).then((text) => text.trim())),
144160
detectNative: deps.detectNative ?? detectNativeInstall,
145161
platform,
146162
};
147163

148164
if (resolved.detectNative()) return 'native';
149165

150-
const packageRoot = resolved.getPackageRoot();
166+
// A layout with no reachable `package.json` cannot be classified, and this
167+
// runs on every launch — it reports "unsupported" rather than throwing.
168+
let packageRoot: string;
169+
try {
170+
packageRoot = resolved.getPackageRoot();
171+
} catch {
172+
return 'unsupported';
173+
}
151174
const heuristic = classifyByPathHeuristic(packageRoot);
152175
if (heuristic !== null) return heuristic;
153176

apps/pythinker-code/src/cli/update/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ export interface UpdateInstallSuccess {
112112
readonly version: string;
113113
readonly installedAt: string;
114114
readonly notifiedAt: string | null;
115+
/**
116+
* Why this success was recorded without proof that the new version runs.
117+
* Absent when the installed binary was probed and matched. `doctor` prints
118+
* it, so "it says updated but it did not" is answerable in one command.
119+
*/
120+
readonly unverified?: string;
115121
}
116122

117123
export interface UpdateInstallState {

0 commit comments

Comments
 (0)