Skip to content

Commit 20ae711

Browse files
committed
fix: enable Windows native auto-update
Windows native installs could rename a running exe but not overwrite it, so the CLI hard-disabled auto-update on that platform and told users to run install.ps1 by hand instead. install.ps1 now stages the new binary inside the install dir and replaces the running exe with a rename-aside-then-move sequence, retrying cleanup of the old file for a few seconds since it may still be locked by the exiting process. The CLI drops the win32 auto-install gate and spawns the installer via PowerShell the same way it spawns curl|bash on macOS/Linux, and sweeps any leftover renamed-aside binary on the next startup.
1 parent 0631ca4 commit 20ae711

7 files changed

Lines changed: 179 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Enable automatic updates for native installs on Windows: /update now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable.

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function installCommandFor(
9999

100100
export type AutomaticUpdateMode = 'background-install' | 'restart-install' | 'manual';
101101

102-
export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean {
102+
export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean {
103103
switch (source) {
104104
case 'npm-global':
105105
case 'pnpm-global':
@@ -111,7 +111,7 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform)
111111
// TUI updates use the separate prepare-on-restart lifecycle instead.
112112
return false;
113113
case 'native':
114-
return platform !== 'win32';
114+
return true;
115115
case 'unsupported':
116116
return false;
117117
}
@@ -147,6 +147,12 @@ export function spawnForSource(
147147
case 'homebrew':
148148
return { cmd: 'brew', args: ['upgrade', 'pythinker-code'] };
149149
case 'native':
150+
if (platform === 'win32') {
151+
return {
152+
cmd: 'powershell.exe',
153+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', NATIVE_INSTALL_COMMAND_WIN],
154+
};
155+
}
150156
// `curl … | bash` reports only the trailing bash's exit status, so a
151157
// failed download (curl can't connect → empty stdin → bash exits 0)
152158
// would look like a successful update. `pipefail` makes the pipeline
@@ -176,7 +182,7 @@ export function renderManualUpdateMessage(
176182
sourceDesc = 'homebrew';
177183
break;
178184
case 'native':
179-
sourceDesc = 'native (windows). Auto-update is not supported on this platform.';
185+
sourceDesc = 'native install.';
180186
break;
181187
case 'unsupported':
182188
sourceDesc = 'unsupported package manager or layout.';

apps/pythinker-code/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
import { dispatchUpdateHelperIfRequested } from './cli/update/update-helper';
4242
import { createPythinkerCodeHostIdentity, getVersion } from './cli/version';
4343
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app';
44-
import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
44+
import { cleanupStaleNativeCacheForCurrent, cleanupStaleUpdateBackup } from './native/native-assets';
4545
import { installNativeModuleHook } from './native/module-hook';
4646
import { runNativeAssetSmokeIfRequested } from './native/smoke';
4747

@@ -181,6 +181,8 @@ export function main(): void {
181181
} catch {
182182
// ignore: cache GC must never affect process startup
183183
}
184+
// Sweep a leftover `pythinker.exe.old` from a prior Windows native update.
185+
cleanupStaleUpdateBackup();
184186
});
185187

186188
const version = getVersion();

apps/pythinker-code/src/native/native-assets.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,24 @@ export function cleanupStaleNativeCacheForCurrent(
378378
currentRoot,
379379
});
380380
}
381+
382+
/**
383+
* Windows native installs can't overwrite a running exe, so the updater
384+
* renames the old one aside to `pythinker.exe.old` before writing the
385+
* replacement. Best-effort cleanup at the next startup; the file may still
386+
* be locked (AV scan, slow parent exit) — ignore and retry next launch.
387+
*/
388+
export function cleanupStaleUpdateBackup(
389+
options: { readonly execPath?: string; readonly platform?: NodeJS.Platform; readonly isSea?: boolean } = {},
390+
): void {
391+
const platform = options.platform ?? process.platform;
392+
if (platform !== 'win32') return;
393+
const isSea = options.isSea ?? getSeaAssetSource() !== null;
394+
if (!isSea) return;
395+
const execPath = options.execPath ?? process.execPath;
396+
try {
397+
rmSync(`${execPath}.old`, { force: true });
398+
} catch {
399+
// Locked or absent; the next startup retries.
400+
}
401+
}

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
readUpdateInstallState,
1111
writeUpdateInstallState,
1212
} from '#/cli/update/install-state';
13-
import { runUpdatePreflight, spawnForSource, startManualUpdate } from '#/cli/update/preflight';
13+
import { canAutoInstall, runUpdatePreflight, spawnForSource, startManualUpdate } from '#/cli/update/preflight';
1414
import { promptForInstallChoice } from '#/cli/update/prompt';
1515
import type * as PromptModule from '#/cli/update/prompt';
1616
import { refreshUpdateCache } from '#/cli/update/refresh';
@@ -552,18 +552,30 @@ describe('runUpdatePreflight', () => {
552552
}
553553
});
554554

555-
it('native on win32: prints manual powershell command, does not spawn', async () => {
555+
it('native on win32: starts a background powershell install, no manual prompt', async () => {
556556
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
557557
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
558558
mocks.detectInstallSource.mockResolvedValue('native');
559+
mockSpawnExit(0);
559560
const originalPlatform = process.platform;
560561
Object.defineProperty(process, 'platform', { value: 'win32' });
561562
try {
562563
const { stdout, options } = captureOutput();
563564
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
564-
expect(stdout.join('')).toContain('irm https://code.pythinker.com/pythinker-code/install.ps1 | iex');
565+
await flushBackgroundInstall();
566+
expect(stdout.join('')).toBe('');
565567
expect(promptForInstallChoice).not.toHaveBeenCalled();
566-
expect(mocks.spawn).not.toHaveBeenCalled();
568+
expect(mocks.spawn).toHaveBeenCalledWith(
569+
'powershell.exe',
570+
[
571+
'-NoProfile',
572+
'-ExecutionPolicy',
573+
'Bypass',
574+
'-Command',
575+
'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex',
576+
],
577+
{ detached: true, stdio: 'ignore' },
578+
);
567579
} finally {
568580
Object.defineProperty(process, 'platform', { value: originalPlatform });
569581
}
@@ -1473,8 +1485,8 @@ describe('spawnForSource native', () => {
14731485
// so a curl that never connects (exit 7, empty stdin → bash exits 0) is
14741486
// masked and the update is wrongly reported as successful. `set -o pipefail`
14751487
// makes the pipeline surface curl's failure. Shadowing `curl` with a shell
1476-
// function keeps this offline and deterministic; skipped on Windows (no bash,
1477-
// and native auto-install is unsupported there anyway).
1488+
// function keeps this offline and deterministic; skipped on Windows (no bash
1489+
// to run this script with).
14781490
it.skipIf(process.platform === 'win32')(
14791491
'surfaces a failed curl download as a non-zero exit',
14801492
() => {
@@ -1485,6 +1497,36 @@ describe('spawnForSource native', () => {
14851497
expect(result.status).toBeGreaterThan(0);
14861498
},
14871499
);
1500+
1501+
it('darwin/linux: unchanged bash -c pipeline', () => {
1502+
const { cmd, args } = spawnForSource('native', '0.5.0', 'darwin');
1503+
expect(cmd).toBe('bash');
1504+
expect(args[0]).toBe('-c');
1505+
expect(args[1]).toContain('curl -fsSL https://code.pythinker.com/pythinker-code/install.sh');
1506+
});
1507+
1508+
it('win32: powershell.exe with -ExecutionPolicy Bypass and the irm|iex install command', () => {
1509+
const { cmd, args } = spawnForSource('native', '0.5.0', 'win32');
1510+
expect(cmd).toBe('powershell.exe');
1511+
expect(args).toEqual([
1512+
'-NoProfile',
1513+
'-ExecutionPolicy',
1514+
'Bypass',
1515+
'-Command',
1516+
'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex',
1517+
]);
1518+
});
1519+
});
1520+
1521+
describe('canAutoInstall native', () => {
1522+
it('is true on win32 (rename-aside replace no longer needs the platform gate)', () => {
1523+
expect(canAutoInstall('native', 'win32')).toBe(true);
1524+
});
1525+
1526+
it('is true on darwin/linux', () => {
1527+
expect(canAutoInstall('native', 'darwin')).toBe(true);
1528+
expect(canAutoInstall('native', 'linux')).toBe(true);
1529+
});
14881530
});
14891531

14901532
describe('startManualUpdate', () => {

apps/pythinker-code/test/native/native-assets.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { join } from 'node:path';
66
import { describe, expect, it } from 'vitest';
77

88
import {
9+
cleanupStaleUpdateBackup,
910
getNativeAssetFilePath,
1011
getNativeCacheBase,
1112
getNativePackageRoot,
@@ -158,3 +159,56 @@ describe('native assets', () => {
158159
}
159160
});
160161
});
162+
163+
describe('cleanupStaleUpdateBackup', () => {
164+
it('removes a leftover .old exe on win32 SEA installs', () => {
165+
const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-'));
166+
const execPath = join(dir, 'pythinker.exe');
167+
const stalePath = `${execPath}.old`;
168+
writeFileSync(stalePath, 'stale');
169+
try {
170+
cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: true });
171+
expect(existsSync(stalePath)).toBe(false);
172+
} finally {
173+
rmSync(dir, { recursive: true, force: true });
174+
}
175+
});
176+
177+
it('is a no-op when there is nothing to clean up (missing file)', () => {
178+
const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-'));
179+
const execPath = join(dir, 'pythinker.exe');
180+
try {
181+
expect(() => {
182+
cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: true });
183+
}).not.toThrow();
184+
} finally {
185+
rmSync(dir, { recursive: true, force: true });
186+
}
187+
});
188+
189+
it('skips non-win32 platforms', () => {
190+
const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-'));
191+
const execPath = join(dir, 'pythinker');
192+
const stalePath = `${execPath}.old`;
193+
writeFileSync(stalePath, 'stale');
194+
try {
195+
cleanupStaleUpdateBackup({ execPath, platform: 'darwin', isSea: true });
196+
expect(existsSync(stalePath)).toBe(true);
197+
} finally {
198+
rmSync(dir, { recursive: true, force: true });
199+
}
200+
});
201+
202+
it('skips non-SEA (npm/dev) processes', () => {
203+
const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-'));
204+
const execPath = join(dir, 'pythinker.exe');
205+
const stalePath = `${execPath}.old`;
206+
writeFileSync(stalePath, 'stale');
207+
try {
208+
cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: false });
209+
expect(existsSync(stalePath)).toBe(true);
210+
} finally {
211+
rmSync(dir, { recursive: true, force: true });
212+
}
213+
});
214+
});

apps/pythinker-web/public/install.ps1

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -505,17 +505,48 @@ try {
505505
}
506506
Phase-Ok "Verifying"
507507

508-
# The release zip contains a single pythinker.exe at its root.
509-
$extractDir = Join-Path $tempDir "extracted"
508+
$installDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker"
509+
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
510+
511+
# The release zip contains a single pythinker.exe at its root. Extract
512+
# into $installDir (not $env:TEMP) so the final Move-Item below is always
513+
# a same-volume rename, never a cross-volume copy.
514+
$extractDir = Join-Path $installDir ("update-" + [System.Guid]::NewGuid().ToString('N'))
510515
Expand-Archive -LiteralPath $installerPath -DestinationPath $extractDir -Force
511516
$binary = Join-Path $extractDir "pythinker.exe"
512517
if (-not (Test-Path $binary)) {
513518
Fail "archive did not contain pythinker.exe"
514519
}
515520

516-
$installDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker"
517-
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
518-
Copy-Item -LiteralPath $binary -Destination (Join-Path $installDir "pythinker.exe") -Force
521+
# A running pythinker.exe can be renamed but not overwritten or deleted on
522+
# Windows. Rename it aside, move the new binary into place, then retry
523+
# deleting the stale copy for a few seconds in case it's still locked by
524+
# the running parent process or an AV scan.
525+
$target = Join-Path $installDir "pythinker.exe"
526+
$stale = "$target.old"
527+
# Opportunistic cleanup of a previous update's leftover (may be locked; ignore).
528+
if (Test-Path $stale) { Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue }
529+
if (Test-Path $target) {
530+
try {
531+
Rename-Item -LiteralPath $target -NewName "pythinker.exe.old" -Force -ErrorAction Stop
532+
} catch {
533+
Fail "could not rename existing pythinker.exe aside, a concurrent update may be in progress: $($_.Exception.Message)"
534+
}
535+
}
536+
try {
537+
Move-Item -LiteralPath $binary -Destination $target -Force -ErrorAction Stop
538+
} catch {
539+
# Roll the old exe back so the user is never left with no binary at all.
540+
if ((Test-Path $stale) -and -not (Test-Path $target)) {
541+
Rename-Item -LiteralPath $stale -NewName "pythinker.exe" -Force -ErrorAction SilentlyContinue
542+
}
543+
Fail "could not install the new pythinker.exe: $($_.Exception.Message)"
544+
}
545+
Remove-Item -Recurse -Force $extractDir -ErrorAction SilentlyContinue
546+
for ($i = 0; $i -lt 5 -and (Test-Path $stale); $i++) {
547+
Start-Sleep -Seconds 2
548+
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
549+
}
519550
Phase-Ok "Installing"
520551

521552
# Persist the install dir on the user PATH, and make it available in
@@ -533,4 +564,7 @@ try {
533564
} finally {
534565
Write-Host -NoNewline $SHOW
535566
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
567+
if ($extractDir) {
568+
Remove-Item -Recurse -Force $extractDir -ErrorAction SilentlyContinue
569+
}
536570
}

0 commit comments

Comments
 (0)