Skip to content

Commit 0c9b0ab

Browse files
fix: suppress headless stdout leaks
1 parent 3752542 commit 0c9b0ab

File tree

9 files changed

+108
-25
lines changed

9 files changed

+108
-25
lines changed

src/commands/model.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { outro, log } from '@clack/prompts';
33
import { getConfig, setConfigs } from '../utils/config-runtime.js';
44
import { getProvider } from '../feature/providers/index.js';
55
import { selectModel } from '../feature/models.js';
6+
import { KnownError, handleCommandError } from '../utils/error.js';
7+
import { isInteractive } from '../utils/headless.js';
68

79
export default command(
810
{
@@ -15,6 +17,12 @@ export default command(
1517
},
1618
() => {
1719
(async () => {
20+
if (!isInteractive()) {
21+
throw new KnownError(
22+
'Interactive terminal required for model selection.'
23+
);
24+
}
25+
1826
const config = await getConfig();
1927

2028
if (!config.provider) {
@@ -59,9 +67,6 @@ export default command(
5967
} else {
6068
outro('Model selection cancelled');
6169
}
62-
})().catch((error) => {
63-
console.error(`❌ Model selection failed: ${error.message}`);
64-
process.exit(1);
65-
});
70+
})().catch(handleCommandError);
6671
}
6772
);

src/commands/pr.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { generateText } from 'ai';
99
import { createOpenAI } from '@ai-sdk/openai';
1010
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
1111
import { KnownError, handleCommandError } from '../utils/error.js';
12+
import { isInteractive } from '../utils/headless.js';
1213

1314
type GitProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure';
1415

@@ -80,6 +81,12 @@ export default command(
8081
},
8182
() => {
8283
(async () => {
84+
if (!isInteractive()) {
85+
throw new KnownError(
86+
'Interactive terminal required for PR creation.'
87+
);
88+
}
89+
8390
intro(bgCyan(black(' aicommits pr ')));
8491

8592
await assertGitRepo();

src/commands/prepare-commit-msg-hook.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getConfig } from '../utils/config-runtime.js';
66
import { getProvider } from '../feature/providers/index.js';
77
import { generateCommitMessage } from '../utils/openai.js';
88
import { KnownError, handleCommandError } from '../utils/error.js';
9+
import { isHeadless } from '../utils/headless.js';
910

1011
const [messageFilePath, commitSource] = process.argv.slice(2);
1112

@@ -28,7 +29,10 @@ export default () =>
2829
return;
2930
}
3031

31-
intro(bgCyan(black(' aicommits ')));
32+
const headless = isHeadless();
33+
if (!headless) {
34+
intro(bgCyan(black(' aicommits ')));
35+
}
3236

3337
const config = await getConfig({});
3438

@@ -60,8 +64,8 @@ export default () =>
6064
// Use the unified model or provider default
6165
let model = config.OPENAI_MODEL || providerInstance.getDefaultModel();
6266

63-
const s = spinner();
64-
s.start('The AI is analyzing your changes');
67+
const s = headless ? null : spinner();
68+
s?.start('The AI is analyzing your changes');
6569
let messages: string[];
6670
try {
6771
const result = await generateCommitMessage(
@@ -79,7 +83,7 @@ export default () =>
7983
);
8084
messages = result.messages;
8185
} finally {
82-
s.stop('Changes analyzed');
86+
s?.stop('Changes analyzed');
8387
}
8488

8589
/**
@@ -119,5 +123,7 @@ export default () =>
119123
const newContent = instructions + '\n' + currentContent;
120124
await fs.writeFile(messageFilePath, newContent);
121125

122-
outro(`${green('✔')} Saved commit message!`);
126+
if (!headless) {
127+
outro(`${green('✔')} Saved commit message!`);
128+
}
123129
})().catch(handleCommandError);

src/commands/setup.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
getAvailableProviders,
88
getProviderBaseUrl,
99
} from '../feature/providers/index.js';
10+
import { KnownError, handleCommandError } from '../utils/error.js';
11+
import { isInteractive } from '../utils/headless.js';
1012

1113
export default command(
1214
{
@@ -18,6 +20,12 @@ export default command(
1820
},
1921
(argv) => {
2022
(async () => {
23+
if (!isInteractive()) {
24+
throw new KnownError(
25+
'Interactive terminal required for setup. Run `aicommits setup` in a terminal.'
26+
);
27+
}
28+
2129
let config = await getConfig();
2230

2331
const providerOptions = getAvailableProviders();
@@ -156,9 +164,6 @@ export default command(
156164
// console.error(`❌ Failed to create git alias: ${(error as Error).message}`);
157165
// }
158166
// }
159-
})().catch((error) => {
160-
console.error(`❌ Setup failed: ${error.message}`);
161-
process.exit(1);
162-
});
167+
})().catch(handleCommandError);
163168
}
164169
);

src/utils/commit-helpers.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { KnownError } from './error.js';
2+
import { isInteractive } from './headless.js';
23

34
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
45

@@ -21,9 +22,6 @@ export const getCommitMessage = async (
2122
const { select, confirm, isCancel } = await import('@clack/prompts');
2223
const { dim } = await import('kolorist');
2324

24-
// Check if interactive prompts are available
25-
const isInteractive = process.stdout.isTTY && !process.env.CI;
26-
2725
// Single message case
2826
if (messages.length === 1) {
2927
const [message] = messages;
@@ -32,7 +30,7 @@ export const getCommitMessage = async (
3230
return message;
3331
}
3432

35-
if (!isInteractive) {
33+
if (!isInteractive()) {
3634
throw new KnownError('Interactive terminal required for commit message confirmation. Use --yes flag to skip confirmation.');
3735
}
3836

@@ -49,7 +47,7 @@ export const getCommitMessage = async (
4947
return messages[0];
5048
}
5149

52-
if (!isInteractive) {
50+
if (!isInteractive()) {
5351
throw new KnownError('Interactive terminal required for commit message selection. Use --yes flag to skip selection and use the first message.');
5452
}
5553

@@ -59,4 +57,4 @@ export const getCommitMessage = async (
5957
});
6058

6159
return isCancel(selected) ? null : (selected as string);
62-
};
60+
};

src/utils/headless.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
export const isHeadless = () => !process.stdout.isTTY;
1+
export const isHeadless = () => !process.stdin.isTTY || !process.stdout.isTTY;
2+
3+
export const isInteractive = () =>
4+
Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);

src/utils/openai.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
44
import { KnownError } from './error.js';
55
import type { CommitType } from './config-types.js';
66
import { generatePrompt, commitTypeFormats } from './prompt.js';
7+
import { isHeadless } from './headless.js';
8+
9+
const shouldLogDebug = () =>
10+
Boolean(process.env.DEBUG || process.env.AICOMMITS_DEBUG) && !isHeadless();
711

812
/**
913
* Extracts the actual response from reasoning model outputs.
@@ -82,7 +86,7 @@ export const generateCommitMessage = async (
8286
customPrompt?: string,
8387
headers?: Record<string, string>
8488
) => {
85-
if (process.env.DEBUG) {
89+
if (shouldLogDebug()) {
8690
console.log('Diff being sent to AI:');
8791
console.log(diff);
8892
}
@@ -164,8 +168,6 @@ export const generateCommitMessage = async (
164168
} catch (error) {
165169
const errorAsAny = error as any;
166170

167-
console.log(errorAsAny);
168-
169171
if (errorAsAny.code === 'ENOTFOUND') {
170172
throw new KnownError(
171173
`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`
@@ -261,8 +263,6 @@ Do not add thanks, explanations, or any text outside the commit message.`;
261263
} catch (error) {
262264
const errorAsAny = error as any;
263265

264-
console.log(errorAsAny);
265-
266266
throw errorAsAny;
267267
}
268268
};

tests/specs/cli/headless.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { testSuite, expect } from 'manten';
2+
import { createFixture, createGit } from '../../utils.js';
3+
4+
export default testSuite(({ describe }) => {
5+
describe('Headless mode', ({ test }) => {
6+
test('setup requires an interactive terminal', async () => {
7+
const { fixture, aicommits } = await createFixture();
8+
9+
const { stdout, stderr, exitCode } = await aicommits(['setup'], {
10+
reject: false,
11+
env: {
12+
CI: '1',
13+
},
14+
});
15+
16+
expect(exitCode).toBe(1);
17+
expect(stdout).toBe('');
18+
expect(stderr).toMatch('Interactive terminal required for setup');
19+
20+
await fixture.rm();
21+
});
22+
23+
test('model requires an interactive terminal', async () => {
24+
const { fixture, aicommits } = await createFixture();
25+
26+
const { stdout, stderr, exitCode } = await aicommits(['model'], {
27+
reject: false,
28+
env: {
29+
CI: '1',
30+
},
31+
});
32+
33+
expect(exitCode).toBe(1);
34+
expect(stdout).toBe('');
35+
expect(stderr).toMatch('Interactive terminal required for model selection');
36+
37+
await fixture.rm();
38+
});
39+
40+
test('pr requires an interactive terminal', async () => {
41+
const { fixture, aicommits } = await createFixture();
42+
await createGit(fixture.path);
43+
44+
const { stdout, stderr, exitCode } = await aicommits(['pr'], {
45+
reject: false,
46+
env: {
47+
CI: '1',
48+
},
49+
});
50+
51+
expect(exitCode).toBe(1);
52+
expect(stdout).toBe('');
53+
expect(stderr).toMatch('Interactive terminal required for PR creation');
54+
55+
await fixture.rm();
56+
});
57+
});
58+
});

tests/specs/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { testSuite } from 'manten';
33
export default testSuite(({ describe }) => {
44
describe('CLI', ({ runTestSuite }) => {
55
runTestSuite(import('./error-cases.js'));
6+
runTestSuite(import('./headless.js'));
67
runTestSuite(import('./commits.js'));
78
runTestSuite(import('./no-verify.js'));
89
});

0 commit comments

Comments
 (0)