fix(ci-setup): probe the resolved --port for availability

detectEnvironment ran checkPortAvailable(4747) eagerly, before options
were parsed, and the result was printed as a hardcoded "Port 4747:
available" line — wrong/irrelevant whenever the user passed a different
--port. Export checkPortAvailable, drop the eager hardcoded probe (and the
now-unused portAvailable field from DetectResult), and probe resolved.port
after option resolution, reporting the actual port. This also removes a
latent real-socket bind on 4747 from the default unit-test path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 08:59:42 +00:00
parent e87e3a4122
commit 847e901e39
5 changed files with 39 additions and 12 deletions

View file

@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { detectEnvironment } from './ci-setup/detect.js';
import { detectEnvironment, checkPortAvailable } from './ci-setup/detect.js';
import { resolveOptions } from './ci-setup/prompts.js';
import { generateFiles } from './ci-setup/templates.js';
import type { CiSetupOptions, CiSetupResult, GeneratedFile } from './ci-setup/types.js';
@ -68,9 +68,6 @@ export const ciSetupCommand = async (options?: {
if (detect.hasDocker) {
console.log(' ✓ Docker: docker-compose or Dockerfile found');
}
console.log(
` ${detect.portAvailable ? '✓' : '⚠'} Port 4747: ${detect.portAvailable ? 'available' : 'in use (serve may already be running)'}`,
);
console.log(' ⚠ License: PolyForm-Noncommercial — confirm non-commercial use\n');
// Parse and validate options from commander flags. An explicitly-passed but
@ -105,6 +102,14 @@ export const ciSetupCommand = async (options?: {
}
const resolved = await resolveOptions(detect, partial);
// Probe the *resolved* port (now that --port / prompts are settled) rather
// than a hardcoded default at detection time.
const portAvailable = await checkPortAvailable(resolved.port);
console.log(
` ${portAvailable ? '✓' : '⚠'} Port ${resolved.port}: ${portAvailable ? 'available' : 'in use (serve may already be running)'}`,
);
const files = generateFiles(resolved, detect);
const result: CiSetupResult = { generated: [], skipped: [], errors: [] };

View file

@ -105,7 +105,12 @@ async function detectPrimaryLanguage(repoRoot: string): Promise<string> {
return top;
}
function checkPortAvailable(port: number): Promise<boolean> {
/**
* Whether `port` can be bound on the loopback interface. Exported so the caller
* can probe the *resolved* --port (known only after option parsing) rather than
* a hardcoded default at detection time.
*/
export function checkPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
@ -124,17 +129,15 @@ export async function detectEnvironment(cwd: string): Promise<DetectResult> {
gitRoot: null,
detectedCi: null,
hasDocker: false,
portAvailable: false,
primaryLanguage: 'Unknown',
};
}
const [detectedCi, hasDocker, portAvailable, primaryLanguage] = await Promise.all([
const [detectedCi, hasDocker, primaryLanguage] = await Promise.all([
detectCiSystem(gitRoot),
detectDocker(gitRoot),
checkPortAvailable(4747),
detectPrimaryLanguage(gitRoot),
]);
return { gitRoot, detectedCi, hasDocker, portAvailable, primaryLanguage };
return { gitRoot, detectedCi, hasDocker, primaryLanguage };
}

View file

@ -19,7 +19,6 @@ export interface DetectResult {
gitRoot: string | null;
detectedCi: CiSystem | null;
hasDocker: boolean;
portAvailable: boolean;
primaryLanguage: string;
}

View file

@ -7,7 +7,6 @@ const DEFAULT_DETECT: DetectResult = {
gitRoot: '/repo',
detectedCi: 'github-actions',
hasDocker: true,
portAvailable: true,
primaryLanguage: 'TypeScript',
};

View file

@ -231,6 +231,28 @@ describe('ciSetupCommand', () => {
const entries = await fs.readdir(tempDir);
expect(entries).toHaveLength(0);
});
it('probes the resolved --port for availability (not a hardcoded 4747)', async () => {
vi.resetModules();
vi.doMock('../../src/cli/ci-setup/detect.js', async () => {
const actual = await vi.importActual<typeof import('../../src/cli/ci-setup/detect.js')>(
'../../src/cli/ci-setup/detect.js',
);
return { ...actual, checkPortAvailable: vi.fn(() => Promise.resolve(true)) };
});
const detectMod = await import('../../src/cli/ci-setup/detect.js');
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'token',
port: '4748',
dryRun: true,
outputDir: tempDir,
});
expect(vi.mocked(detectMod.checkPortAvailable)).toHaveBeenCalledWith(4748);
vi.doUnmock('../../src/cli/ci-setup/detect.js');
});
});
describe('resolveOptions (U1: interactive prompts reachable)', () => {
@ -238,7 +260,6 @@ describe('resolveOptions (U1: interactive prompts reachable)', () => {
gitRoot: '/tmp/repo',
detectedCi: null,
hasDocker: false,
portAvailable: true,
primaryLanguage: 'TypeScript',
};
let originalTTY: boolean | undefined;