Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 41 additions & 40 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
"scripts": {
"d": "npm install && npm run dev",
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev:main": "tsc -p tsconfig.main.json && mkdir -p dist/main && cp src/main/appConfig.json dist/main/appConfig.json && electron dist/main/main/entry.js --dev",
"dev:main": "tsc -p tsconfig.main.json && node scripts/copy-app-config.cjs && electron dist/main/main/entry.js --dev",
"dev:renderer": "vite",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json && mkdir -p dist/main && cp src/main/appConfig.json dist/main/appConfig.json",
"build:main": "tsc -p tsconfig.main.json && node scripts/copy-app-config.cjs",
"build:renderer": "vite build",
"package": "npm run build && electron-builder",
"package:mac": "npm run build && electron-builder --mac",
"package:linux": "npm run build && electron-builder --linux --publish never",
"package:win": "npm run build && electron-builder --win --publish never",
"postinstall": "electron-rebuild",
"postinstall": "node scripts/postinstall.cjs",
"rebuild": "electron-rebuild -f -v 30.5.1",
"clean": "rm -rf node_modules dist",
"clean": "node scripts/clean.cjs",
"reset": "npm run clean && npm install",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
Expand Down
8 changes: 8 additions & 0 deletions scripts/clean.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const fs = require('fs');
const path = require('path');

const repoRoot = path.resolve(__dirname, '..');

for (const dir of ['node_modules', 'dist']) {
fs.rmSync(path.join(repoRoot, dir), { recursive: true, force: true });
}
10 changes: 10 additions & 0 deletions scripts/copy-app-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const fs = require('fs');
const path = require('path');

const repoRoot = path.resolve(__dirname, '..');
const src = path.join(repoRoot, 'src', 'main', 'appConfig.json');
const destDir = path.join(repoRoot, 'dist', 'main');
const dest = path.join(destDir, 'appConfig.json');

fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
45 changes: 45 additions & 0 deletions scripts/postinstall.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const { spawnSync } = require('child_process');
const path = require('path');

function getElectronRebuildBin() {
const binName = process.platform === 'win32' ? 'electron-rebuild.cmd' : 'electron-rebuild';
return path.resolve(__dirname, '..', 'node_modules', '.bin', binName);
}

function runElectronRebuild(onlyModules) {
const electronRebuildBin = getElectronRebuildBin();
const args = ['-f'];

if (onlyModules && onlyModules.length > 0) {
args.push('--only', onlyModules.join(','));
}

const result =
process.platform === 'win32'
? spawnSync(electronRebuildBin, args, { stdio: 'inherit', shell: true })
: spawnSync(electronRebuildBin, args, { stdio: 'inherit' });

if (result.error) {
// eslint-disable-next-line no-console
console.error('postinstall: failed to run electron-rebuild:', result.error);
}
// spawnSync.status is the numeric exit code, null when terminated by signal.
if (result.status === 0) return;
process.exit(typeof result.status === 'number' ? result.status : 1);
}

const disablePty = process.env.EMDASH_DISABLE_PTY === '1';
const disableNativeDb = process.env.EMDASH_DISABLE_NATIVE_DB === '1';

// Keep this list explicit: these are the native modules we ship/unpack.
const nativeModules = [];
if (!disableNativeDb) nativeModules.push('sqlite3');
if (!disablePty) nativeModules.push('node-pty');
nativeModules.push('keytar');

if (nativeModules.length === 0) {
// Nothing to rebuild; skip quietly.
process.exit(0);
}

runElectronRebuild(nativeModules);
35 changes: 33 additions & 2 deletions src/main/services/ConnectionsService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn, execFileSync } from 'child_process';
import { BrowserWindow } from 'electron';
import { extname } from 'node:path';
import { providerStatusCache, type ProviderStatus } from './providerStatusCache';
import { listDetectableProviders, type ProviderDefinition } from '@shared/providers/registry';
import { log } from '../lib/logger';
Expand Down Expand Up @@ -232,7 +233,12 @@ class ConnectionsService {
const resolvedPath = this.resolveCommandPath(command);
return new Promise((resolve) => {
try {
const child = spawn(command, args);
const execPath = resolvedPath ?? command;
const execExt = process.platform === 'win32' ? extname(execPath).toLowerCase() : '';
const needsShell =
process.platform === 'win32' &&
(execExt === '.cmd' || execExt === '.bat' || execExt === '.ps1');
const child = spawn(execPath, args, { shell: needsShell, windowsHide: true });

let stdout = '';
let stderr = '';
Expand Down Expand Up @@ -318,7 +324,32 @@ class ConnectionsService {
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return lines[0] ?? null;

if (process.platform !== 'win32') {
return lines[0] ?? null;
}

// On Windows, `where <cmd>` can return an extensionless shim first (e.g. `codex`)
// which is not directly executable via CreateProcess, causing ENOENT at spawn time.
// Prefer actual executable extensions when present.
const extensionPreference: Record<string, number> = {
'.exe': 0,
'.cmd': 1,
'.bat': 2,
'.com': 3,
'.ps1': 50,
'': 100,
};

const best = [...lines].sort((a, b) => {
const aExt = extname(a).toLowerCase();
const bExt = extname(b).toLowerCase();
const aRank = extensionPreference[aExt] ?? extensionPreference[''];
const bRank = extensionPreference[bExt] ?? extensionPreference[''];
return aRank - bRank;
})[0];

return best ?? null;
} catch {
return null;
}
Expand Down
18 changes: 18 additions & 0 deletions src/main/services/GitHubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,24 @@ export class GitHubService {
// First check if gh CLI is authenticated system-wide
const isGHAuth = await this.isGHCLIAuthenticated();
if (isGHAuth) {
// Best-effort: if user is logged in via gh but we don't have a stored token yet,
// retrieve it and store it for subsequent gh command retries and API calls.
try {
const existingToken = await this.getStoredToken();
if (!existingToken) {
const { stdout } = await this.execGH('gh auth token');
const token = String(stdout || '').trim();
if (token) {
try {
await this.storeToken(token);
} catch {
// Non-fatal: user is still authenticated via gh CLI
}
}
}
} catch {
// Non-fatal: user is still authenticated via gh CLI
}
return true;
}

Expand Down
82 changes: 77 additions & 5 deletions src/main/services/PrGenerationService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,67 @@
import { exec, execFile, spawn } from 'child_process';
import { exec, execFile, execFileSync, spawn } from 'child_process';
import { extname } from 'node:path';
import { promisify } from 'util';
import { log } from '../lib/logger';
import { getProvider, PROVIDER_IDS, type ProviderId } from '../../shared/providers/registry';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

const cliPathCache = new Map<string, string | null>();

function resolveCliPath(command: string): string | null {
if (!command) return null;

// If it's already a path (absolute or relative), just use it.
if (command.includes('/') || command.includes('\\')) return command;

if (cliPathCache.has(command)) return cliPathCache.get(command) ?? null;

const resolver = process.platform === 'win32' ? 'where' : 'which';
try {
const result = execFileSync(resolver, [command], { encoding: 'utf8' });
const lines = result
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);

if (process.platform !== 'win32') {
const resolved = lines[0] ?? null;
cliPathCache.set(command, resolved);
return resolved;
}

// Prefer actual executable extensions on Windows (avoid extensionless shims like `%APPDATA%\\npm\\codex`).
const extensionPreference: Record<string, number> = {
'.exe': 0,
'.cmd': 1,
'.bat': 2,
'.com': 3,
'.ps1': 50,
'': 100,
};

const best = [...lines].sort((a, b) => {
const aExt = extname(a).toLowerCase();
const bExt = extname(b).toLowerCase();
const aRank = extensionPreference[aExt] ?? extensionPreference[''];
const bRank = extensionPreference[bExt] ?? extensionPreference[''];
return aRank - bRank;
})[0];

const resolved = best ?? null;
cliPathCache.set(command, resolved);
return resolved;
} catch {
cliPathCache.set(command, null);
return null;
}
}

function shouldUseShellForWindows(execPath: string): boolean {
if (process.platform !== 'win32') return false;
const ext = extname(execPath).toLowerCase();
return ext === '.cmd' || ext === '.bat' || ext === '.ps1';
}

export interface GeneratedPrContent {
title: string;
Expand Down Expand Up @@ -254,10 +311,23 @@ export class PrGenerationService {
return null;
}

const resolvedCliPath = resolveCliPath(cliCommand) ?? cliCommand;
const needsShell = shouldUseShellForWindows(resolvedCliPath);

// Check if provider CLI is available
try {
await execFileAsync(cliCommand, provider.versionArgs || ['--version'], {
cwd: taskPath,
await new Promise<void>((resolve, reject) => {
const child = spawn(resolvedCliPath, provider.versionArgs || ['--version'], {
cwd: taskPath,
stdio: ['ignore', 'pipe', 'pipe'],
shell: needsShell,
windowsHide: true,
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`Exit code ${code ?? 'null'}`));
});
});
} catch {
log.debug(`Provider ${providerId} CLI not available`);
Expand Down Expand Up @@ -293,9 +363,11 @@ export class PrGenerationService {
}

// Spawn the provider CLI
const child = spawn(cliCommand, args, {
const child = spawn(resolvedCliPath, args, {
cwd: taskPath,
stdio: ['pipe', 'pipe', 'pipe'],
shell: needsShell,
windowsHide: true,
env: {
...process.env,
// Ensure we have a proper terminal environment
Expand Down
37 changes: 37 additions & 0 deletions src/main/services/providerCli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { buildProviderCliArgs, detectProviderFromShellCommand } from './providerCli';
import { getProvider } from '@shared/providers/registry';

describe('providerCli', () => {
it('detects provider from Windows shim path', () => {
const provider = detectProviderFromShellCommand(
'C:\\\\Users\\\\User\\\\AppData\\\\Roaming\\\\npm\\\\codex.cmd'
);
expect(provider?.id).toBe('codex');
});

it('detects provider from bare command', () => {
const provider = detectProviderFromShellCommand('codex');
expect(provider?.id).toBe('codex');
});

it('builds args with auto-approve', () => {
const provider = getProvider('codex');
expect(provider).toBeTruthy();
expect(provider?.autoApproveFlag).toBeTruthy();
const args = buildProviderCliArgs(provider!, { autoApprove: true });
expect(args).toContain(provider!.autoApproveFlag!);
});

it('includes resume flag unless skipped', () => {
const provider = getProvider('claude');
expect(provider).toBeTruthy();

const args = buildProviderCliArgs(provider!, { skipResume: false });
expect(args.slice(0, 2)).toEqual(['-c', '-r']);

const skipped = buildProviderCliArgs(provider!, { skipResume: true });
expect(skipped).not.toContain('-c');
expect(skipped).not.toContain('-r');
});
});
47 changes: 47 additions & 0 deletions src/main/services/providerCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import path from 'path';
import { PROVIDERS, type ProviderDefinition } from '@shared/providers/registry';

export function detectProviderFromShellCommand(
shellCommand: string | undefined
): ProviderDefinition | undefined {
if (!shellCommand) return undefined;

const base = path.basename(shellCommand).toLowerCase();
const baseNoExt = base.replace(/\.(exe|cmd|bat|com)$/i, '');
if (!baseNoExt) return undefined;

return PROVIDERS.find((p) => (p.cli || '').toLowerCase() === baseNoExt);
}

export function buildProviderCliArgs(
provider: ProviderDefinition,
options: {
autoApprove?: boolean;
initialPrompt?: string;
skipResume?: boolean;
}
): string[] {
const { autoApprove, initialPrompt, skipResume } = options;
const cliArgs: string[] = [];

if (provider.resumeFlag && !skipResume) {
cliArgs.push(...provider.resumeFlag.split(' ').filter(Boolean));
}

if (provider.defaultArgs?.length) {
cliArgs.push(...provider.defaultArgs);
}

if (autoApprove && provider.autoApproveFlag) {
cliArgs.push(provider.autoApproveFlag);
}

if (provider.initialPromptFlag !== undefined && initialPrompt?.trim()) {
if (provider.initialPromptFlag) {
cliArgs.push(provider.initialPromptFlag);
}
cliArgs.push(initialPrompt.trim());
}

return cliArgs;
}
Loading