Skip to content

Commit 2bb9e9d

Browse files
committed
feat: restart Homebrew-managed installs after update and refine update preflight
Add Homebrew install detection with an update helper and activation step so brew-managed CLIs restart onto the new version after /update, and update the doctor, preflight, and docs accordingly.
1 parent 41a660d commit 2bb9e9d

27 files changed

Lines changed: 1992 additions & 70 deletions
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+
Prepare verified Homebrew updates in the background and install them automatically on the next interactive launch.

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

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@ import { z } from 'zod';
1515

1616
import { getTuiConfigPath, parseTuiConfig } from '#/tui/config';
1717
import { readUpdateCache } from '#/cli/update/cache';
18-
import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from '#/cli/update/preflight';
18+
import { readUpdateInstallState } from '#/cli/update/install-state';
19+
import {
20+
automaticUpdateModeFor,
21+
isAutoUpdateDisabledByEnv,
22+
shouldAutoInstallUpdates,
23+
type AutomaticUpdateMode,
24+
} from '#/cli/update/preflight';
1925
import { detectInstallSource } from '#/cli/update/source';
26+
import type { UpdateInstallFailure } from '#/cli/update/types';
2027
import { getHostPackageRoot, getVersion } from '#/cli/version';
28+
import { getUpdateInstallLogFile } from '#/utils/paths';
2129

2230
interface WritableLike {
2331
write(chunk: string): boolean;
@@ -50,6 +58,12 @@ export interface DoctorRuntimeInfo {
5058
readonly latest: string | null;
5159
readonly checkedAt: string | null;
5260
readonly autoUpdate?: 'on' | 'off' | 'env-disabled';
61+
readonly mode?: AutomaticUpdateMode;
62+
readonly pendingVersion?: string;
63+
readonly pendingRequestedBy?: 'automatic' | 'manual';
64+
readonly activeOperation?: string;
65+
readonly lastFailure?: string;
66+
readonly logPath?: string;
5367
};
5468
}
5569

@@ -166,11 +180,12 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
166180
runtimeInfo:
167181
deps?.runtimeInfo ??
168182
(async () => {
169-
const [installSource, installations, ripgrep, update, autoInstall] = await Promise.all([
183+
const [installSource, installations, ripgrep, update, installState, autoInstall] = await Promise.all([
170184
detectInstallSource(),
171185
findPythinkerExecutables(),
172186
findExistingRg(resolvePythinkerHome()),
173187
readUpdateCache(),
188+
readUpdateInstallState(),
174189
shouldAutoInstallUpdates(),
175190
]);
176191
return {
@@ -184,12 +199,31 @@ function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): Resolv
184199
latest: update.latest,
185200
checkedAt: update.checkedAt,
186201
autoUpdate: isAutoUpdateDisabledByEnv() ? 'env-disabled' : autoInstall ? 'on' : 'off',
202+
mode: automaticUpdateModeFor(installSource, process.platform),
203+
pendingVersion: installState.pending?.version,
204+
pendingRequestedBy: installState.pending?.requestedBy,
205+
activeOperation:
206+
installState.active === null
207+
? undefined
208+
: `${installState.active.operation ?? 'install'} ${installState.active.version}`,
209+
lastFailure:
210+
installState.lastFailure === null
211+
? undefined
212+
: formatUpdateFailure(installState.lastFailure),
213+
logPath: getUpdateInstallLogFile(),
187214
},
188215
};
189216
}),
190217
};
191218
}
192219

220+
function formatUpdateFailure(failure: UpdateInstallFailure): string {
221+
const summary = `${failure.operation ?? 'install'} ${failure.version} ` +
222+
`(attempt ${String(failure.attempts)})`;
223+
const message = failure.message?.replaceAll(/\s+/gu, ' ').trim();
224+
return message === undefined || message === '' ? summary : `${summary}: ${message}`;
225+
}
226+
193227
export async function findPythinkerExecutables(
194228
pathValue = process.env['PATH'],
195229
platform: NodeJS.Platform = process.platform,
@@ -368,25 +402,61 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] {
368402
? []
369403
: [
370404
' Update channel: CDN staged rollout',
371-
...(info.update.autoUpdate === undefined
372-
? []
373-
: [
374-
info.update.autoUpdate === 'env-disabled'
375-
? ' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE'
376-
: ` Auto-update: ${info.update.autoUpdate} (tui.toml [upgrade].auto_install)`,
377-
]),
405+
...formatAutomaticUpdate(info),
378406
...(info.update.latest === null
379407
? [' Latest cached version: unavailable']
380408
: [
381409
` Latest cached version: ${info.update.latest}${
382410
info.update.checkedAt === null ? '' : ` (checked ${info.update.checkedAt})`
383411
}`,
384412
]),
413+
...formatPreparedUpdate(info.update),
414+
...(info.update.activeOperation === undefined
415+
? []
416+
: [` Update operation: ${info.update.activeOperation}`]),
417+
...(info.update.lastFailure === undefined
418+
? []
419+
: [` Last update failure: ${info.update.lastFailure}`]),
420+
...(info.update.logPath === undefined ? [] : [` Update log: ${info.update.logPath}`]),
385421
]),
386422
'',
387423
];
388424
}
389425

426+
function formatPreparedUpdate(
427+
update: NonNullable<DoctorRuntimeInfo['update']>,
428+
): string[] {
429+
if (update.pendingVersion === undefined) return [];
430+
if (update.pendingRequestedBy === 'automatic' && update.autoUpdate !== 'on') {
431+
return [
432+
` Prepared update: ${update.pendingVersion} ` +
433+
'(automatic activation paused until auto-update is enabled)',
434+
];
435+
}
436+
return [` Prepared update: ${update.pendingVersion} (installs on next launch)`];
437+
}
438+
439+
function formatAutomaticUpdate(info: DoctorRuntimeInfo): string[] {
440+
const update = info.update;
441+
if (update?.autoUpdate === undefined) return [];
442+
if (update.autoUpdate === 'env-disabled') {
443+
return [' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE'];
444+
}
445+
if (update.autoUpdate === 'off') {
446+
return [' Auto-update: off (tui.toml [upgrade].auto_install)'];
447+
}
448+
switch (update.mode) {
449+
case 'restart-install':
450+
return [' Auto-update: on (prepare in background; install on next launch)'];
451+
case 'background-install':
452+
return [' Auto-update: on (installs in background)'];
453+
case 'manual':
454+
return [` Auto-update: unavailable for ${info.installSource}`];
455+
case undefined:
456+
return [' Auto-update: on (tui.toml [upgrade].auto_install)'];
457+
}
458+
}
459+
390460
function formatResults(results: readonly CheckResult[]): string[] {
391461
const lines: string[] = [];
392462
for (const result of results) {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { gte, valid } from 'semver';
2+
3+
import { getUpdateInstallLogFile } from '#/utils/paths';
4+
5+
import {
6+
activateHomebrewUpdate,
7+
PreparedHomebrewUpdateInvalidError,
8+
} from './homebrew';
9+
import { tryAcquireUpdateInstallLock, type UpdateInstallLockHandle } from './install-lock';
10+
import { readUpdateInstallState, writeUpdateInstallState } from './install-state';
11+
import { detectInstallSource } from './source';
12+
import type { InstallSource, UpdateInstallState, UpdatePreparedHomebrew } from './types';
13+
14+
const ACTIVATION_FAILURE_LIMIT = 2;
15+
16+
export interface ActivatePendingUpdateDeps {
17+
readonly readState: () => Promise<UpdateInstallState>;
18+
readonly writeState: (state: UpdateInstallState) => Promise<void>;
19+
readonly acquireLock: (
20+
request: { readonly version: string },
21+
) => Promise<UpdateInstallLockHandle | null>;
22+
readonly activateHomebrew: (
23+
prepared: UpdatePreparedHomebrew,
24+
) => Promise<{ readonly version: string; readonly executable: string }>;
25+
readonly detectSource: () => Promise<InstallSource>;
26+
readonly now: () => Date;
27+
readonly pid: number;
28+
}
29+
30+
export interface ActivatePendingUpdateOptions {
31+
readonly enabled: boolean;
32+
readonly automaticEnabled: boolean;
33+
readonly deps?: Partial<ActivatePendingUpdateDeps>;
34+
}
35+
36+
function resolveDeps(overrides: Partial<ActivatePendingUpdateDeps> = {}): ActivatePendingUpdateDeps {
37+
return {
38+
readState: overrides.readState ?? (() => readUpdateInstallState()),
39+
writeState: overrides.writeState ?? ((state) => writeUpdateInstallState(state)),
40+
acquireLock: overrides.acquireLock ?? ((request) => tryAcquireUpdateInstallLock(request)),
41+
activateHomebrew:
42+
overrides.activateHomebrew ??
43+
((prepared) => activateHomebrewUpdate(prepared, { logFile: getUpdateInstallLogFile() })),
44+
detectSource: overrides.detectSource ?? (() => detectInstallSource()),
45+
now: overrides.now ?? (() => new Date()),
46+
pid: overrides.pid ?? process.pid,
47+
};
48+
}
49+
50+
function activationAttempts(state: UpdateInstallState, version: string): number {
51+
const failure = state.lastFailure;
52+
return failure?.version === version && failure.operation === 'activate' ? failure.attempts : 0;
53+
}
54+
55+
function errorMessage(error: unknown): string {
56+
return error instanceof Error ? error.message : String(error);
57+
}
58+
59+
function isRunningPreparedVersion(currentVersion: string, preparedVersion: string): boolean {
60+
return (
61+
valid(currentVersion) !== null &&
62+
valid(preparedVersion) !== null &&
63+
gte(currentVersion, preparedVersion)
64+
);
65+
}
66+
67+
export async function activatePendingUpdate(
68+
currentVersion: string,
69+
options: ActivatePendingUpdateOptions,
70+
) {
71+
if (!options.enabled) return { status: 'none' as const };
72+
const deps = resolveDeps(options.deps);
73+
let state = await deps.readState();
74+
const pending = state.pending;
75+
if (pending === null) return { status: 'none' as const };
76+
if (pending.requestedBy === 'automatic' && !options.automaticEnabled) {
77+
return { status: 'none' as const };
78+
}
79+
80+
if (isRunningPreparedVersion(currentVersion, pending.version)) {
81+
const installedAt = deps.now().toISOString();
82+
await deps.writeState({
83+
active: null,
84+
pending: null,
85+
lastFailure: null,
86+
lastSuccess: {
87+
version: currentVersion,
88+
installedAt,
89+
notifiedAt: null,
90+
},
91+
});
92+
return { status: 'finalized' as const, version: currentVersion };
93+
}
94+
95+
if (await deps.detectSource() !== pending.source) {
96+
await deps.writeState({ ...state, active: null, pending: null });
97+
return { status: 'invalidated' as const, version: pending.version };
98+
}
99+
100+
if (activationAttempts(state, pending.version) >= ACTIVATION_FAILURE_LIMIT) {
101+
return {
102+
status: 'failed' as const,
103+
version: pending.version,
104+
message: `Automatic activation failed ${String(ACTIVATION_FAILURE_LIMIT)} times`,
105+
};
106+
}
107+
108+
const lock = await deps.acquireLock({ version: pending.version });
109+
if (lock === null) return { status: 'in-progress' as const, version: pending.version };
110+
111+
try {
112+
state = await deps.readState();
113+
if (state.pending?.jobId !== pending.jobId) return { status: 'none' as const };
114+
const startedAt = deps.now().toISOString();
115+
const activatingState: UpdateInstallState = {
116+
...state,
117+
active: {
118+
version: pending.version,
119+
source: 'homebrew',
120+
operation: 'activate',
121+
jobId: pending.jobId,
122+
startedAt,
123+
pid: deps.pid,
124+
},
125+
};
126+
await deps.writeState(activatingState);
127+
128+
try {
129+
const activated = await deps.activateHomebrew(pending);
130+
return {
131+
status: 'activated' as const,
132+
version: activated.version,
133+
executable: activated.executable,
134+
};
135+
} catch (error) {
136+
const message = errorMessage(error);
137+
if (error instanceof PreparedHomebrewUpdateInvalidError) {
138+
await deps.writeState({
139+
...activatingState,
140+
active: null,
141+
pending: null,
142+
lastFailure: {
143+
version: pending.version,
144+
failedAt: deps.now().toISOString(),
145+
attempts: 1,
146+
operation: 'prepare',
147+
message,
148+
},
149+
});
150+
return { status: 'invalidated' as const, version: pending.version };
151+
}
152+
const attempts = activationAttempts(activatingState, pending.version) + 1;
153+
await deps.writeState({
154+
...activatingState,
155+
active: null,
156+
lastFailure: {
157+
version: pending.version,
158+
failedAt: deps.now().toISOString(),
159+
attempts,
160+
operation: 'activate',
161+
message,
162+
},
163+
});
164+
return { status: 'failed' as const, version: pending.version, message };
165+
}
166+
} finally {
167+
await lock.release().catch(() => {});
168+
}
169+
}

0 commit comments

Comments
 (0)