Skip to content

Commit 481db17

Browse files
authored
fix(cli): enable native automatic updates by default (#295)
- Enable the native staged updater by default on macOS, Linux, and Windows. - Interactive updates keep the confirmation prompt; explicit native updates in scripts or pipes download and stage without prompting. - Report native installs as staged for the next CLI start instead of already installed. - Preserve config/env opt-outs, rollout, checksum verification, and install locks; document startup prompts and opt-outs.
1 parent cbbfa0f commit 481db17

7 files changed

Lines changed: 140 additions & 72 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Enable automatic updates by default for native CLI installations.

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

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export async function handleUpgrade(
8686

8787
const source = await deps.detectInstallSource().catch(() => 'unsupported' as const);
8888
const installCommand = installCommandFor(source, target.version, deps.platform);
89-
if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) {
89+
const needsConfirmation = source !== 'native';
90+
if (!canAutoInstall(source, deps.platform) || (!deps.isInteractive && needsConfirmation)) {
9091
trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', {
9192
current_version: currentVersion,
9293
target_version: target.version,
@@ -101,34 +102,36 @@ export async function handleUpgrade(
101102
return 0;
102103
}
103104

104-
trackUpgradeEvent(deps.track, 'upgrade_command_prompted', {
105-
current_version: currentVersion,
106-
target_version: target.version,
107-
source,
108-
});
109-
logUpgradeInfo(deps.logger, 'manual upgrade prompted', {
110-
currentVersion,
111-
targetVersion: target.version,
112-
source,
113-
});
114-
const choice = await deps.promptForInstallChoice({
115-
currentVersion,
116-
target,
117-
installCommand,
118-
installSource: source,
119-
});
120-
if (choice === 'skip') {
121-
trackUpgradeEvent(deps.track, 'upgrade_command_skipped', {
105+
if (deps.isInteractive) {
106+
trackUpgradeEvent(deps.track, 'upgrade_command_prompted', {
122107
current_version: currentVersion,
123108
target_version: target.version,
124109
source,
125110
});
126-
logUpgradeInfo(deps.logger, 'manual upgrade skipped', {
111+
logUpgradeInfo(deps.logger, 'manual upgrade prompted', {
127112
currentVersion,
128113
targetVersion: target.version,
129114
source,
130115
});
131-
return 0;
116+
const choice = await deps.promptForInstallChoice({
117+
currentVersion,
118+
target,
119+
installCommand,
120+
installSource: source,
121+
});
122+
if (choice === 'skip') {
123+
trackUpgradeEvent(deps.track, 'upgrade_command_skipped', {
124+
current_version: currentVersion,
125+
target_version: target.version,
126+
source,
127+
});
128+
logUpgradeInfo(deps.logger, 'manual upgrade skipped', {
129+
currentVersion,
130+
targetVersion: target.version,
131+
source,
132+
});
133+
return 0;
134+
}
132135
}
133136

134137
try {
@@ -148,7 +151,7 @@ export async function handleUpgrade(
148151
targetVersion: target.version,
149152
source,
150153
});
151-
deps.stdout.write(renderInstallSuccessMessage(target));
154+
deps.stdout.write(renderInstallSuccessMessage(target, source));
152155
return 0;
153156
} catch (error) {
154157
trackUpgradeEvent(deps.track, 'upgrade_command_failed', {

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
33
import { log, type Logger } from '@pymodel/pythinker-code-sdk';
44
import type { TelemetryProperties } from '@pymodel/pythinker-telemetry';
55

6+
import { CLI_COMMAND_NAME, PRODUCT_NAME } from '#/constant/app';
67
import { loadTuiConfig } from '#/tui/config';
78
import { resolveCommandPath } from '#/utils/process/resolve-command';
89

@@ -77,7 +78,7 @@ export function installCommandFor(
7778
case 'homebrew':
7879
return 'brew upgrade pythinker-code';
7980
case 'native':
80-
return 'See https://github.com/PyModel/pythinker-code/releases';
81+
return `${CLI_COMMAND_NAME} upgrade`;
8182
case 'unsupported':
8283
return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
8384
}
@@ -89,13 +90,12 @@ export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform
8990
case 'pnpm-global':
9091
case 'yarn-global':
9192
case 'bun-global':
93+
case 'native':
9294
return true;
9395
case 'homebrew':
9496
// Homebrew upgrade may mutate other dependents and the formula can lag
9597
// behind the CDN release — prompt the user to run `brew upgrade` manually.
9698
return false;
97-
case 'native':
98-
return false;
9999
case 'unsupported':
100100
return false;
101101
}
@@ -213,7 +213,10 @@ export function renderManualUpdateMessage(
213213
);
214214
}
215215

216-
export function renderInstallSuccessMessage(target: UpdateTarget): string {
216+
export function renderInstallSuccessMessage(target: UpdateTarget, source: InstallSource): string {
217+
if (source === 'native') {
218+
return `${PRODUCT_NAME} ${target.version} is staged; it applies the next time you start the CLI.\n`;
219+
}
217220
return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`;
218221
}
219222

@@ -897,7 +900,7 @@ export async function runUpdatePreflight(
897900

898901
try {
899902
await installUpdate(source, userVisibleTarget.version, platform);
900-
stdout.write(renderInstallSuccessMessage(userVisibleTarget));
903+
stdout.write(renderInstallSuccessMessage(userVisibleTarget, source));
901904
return 'exit';
902905
} catch (error) {
903906
stderr.write(

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

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
readUpdateInstallState,
1010
writeUpdateInstallState,
1111
} from '#/cli/update/install-state';
12-
import { canAutoInstall, installCommandFor, runUpdatePreflight } from '#/cli/update/preflight';
12+
import { runUpdatePreflight } from '#/cli/update/preflight';
1313
import { promptForInstallChoice } from '#/cli/update/prompt';
1414
import type * as PromptModule from '#/cli/update/prompt';
1515
import { refreshUpdateCache } from '#/cli/update/refresh';
@@ -499,50 +499,45 @@ describe('runUpdatePreflight', () => {
499499
expect(mocks.spawn).not.toHaveBeenCalled();
500500
});
501501

502-
it('native: shows the releases page and does not spawn on darwin', async () => {
503-
disableAutoInstall();
504-
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
505-
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
506-
mocks.detectInstallSource.mockResolvedValue('native');
507-
mocks.promptForInstallChoice.mockResolvedValue('install');
508-
mockSpawnExit(0);
509-
const originalPlatform = process.platform;
510-
Object.defineProperty(process, 'platform', { value: 'darwin' });
511-
try {
512-
expect(canAutoInstall('native', 'darwin')).toBe(false);
513-
expect(installCommandFor('native', '0.5.0', 'darwin')).toBe(
514-
'See https://github.com/PyModel/pythinker-code/releases',
515-
);
516-
const { stdout, options } = captureOutput();
517-
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
518-
expect(mocks.spawn).not.toHaveBeenCalled();
519-
expect(stdout.join('')).toContain('See https://github.com/PyModel/pythinker-code/releases');
520-
} finally {
521-
Object.defineProperty(process, 'platform', { value: originalPlatform });
522-
}
523-
});
502+
it.each(['darwin', 'linux', 'win32'] as const)(
503+
'native: downloads in the background by default on %s',
504+
async (platform) => {
505+
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
506+
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
507+
mocks.detectInstallSource.mockResolvedValue('native');
508+
mockSpawnExit(0);
509+
const originalPlatform = process.platform;
510+
Object.defineProperty(process, 'platform', { value: platform });
511+
try {
512+
const { stdout, options } = captureOutput();
513+
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
514+
expect(promptForInstallChoice).not.toHaveBeenCalled();
515+
expect(mocks.spawn).toHaveBeenCalledWith(
516+
process.execPath,
517+
['__update_download', '0.5.0'],
518+
platform === 'win32'
519+
? { detached: true, stdio: 'ignore', windowsHide: true }
520+
: { detached: true, stdio: 'ignore' },
521+
);
522+
expect(stdout.join('')).not.toContain('To update manually');
523+
await flushBackgroundInstall();
524+
} finally {
525+
Object.defineProperty(process, 'platform', { value: originalPlatform });
526+
}
527+
},
528+
);
524529

525-
it('native: shows the releases page and does not spawn on win32', async () => {
530+
it('native: asks before downloading when automatic installation is disabled', async () => {
526531
disableAutoInstall();
527532
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
528533
mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
529534
mocks.detectInstallSource.mockResolvedValue('native');
530-
mocks.promptForInstallChoice.mockResolvedValue('install');
531-
mockSpawnExit(0);
532-
const originalPlatform = process.platform;
533-
Object.defineProperty(process, 'platform', { value: 'win32' });
534-
try {
535-
expect(canAutoInstall('native', 'win32')).toBe(false);
536-
expect(installCommandFor('native', '0.5.0', 'win32')).toBe(
537-
'See https://github.com/PyModel/pythinker-code/releases',
538-
);
539-
const { stdout, options } = captureOutput();
540-
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
541-
expect(mocks.spawn).not.toHaveBeenCalled();
542-
expect(stdout.join('')).toContain('See https://github.com/PyModel/pythinker-code/releases');
543-
} finally {
544-
Object.defineProperty(process, 'platform', { value: originalPlatform });
545-
}
535+
mocks.promptForInstallChoice.mockResolvedValue('skip');
536+
const { options } = captureOutput();
537+
538+
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
539+
expect(promptForInstallChoice).toHaveBeenCalledTimes(1);
540+
expect(mocks.spawn).not.toHaveBeenCalled();
546541
});
547542

548543
it('unsupported: prints fallback npm command', async () => {
@@ -699,7 +694,7 @@ describe('runUpdatePreflight', () => {
699694
}
700695
});
701696

702-
it('native: does not retry a background install without a download source', async () => {
697+
it('native: retries an orphaned background install when the download lock is free', async () => {
703698
// Orphaned `active`: older than the spawn grace window and the lock is
704699
// free (beforeEach default) ⇒ the previous downloader is gone; retry.
705700
mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0'));
@@ -716,7 +711,12 @@ describe('runUpdatePreflight', () => {
716711
const { options } = captureOutput();
717712

718713
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
719-
expect(mocks.spawn).not.toHaveBeenCalled();
714+
expect(mocks.spawn).toHaveBeenCalledWith(
715+
process.execPath,
716+
['__update_download', '0.5.0'],
717+
expect.objectContaining({ detached: true, stdio: 'ignore' }),
718+
);
719+
await flushBackgroundInstall();
720720
});
721721

722722
it('native: does not re-spawn while the install lock is genuinely held', async () => {

apps/pythinker-code/test/cli/upgrade.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,59 @@ describe('handleUpgrade', () => {
109109
expect(stderr.join('')).toBe('');
110110
});
111111

112+
it('installs a native update after confirmation instead of showing manual instructions', async () => {
113+
const { stdout, writable } = captureOutput();
114+
const deps = createDeps({ source: 'native' });
115+
116+
await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0);
117+
118+
expect(deps.promptForInstallChoice).toHaveBeenCalledTimes(1);
119+
expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin');
120+
expect(stdout.join('')).not.toContain('To update manually');
121+
});
122+
123+
it('reports a native install as staged for the next start rather than already applied', async () => {
124+
const { stdout, writable } = captureOutput();
125+
const deps = createDeps({ latest: '0.5.0', source: 'native' });
126+
127+
await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0);
128+
129+
const output = stdout.join('');
130+
expect(output).toContain('Pythinker Code 0.5.0 is staged; it applies the next time you start the CLI.');
131+
expect(output).not.toContain('Updated @pymodel/pythinker-code to 0.5.0');
132+
});
133+
134+
it('stages a native update without prompting when not interactive', async () => {
135+
const { stdout, writable } = captureOutput();
136+
const deps = createDeps({ latest: '0.5.0', source: 'native', isInteractive: false });
137+
138+
await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0);
139+
140+
expect(deps.promptForInstallChoice).not.toHaveBeenCalled();
141+
expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin');
142+
const output = stdout.join('');
143+
expect(output).toContain('Pythinker Code 0.5.0 is staged; it applies the next time you start the CLI.');
144+
expect(output).not.toContain('To update manually');
145+
});
146+
147+
it('returns a failing exit code when a non-interactive native stage fails', async () => {
148+
const { stdout, stderr, writable } = captureOutput();
149+
const deps = createDeps({
150+
latest: '0.5.0',
151+
source: 'native',
152+
isInteractive: false,
153+
installUpdate: vi.fn().mockRejectedValue(new Error('update install exited with code 1')),
154+
});
155+
156+
await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1);
157+
158+
expect(deps.promptForInstallChoice).not.toHaveBeenCalled();
159+
expect(stderr.join('')).toContain(
160+
'warning: failed to install @pymodel/pythinker-code@0.5.0: update install exited with code 1',
161+
);
162+
expect(stdout.join('')).not.toContain('is staged');
163+
});
164+
112165
it('skips the foreground install when the update prompt is declined', async () => {
113166
const { stdout, writable } = captureOutput();
114167
const deps = createDeps({

docs/guides/getting-started.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ After installation, verify that the executable is ready:
6666
pythinker --version
6767
```
6868

69-
**Upgrade**: automatic updates are enabled by default. npm, pnpm, yarn, bun, and supported native installations update in the background. Homebrew installations download and verify the formula source in the background, then install it on the next interactive launch and restart into the new version. Run `pythinker upgrade` to check immediately. For npm, pnpm, yarn, bun, and macOS / Linux native installations it offers to install the update right away; for Homebrew and Windows native installations it prints the command to run. You can also upgrade directly via the package manager:
69+
**Upgrade**: automatic updates are enabled by default for global npm, pnpm, yarn, bun, and native installations. Native installations on macOS, Linux, and Windows download and verify the update in the background, then apply it on the next start. Homebrew installations require `brew upgrade pythinker-code`.
70+
71+
Run `pythinker update` (or `pythinker upgrade`) to check immediately. In a terminal, the command asks you to confirm. For a native installation in a script or a pipe, there is no prompt and the update downloads immediately. Native updates apply on the next start. To disable background installation, set `[upgrade].auto_install = false` in `~/.pythinker-code/tui.toml`. This setting stops unattended installation, but a startup prompt can still show when an update is available, as it does for a package manager installation. Set `PYTHINKER_CODE_NO_AUTO_UPDATE=1` to also disable automatic checks, startup prompts, and automatic update application.
72+
73+
You can also upgrade directly via the package manager:
7074

7175
```sh
7276
npm install -g @pymodel/pythinker-code@latest

docs/reference/pythinker-command.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,13 @@ pythinker export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log
253253

254254
### `pythinker upgrade`
255255

256-
Immediately check for the latest version and display an update prompt; exits after you make a selection. `pythinker update` is an alias for this command.
256+
Immediately check for the latest version. In a terminal, the command displays an update prompt and exits after you make a selection. `pythinker update` is an alias for this command.
257257

258258
```sh
259259
pythinker upgrade
260260
```
261261

262-
For global npm, pnpm, yarn, and bun installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start. When the current installation method cannot be upgraded automatically, the manual update command is printed instead.
262+
For global npm, pnpm, yarn, and bun installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start; when there is no terminal, such as in a script or a pipe, there is no prompt and the download starts immediately. When the current installation method cannot be upgraded automatically, the manual update command is printed instead.
263263

264264
### `pythinker vis`
265265

0 commit comments

Comments
 (0)