fix(auth): enrich PTY env and invalidate status cache after login

- Use buildEnrichedEnv() in PtyTerminalService so login terminal gets
  full PATH (Homebrew, nvm, etc.) and USER for Keychain lookup
- Add cliInstaller:invalidateStatus IPC to clear cached auth status
  after successful login, preventing stale "not logged in" responses
- Show "Verifying authentication..." spinner instead of flashing
  the "Not logged in" banner between modal close and status refresh

Ref #27
This commit is contained in:
iliya 2026-03-25 13:36:12 +02:00
parent 60d80cde70
commit b8aa2d9f14
9 changed files with 64 additions and 5 deletions

View file

@ -10,6 +10,7 @@
import {
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import { getErrorMessage } from '@shared/utils/errorHandling';
@ -39,6 +40,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
logger.info('CLI installer handlers registered');
}
@ -49,6 +51,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
logger.info('CLI installer handlers removed');
}
@ -105,3 +108,8 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
return { success: false, error: msg };
}
}
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
cachedStatus = null;
return { success: true, data: undefined };
}

View file

@ -7,6 +7,7 @@
import crypto from 'node:crypto';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getHomeDir } from '@main/utils/pathDecoder';
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels';
@ -65,9 +66,7 @@ export class PtyTerminalService {
rows: options?.rows ?? 24,
cwd: options?.cwd ?? home,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
...buildEnrichedEnv(),
...options?.env,
} as Record<string, string>,
});

View file

@ -405,6 +405,9 @@ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
/** CLI installer progress events (main -> renderer) */
export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
/** Invalidate cached CLI status (forces fresh check on next getStatus) */
export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus';
// =============================================================================
// Terminal API Channels
// =============================================================================

View file

@ -10,6 +10,7 @@ import {
APP_RELAUNCH,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS,
CLI_INSTALLER_PROGRESS,
CONTEXT_CHANGED,
CONTEXT_GET_ACTIVE,
@ -1293,6 +1294,9 @@ const electronAPI: ElectronAPI = {
install: async (): Promise<void> => {
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
},
invalidateStatus: async (): Promise<void> => {
return invokeIpcWithResult<void>(CLI_INSTALLER_INVALIDATE_STATUS);
},
onProgress: (callback: (event: unknown, data: CliInstallerProgress) => void): (() => void) => {
ipcRenderer.on(
CLI_INSTALLER_PROGRESS,

View file

@ -1045,6 +1045,7 @@ export class HttpAPIClient implements ElectronAPI {
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
},
invalidateStatus: async (): Promise<void> => {},
onProgress: (): (() => void) => {
return () => {};
},

View file

@ -272,11 +272,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
installerRawChunks,
completedVersion,
fetchCliStatus,
invalidateCliStatus,
installCli,
isBusy,
} = useCliInstaller();
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
useEffect(() => {
if (!isElectron) return;
@ -526,6 +528,22 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed but not logged in — yellow warning banner
if (cliStatus.installed && !cliStatus.authLoggedIn) {
if (isVerifyingAuth) {
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4"
style={{
borderColor: VARIANT_STYLES.info.border,
backgroundColor: VARIANT_STYLES.info.bg,
}}
>
<RefreshCw className="size-4 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Verifying authentication...
</p>
</div>
);
}
return (
<>
<div
@ -565,10 +583,26 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
args={['auth', 'login']}
onClose={() => {
setShowLoginTerminal(false);
void fetchCliStatus();
setIsVerifyingAuth(true);
void (async () => {
try {
await invalidateCliStatus();
await fetchCliStatus();
} finally {
setIsVerifyingAuth(false);
}
})();
}}
onExit={() => {
void fetchCliStatus();
setIsVerifyingAuth(true);
void (async () => {
try {
await invalidateCliStatus();
await fetchCliStatus();
} finally {
setIsVerifyingAuth(false);
}
})();
}}
autoCloseOnSuccessMs={4000}
successMessage="Login complete"

View file

@ -29,6 +29,7 @@ export function useCliInstaller(): {
installerRawChunks: string[];
completedVersion: string | null;
fetchCliStatus: () => Promise<void>;
invalidateCliStatus: () => Promise<void>;
installCli: () => void;
isBusy: boolean;
} {
@ -44,6 +45,7 @@ export function useCliInstaller(): {
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
const completedVersion = useStore((s) => s.cliCompletedVersion);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
const installCli = useStore((s) => s.installCli);
const isBusy = installerState !== 'idle' && installerState !== 'error';
@ -61,6 +63,7 @@ export function useCliInstaller(): {
installerRawChunks,
completedVersion,
fetchCliStatus,
invalidateCliStatus,
installCli,
isBusy,
};

View file

@ -42,6 +42,7 @@ export interface CliInstallerSlice {
// Actions
fetchCliStatus: () => Promise<void>;
invalidateCliStatus: () => Promise<void>;
installCli: () => void;
}
@ -90,6 +91,10 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return cliStatusInFlight;
},
invalidateCliStatus: async () => {
await api.cliInstaller?.invalidateStatus();
},
installCli: () => {
set({
cliInstallerState: 'checking',

View file

@ -83,6 +83,8 @@ export interface CliInstallerAPI {
getStatus: () => Promise<CliInstallationStatus>;
/** Start install/update flow. Progress sent via onProgress events. */
install: () => Promise<void>;
/** Invalidate cached status (forces fresh check on next getStatus) */
invalidateStatus: () => Promise<void>;
/** Subscribe to progress events. Returns cleanup function. */
onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void;
}