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
16 changes: 15 additions & 1 deletion packages/docs/src/pages/guide.astro
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,21 @@ export WARDEN_ANTHROPIC_API_KEY=sk-ant-...`}
/>
</Terminal>

<p>Warden analyzes staged and unstaged changes, running any skills that match via your configured triggers.</p>
<p>Warden analyzes all uncommitted changes (staged and unstaged) against HEAD, running any skills that match via your configured triggers.</p>

<h3>Review Only Staged Changes</h3>

<p>For pre-commit workflows, analyze only what you're about to commit:</p>

<Terminal showCopy={true}>
<Code
code={`warden --staged`}
lang="bash"
theme="vitesse-black"
/>
</Terminal>

<p>This uses <code>git diff --cached</code> so only staged files are analyzed.</p>

<h3>Review Before Pushing</h3>

Expand Down
5 changes: 5 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const CLIOptionsSchema = z.object({
list: z.boolean().default(false),
/** Force interpretation of ambiguous targets as git refs */
git: z.boolean().default(false),
/** Analyze only staged changes (git diff --cached) */
staged: z.boolean().default(false),
/** Remote repository reference for skills (e.g., "owner/repo" or "owner/repo@sha") */
remote: z.string().optional(),
/** Skip network operations - only use cached remote skills */
Expand Down Expand Up @@ -109,6 +111,7 @@ Options:
--fix Automatically apply all suggested fixes
--parallel <n> Max concurrent trigger/skill executions (default: 4)
-x, --fail-fast Stop after first finding
--staged Analyze only staged changes
--git Force ambiguous targets to be treated as git refs
--quiet Errors and final summary only
-v, --verbose Show real-time findings and hunk details
Expand Down Expand Up @@ -291,6 +294,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs
'fail-fast': { type: 'boolean', short: 'x', default: false },
parallel: { type: 'string' },
git: { type: 'boolean', default: false },
staged: { type: 'boolean', default: false },
log: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'V', default: false },
Expand Down Expand Up @@ -465,6 +469,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs
force: values.force,
parallel: values.parallel ? parseInt(values.parallel, 10) : undefined,
git: values.git,
staged: values.staged,
log: values.log,
offline: values.offline,
failFast: values['fail-fast'],
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function createOptions(overrides: Partial<CLIOptions> = {}): CLIOptions {
force: false,
list: false,
git: false,
staged: false,
offline: false,
failFast: false,
...overrides,
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function createDefaultOptions(overrides: Partial<CLIOptions> = {}): CLIOptions {
force: false,
list: false,
git: false,
staged: false,
offline: false,
failFast: false,
log: false,
Expand Down
222 changes: 222 additions & 0 deletions src/cli/commands/setup-app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { runSetupApp } from './setup-app.js';
import { Reporter, parseVerbosity } from '../output/index.js';
import type { SetupAppOptions } from '../args.js';

// Mock all external dependencies so we never hit the network or filesystem.
vi.mock('./setup-app/manifest.js', () => ({
buildManifest: () => ({ name: 'test-app', url: 'http://localhost', public: false }),
}));

vi.mock('./setup-app/browser.js', () => ({
openBrowser: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('./setup-app/credentials.js', () => ({
exchangeCodeForCredentials: vi.fn().mockResolvedValue({
id: 12345,
name: 'test-app',
pem: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----',
htmlUrl: 'https://github.com/apps/test-app',
}),
}));

vi.mock('../git.js', () => ({
getGitHubRepoUrl: () => 'https://github.com/test/repo',
}));

// We need fine-grained control over the callback server mock, so we build it
// per-test in a factory that the mock delegates to.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let serverFactory: () => any;

vi.mock('./setup-app/server.js', () => ({
startCallbackServer: (..._args: unknown[]) => serverFactory(),
}));

function createTestReporter(): Reporter {
const mode = { isTTY: false, supportsColor: false, columns: 80 };
return new Reporter(mode, parseVerbosity(false, 0, false));
}

function createOptions(overrides: Partial<SetupAppOptions> = {}): SetupAppOptions {
return {
port: 3456,
timeout: 60,
open: false,
...overrides,
};
}

/**
* Build a fake server handle whose `server` is an EventEmitter so we can
* simulate 'error' events. The `waitForCallback` promise can be resolved or
* rejected externally.
*/
function createMockServerHandle() {
const emitter = new EventEmitter();
let resolveCallback!: (v: { code: string }) => void;
let rejectCallback!: (e: Error) => void;
const waitForCallback = new Promise<{ code: string }>((resolve, reject) => {
resolveCallback = resolve;
rejectCallback = reject;
});
const close = vi.fn();

return {
handle: {
server: emitter,
waitForCallback,
close,
startUrl: 'http://localhost:3456/start',
},
resolveCallback,
rejectCallback,
close,
};
}

describe('runSetupApp', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

describe('server error handling', () => {
it('returns exit code 1 and calls close() when the server emits EADDRINUSE', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();
const errorSpy = vi.spyOn(reporter, 'error');

// Emit the error on the next microtask so `runSetupApp` has time to
// register its listener and start awaiting the promise race.
const errorObj: NodeJS.ErrnoException = new Error('listen EADDRINUSE: address already in use');
errorObj.code = 'EADDRINUSE';

setTimeout(() => mock.handle.server.emit('error', errorObj), 5);

const exitCode = await runSetupApp(createOptions(), reporter);

expect(exitCode).toBe(1);
expect(mock.close).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('already in use'),
);
});

it('returns exit code 1 for generic server errors', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();
const errorSpy = vi.spyOn(reporter, 'error');

const errorObj: NodeJS.ErrnoException = new Error('something broke');
errorObj.code = 'EACCES';

setTimeout(() => mock.handle.server.emit('error', errorObj), 5);

const exitCode = await runSetupApp(createOptions(), reporter);

expect(exitCode).toBe(1);
expect(mock.close).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Server error: something broke'),
);
});

it('does not call process.exit on server error', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);

const errorObj: NodeJS.ErrnoException = new Error('port in use');
errorObj.code = 'EADDRINUSE';

setTimeout(() => mock.handle.server.emit('error', errorObj), 5);

await runSetupApp(createOptions(), reporter);

expect(exitSpy).not.toHaveBeenCalled();
});
});

describe('cleanup on errors', () => {
it('calls serverHandle.close() even when waitForCallback rejects', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();

setTimeout(() => mock.rejectCallback(new Error('Timeout')), 5);

const exitCode = await runSetupApp(createOptions(), reporter);

expect(exitCode).toBe(1);
expect(mock.close).toHaveBeenCalled();
});
});

describe('happy path', () => {
it('returns exit code 0 on successful callback exchange', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();

setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);

const exitCode = await runSetupApp(createOptions(), reporter);

expect(exitCode).toBe(0);
expect(mock.close).toHaveBeenCalled();
});
});

describe('URL display', () => {
it('shows URL when open is false', async () => {
const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();
const textSpy = vi.spyOn(reporter, 'text');

setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);

await runSetupApp(createOptions({ open: false }), reporter);

const textCalls = textSpy.mock.calls.map((c) => c[0]);
expect(textCalls).toContainEqual(
expect.stringContaining('Open this URL in your browser'),
);
});

it('shows URL when browser open fails', async () => {
const { openBrowser } = await import('./setup-app/browser.js');
vi.mocked(openBrowser).mockRejectedValueOnce(new Error('xdg-open not found'));

const mock = createMockServerHandle();
serverFactory = () => mock.handle;

const reporter = createTestReporter();
const textSpy = vi.spyOn(reporter, 'text');
const warnSpy = vi.spyOn(reporter, 'warning');

setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);

await runSetupApp(createOptions({ open: true }), reporter);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Could not open browser'),
);
const textCalls = textSpy.mock.calls.map((c) => c[0]);
expect(textCalls).toContainEqual(
expect.stringContaining('Open this URL in your browser'),
);
});
});
});
34 changes: 20 additions & 14 deletions src/cli/commands/setup-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,29 +48,35 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter):
org,
});

// Handle server errors (e.g., port already in use)
serverHandle.server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reporter.error(`Port ${port} is already in use. Try a different port with --port <number>`);
} else {
reporter.error(`Server error: ${error.message}`);
}
process.exit(1);
// Handle server errors (e.g., port already in use) by racing a rejection
// against the callback promise so the error flows into the catch block below.
const serverError = new Promise<never>((_, reject) => {
serverHandle.server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${port} is already in use. Try a different port with --port <number>`));
} else {
reject(new Error(`Server error: ${error.message}`));
}
});
});
// Prevent unhandled rejection if the server errors before Promise.race is reached
serverError.catch((_e: unknown) => undefined);

try {
// Open browser to our local server (which will POST to GitHub)
let showUrl = !open;
if (open) {
reporter.step('Opening browser...');
try {
await openBrowser(serverHandle.startUrl);
} catch {
reporter.warning('Could not open browser automatically.');
reporter.blank();
reporter.text('Open this URL in your browser:');
reporter.text(chalk.cyan(serverHandle.startUrl));
showUrl = true;
}
} else {
}

// Show the URL if the browser was not opened (or failed to open)
if (showUrl) {
reporter.blank();
reporter.text('Open this URL in your browser:');
reporter.text(chalk.cyan(serverHandle.startUrl));
Expand All @@ -81,8 +87,8 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter):
reporter.blank();
reporter.text(chalk.dim('Waiting for GitHub callback... (Ctrl+C to cancel)'));

// Wait for callback
const { code } = await serverHandle.waitForCallback;
// Wait for callback, but also abort if the server errors out
const { code } = await Promise.race([serverHandle.waitForCallback, serverError]);

// Exchange code for credentials
reporter.blank();
Expand Down
11 changes: 9 additions & 2 deletions src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface LocalContextOptions {
cwd?: string;
/** Override auto-detected default branch (from config) */
defaultBranch?: string;
/** Analyze only staged changes (git diff --cached) */
staged?: boolean;
}

/**
Expand All @@ -49,12 +51,14 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event
const { owner, name } = getRepoName(cwd);
const defaultBranch = options.defaultBranch ?? getDefaultBranch(cwd);

const base = options.base ?? defaultBranch;
// When staged, always diff against HEAD (index vs HEAD)
const staged = options.staged ?? false;
const base = staged ? 'HEAD' : (options.base ?? defaultBranch);
const head = options.head; // undefined means working tree
const currentBranch = getCurrentBranch(cwd);
const headSha = head ? head : getHeadSha(cwd);

const changedFiles = getChangedFilesWithPatches(base, head, cwd);
const changedFiles = getChangedFilesWithPatches(base, head, cwd, { staged });
const files = changedFiles.map(toFileChange);

// Use actual commit message when analyzing a specific commit
Expand All @@ -64,6 +68,9 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event
const commitMsg = getCommitMessage(head, cwd);
title = commitMsg.subject || `Commit ${head}`;
body = commitMsg.body || `Analyzing changes in ${head}`;
} else if (staged) {
title = `Staged changes: ${currentBranch}`;
body = `Analyzing staged changes`;
} else {
title = `Local changes: ${currentBranch}`;
body = `Analyzing local changes from ${base} to working tree`;
Expand Down
Loading