mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
fix(copilot): sync alias UX and harden daemon stop safety
This commit is contained in:
+2
-22
@@ -30,6 +30,7 @@ import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { getImageAnalysisHookEnv } from './utils/hooks';
|
||||
import { fail, info, warn } from './utils/ui';
|
||||
import { COPILOT_SUBCOMMAND_TOKENS } from './copilot/constants';
|
||||
|
||||
// Import centralized error handling
|
||||
import { handleError, runCleanup } from './errors';
|
||||
@@ -573,28 +574,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Only route to command handler for known subcommands, otherwise treat as profile
|
||||
const COPILOT_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
'models',
|
||||
'usage',
|
||||
'start',
|
||||
'stop',
|
||||
'enable',
|
||||
'disable',
|
||||
'--auth',
|
||||
'--status',
|
||||
'--models',
|
||||
'--usage',
|
||||
'--start',
|
||||
'--stop',
|
||||
'--enable',
|
||||
'--disable',
|
||||
'help',
|
||||
'--help',
|
||||
'-h',
|
||||
];
|
||||
if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMANDS.includes(args[1])) {
|
||||
if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMAND_TOKENS.includes(args[1])) {
|
||||
// `ccs copilot <subcommand>` - route to copilot command handler
|
||||
const { handleCopilotCommand } = await import('./commands/copilot-command');
|
||||
const exitCode = await handleCopilotCommand(args.slice(1));
|
||||
|
||||
@@ -16,23 +16,13 @@ import {
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
|
||||
import { ok, fail, info, color } from '../utils/ui';
|
||||
import { normalizeCopilotSubcommand } from '../copilot/constants';
|
||||
|
||||
/**
|
||||
* Handle copilot subcommand.
|
||||
*/
|
||||
export async function handleCopilotCommand(args: string[]): Promise<number> {
|
||||
const subcommandAliasMap: Record<string, string> = {
|
||||
'--auth': 'auth',
|
||||
'--status': 'status',
|
||||
'--models': 'models',
|
||||
'--usage': 'usage',
|
||||
'--start': 'start',
|
||||
'--stop': 'stop',
|
||||
'--enable': 'enable',
|
||||
'--disable': 'disable',
|
||||
};
|
||||
const rawSubcommand = args[0];
|
||||
const subcommand = (rawSubcommand && subcommandAliasMap[rawSubcommand]) || rawSubcommand;
|
||||
const subcommand = normalizeCopilotSubcommand(args[0]);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'auth':
|
||||
@@ -88,6 +78,11 @@ function handleHelp(): number {
|
||||
console.log(' 3. ccs copilot start # Start daemon');
|
||||
console.log(' 4. ccs copilot usage # Check quota usage');
|
||||
console.log('');
|
||||
console.log('Flag aliases:');
|
||||
console.log(
|
||||
' ccs copilot --auth | --status | --models | --usage | --start | --stop | --enable | --disable'
|
||||
);
|
||||
console.log('');
|
||||
console.log('Or use the web UI: ccs config → Copilot tab');
|
||||
console.log('');
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared Copilot command tokens and aliases.
|
||||
* Keep all copilot subcommand routing in one place to avoid drift.
|
||||
*/
|
||||
|
||||
export const COPILOT_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
'models',
|
||||
'usage',
|
||||
'start',
|
||||
'stop',
|
||||
'enable',
|
||||
'disable',
|
||||
] as const;
|
||||
|
||||
export type CopilotSubcommand = (typeof COPILOT_SUBCOMMANDS)[number];
|
||||
|
||||
export const COPILOT_FLAG_ALIASES: Readonly<Record<`--${CopilotSubcommand}`, CopilotSubcommand>> =
|
||||
Object.freeze({
|
||||
'--auth': 'auth',
|
||||
'--status': 'status',
|
||||
'--models': 'models',
|
||||
'--usage': 'usage',
|
||||
'--start': 'start',
|
||||
'--stop': 'stop',
|
||||
'--enable': 'enable',
|
||||
'--disable': 'disable',
|
||||
});
|
||||
|
||||
export const COPILOT_SUBCOMMAND_TOKENS = Object.freeze([
|
||||
...COPILOT_SUBCOMMANDS,
|
||||
...Object.keys(COPILOT_FLAG_ALIASES),
|
||||
'help',
|
||||
'--help',
|
||||
'-h',
|
||||
]);
|
||||
|
||||
export function normalizeCopilotSubcommand(token?: string): string | undefined {
|
||||
if (!token) return token;
|
||||
const alias = COPILOT_FLAG_ALIASES[token as keyof typeof COPILOT_FLAG_ALIASES];
|
||||
return alias || token;
|
||||
}
|
||||
@@ -12,8 +12,13 @@ import * as http from 'http';
|
||||
import { CopilotDaemonStatus } from './types';
|
||||
import { CopilotConfig } from '../config/unified-config-types';
|
||||
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
|
||||
import { verifyCopilotDaemonOwnership } from './daemon-process-ownership';
|
||||
|
||||
const PID_FILE = path.join(getCopilotDir(), 'daemon.pid');
|
||||
const DAEMON_HEALTH_MARKER = 'server running';
|
||||
|
||||
function getPidFilePath(): string {
|
||||
return path.join(getCopilotDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if copilot-api daemon is running on the specified port.
|
||||
@@ -43,7 +48,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(body.trim().toLowerCase().includes('server running'));
|
||||
resolve(body.trim().toLowerCase().includes(DAEMON_HEALTH_MARKER));
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -79,9 +84,10 @@ export async function getDaemonStatus(port: number): Promise<CopilotDaemonStatus
|
||||
* Read PID from file.
|
||||
*/
|
||||
function getPidFromFile(): number | null {
|
||||
const pidFile = getPidFilePath();
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
const content = fs.readFileSync(PID_FILE, 'utf8').trim();
|
||||
if (fs.existsSync(pidFile)) {
|
||||
const content = fs.readFileSync(pidFile, 'utf8').trim();
|
||||
const pid = parseInt(content, 10);
|
||||
return isNaN(pid) ? null : pid;
|
||||
}
|
||||
@@ -95,12 +101,13 @@ function getPidFromFile(): number | null {
|
||||
* Write PID to file.
|
||||
*/
|
||||
function writePidToFile(pid: number): void {
|
||||
const pidFile = getPidFilePath();
|
||||
try {
|
||||
const dir = path.dirname(PID_FILE);
|
||||
const dir = path.dirname(pidFile);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
|
||||
fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
@@ -110,9 +117,10 @@ function writePidToFile(pid: number): void {
|
||||
* Remove PID file.
|
||||
*/
|
||||
function removePidFile(): void {
|
||||
const pidFile = getPidFilePath();
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
fs.unlinkSync(PID_FILE);
|
||||
if (fs.existsSync(pidFile)) {
|
||||
fs.unlinkSync(pidFile);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
@@ -250,6 +258,25 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string }
|
||||
}
|
||||
|
||||
try {
|
||||
const ownership = verifyCopilotDaemonOwnership(pid);
|
||||
if (ownership === 'not-running') {
|
||||
removePidFile();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
// PID was reused by an unrelated process.
|
||||
removePidFile();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
// Send SIGTERM to the process
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function executeCopilotProfile(
|
||||
if (!isCopilotApiInstalled()) {
|
||||
console.error(fail('copilot-api is not installed.'));
|
||||
console.error('');
|
||||
console.error('Install with: ccs copilot --install');
|
||||
console.error('Install/repair by running: ccs copilot start');
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown';
|
||||
|
||||
function getProcessCommandLine(pid: number): string | null {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
// /proc cmdline uses null separators between arguments.
|
||||
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine)`;
|
||||
const shells = ['powershell.exe', 'powershell', 'pwsh.exe', 'pwsh'];
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
const result = spawnSync(shell, ['-NoProfile', '-Command', command], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.error) {
|
||||
continue;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout.trim();
|
||||
} catch {
|
||||
// Try next shell candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function verifyCopilotDaemonOwnership(pid: number): DaemonOwnershipStatus {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
return 'not-running';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const commandLine = getProcessCommandLine(pid);
|
||||
if (!commandLine) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// copilot-api is launched as `... copilot-api start --port <n>`
|
||||
const lower = commandLine.toLowerCase();
|
||||
const looksLikeCopilotDaemon = lower.includes('copilot-api') && lower.includes(' start');
|
||||
|
||||
return looksLikeCopilotDaemon ? 'owned' : 'not-owned';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
COPILOT_SUBCOMMANDS,
|
||||
COPILOT_SUBCOMMAND_TOKENS,
|
||||
normalizeCopilotSubcommand,
|
||||
} from '../../../src/copilot/constants';
|
||||
|
||||
describe('copilot command aliases', () => {
|
||||
it('normalizes all supported flag aliases', () => {
|
||||
for (const subcommand of COPILOT_SUBCOMMANDS) {
|
||||
expect(normalizeCopilotSubcommand(`--${subcommand}`)).toBe(subcommand);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps canonical subcommands unchanged', () => {
|
||||
for (const subcommand of COPILOT_SUBCOMMANDS) {
|
||||
expect(normalizeCopilotSubcommand(subcommand)).toBe(subcommand);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns unknown tokens unchanged', () => {
|
||||
expect(normalizeCopilotSubcommand('--unknown')).toBe('--unknown');
|
||||
expect(normalizeCopilotSubcommand('unknown')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('exposes complete routing token list for ccs entrypoint', () => {
|
||||
for (const subcommand of COPILOT_SUBCOMMANDS) {
|
||||
expect(COPILOT_SUBCOMMAND_TOKENS).toContain(subcommand);
|
||||
expect(COPILOT_SUBCOMMAND_TOKENS).toContain(`--${subcommand}`);
|
||||
}
|
||||
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('help');
|
||||
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('--help');
|
||||
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('-h');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import { isDaemonRunning } from '../../../src/copilot/copilot-daemon';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { isDaemonRunning, stopDaemon } from '../../../src/copilot/copilot-daemon';
|
||||
import { getCcsDir } from '../../../src/utils/config-manager';
|
||||
|
||||
const activeServers: http.Server[] = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-copilot-daemon-test-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
@@ -13,6 +26,18 @@ afterEach(async () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
async function createServer(
|
||||
@@ -59,6 +84,16 @@ describe('copilot daemon health detection', () => {
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when root endpoint returns 200 with empty body', async () => {
|
||||
const port = await createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('');
|
||||
});
|
||||
|
||||
const running = await isDaemonRunning(port);
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when root endpoint is non-200', async () => {
|
||||
const port = await createServer((_req, res) => {
|
||||
res.writeHead(503, { 'Content-Type': 'text/plain' });
|
||||
@@ -69,3 +104,39 @@ describe('copilot daemon health detection', () => {
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copilot daemon stop safety', () => {
|
||||
it('does not terminate unrelated process from stale PID file', async () => {
|
||||
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
unrelatedProcess.unref();
|
||||
|
||||
const unrelatedPid = unrelatedProcess.pid;
|
||||
expect(unrelatedPid).toBeDefined();
|
||||
if (!unrelatedPid) {
|
||||
throw new Error('Failed to spawn unrelated process');
|
||||
}
|
||||
|
||||
const pidFile = path.join(getCcsDir(), 'copilot', 'daemon.pid');
|
||||
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
||||
fs.writeFileSync(pidFile, String(unrelatedPid));
|
||||
|
||||
try {
|
||||
const result = await stopDaemon();
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('unable to verify daemon ownership');
|
||||
}
|
||||
|
||||
// Unrelated process should still be alive.
|
||||
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
|
||||
} finally {
|
||||
try {
|
||||
process.kill(unrelatedPid, 'SIGTERM');
|
||||
} catch {
|
||||
// Process already exited.
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user