agent-ecosystem/src/main/services/infrastructure/PtyTerminalService.ts
iliya b8aa2d9f14 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
2026-03-25 13:36:12 +02:00

117 lines
3.5 KiB
TypeScript

/**
* PtyTerminalService — manages node-pty terminal instances.
*
* Provides PTY spawning, IO, and lifecycle management for the embedded terminal.
* Events (data, exit) are forwarded to the renderer via mainWindow.webContents.send().
*/
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';
import { createLogger } from '@shared/utils/logger';
import type { PtySpawnOptions } from '@shared/types/terminal';
import type { BrowserWindow } from 'electron';
const logger = createLogger('PtyTerminalService');
// Graceful import: node-pty is a native addon that may not be available
// if electron-rebuild was not run or native build tools are missing.
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
type NodePtyModule = typeof NodePty;
let nodePty: NodePtyModule | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
nodePty = require('node-pty') as NodePtyModule;
} catch {
logger.warn('node-pty not available — terminal features disabled');
}
export class PtyTerminalService {
private ptys = new Map<string, IPty>();
private mainWindow: BrowserWindow | null = null;
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
/**
* Spawn a new PTY process.
* @returns Unique PTY ID for subsequent write/resize/kill calls.
* @throws If node-pty native module is not available.
*/
spawn(options?: PtySpawnOptions): string {
if (!nodePty) {
throw new Error(
'Terminal not available: node-pty native module not found. Run: pnpm install'
);
}
const id = crypto.randomUUID();
const shell =
options?.command ??
(process.platform === 'win32'
? (process.env.COMSPEC ?? 'powershell.exe')
: (process.env.SHELL ?? '/bin/bash'));
const home = getHomeDir();
const pty = nodePty.spawn(shell, options?.args ?? [], {
name: 'xterm-256color',
cols: options?.cols ?? 80,
rows: options?.rows ?? 24,
cwd: options?.cwd ?? home,
env: {
...buildEnrichedEnv(),
...options?.env,
} as Record<string, string>,
});
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
pty.onExit(({ exitCode }) => {
this.send(TERMINAL_EXIT, id, exitCode);
this.ptys.delete(id);
});
this.ptys.set(id, pty);
logger.info(`PTY spawned: ${id} (${shell})`);
return id;
}
write(id: string, data: string): void {
this.ptys.get(id)?.write(data);
}
resize(id: string, cols: number, rows: number): void {
this.ptys.get(id)?.resize(cols, rows);
}
kill(id: string): void {
const pty = this.ptys.get(id);
if (pty) {
pty.kill();
this.ptys.delete(id);
logger.info(`PTY killed: ${id}`);
}
}
/** Kill all PTY processes. Called on app shutdown. */
killAll(): void {
const count = this.ptys.size;
if (count > 0) {
logger.info(`Killing ${count} PTY processes on shutdown`);
}
this.ptys.forEach((pty) => pty.kill());
this.ptys.clear();
}
private send(channel: string, ...args: unknown[]): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
}