fix(ci-setup): validate enum flags instead of casting raw strings

--ci/--deploy/--auth/--branch-strategy were cast straight from the raw
commander string (`as AuthMode`, etc.) with no validation — only --port
was checked. A typo like `--auth toekn` is not 'token', so the templates
fell through to the *no-auth* compose, silently producing an
unauthenticated, port-published deployment the user believed was
token-gated; `--ci githubactions` wrote no CI file and rendered a literal
`| undefined |` in GITNEXUS.md.

Add a requireEnum() helper (exact, case-sensitive membership; no
trim/lowercase normalization so `NONE` exits rather than degrading to the
insecure path) called inside each existing option guard, so an omitted
flag still resolves via the prompt/fallback while an explicitly-passed
invalid value exits non-zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 08:57:18 +00:00
parent 0806ac7c44
commit e87e3a4122
2 changed files with 74 additions and 5 deletions

View file

@ -18,6 +18,25 @@ import { generateFiles } from './ci-setup/templates.js';
import type { CiSetupOptions, CiSetupResult, GeneratedFile } from './ci-setup/types.js';
import type { CiSystem, DeployTarget, AuthMode, BranchStrategy } from './ci-setup/types.js';
const CI_SYSTEMS: readonly CiSystem[] = ['github-actions', 'azure-devops', 'both'];
const DEPLOY_TARGETS: readonly DeployTarget[] = ['docker', 'azure-container-app', 'both'];
const AUTH_MODES: readonly AuthMode[] = ['token', 'none'];
const BRANCH_STRATEGIES: readonly BranchStrategy[] = ['pr-scoped', 'main-only'];
/**
* Exact, case-sensitive membership check for an enum flag. Exits non-zero on an
* unrecognized value rather than letting a typo (e.g. `--auth toekn`) fall
* through to a downstream default — which for `--auth` would silently select the
* insecure no-auth deployment. No trim/lowercase normalization: `NONE` exits.
*/
function requireEnum<T extends string>(value: string, allowed: readonly T[], flagName: string): T {
if (!(allowed as readonly string[]).includes(value)) {
console.log(`✗ Invalid ${flagName}: "${value}". Must be one of: ${allowed.join(', ')}.`);
process.exit(1);
}
return value as T;
}
export const ciSetupCommand = async (options?: {
ci?: string;
deploy?: string;
@ -54,10 +73,12 @@ export const ciSetupCommand = async (options?: {
);
console.log(' ⚠ License: PolyForm-Noncommercial — confirm non-commercial use\n');
// Parse and validate options from commander flags
// Parse and validate options from commander flags. An explicitly-passed but
// unrecognized value exits here; an omitted flag (undefined) is skipped by the
// guard and resolves via resolveOptions' prompt/fallback.
const partial: Partial<CiSetupOptions> = {};
if (options?.ci) partial.ci = options.ci as CiSystem;
if (options?.deploy) partial.deploy = options.deploy as DeployTarget;
if (options?.ci) partial.ci = requireEnum(options.ci, CI_SYSTEMS, '--ci');
if (options?.deploy) partial.deploy = requireEnum(options.deploy, DEPLOY_TARGETS, '--deploy');
if (options?.port) {
const portNum = parseInt(options.port as string, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65534) {
@ -66,8 +87,13 @@ export const ciSetupCommand = async (options?: {
}
partial.port = portNum;
}
if (options?.auth) partial.auth = options.auth as AuthMode;
if (options?.branchStrategy) partial.branchStrategy = options.branchStrategy as BranchStrategy;
if (options?.auth) partial.auth = requireEnum(options.auth, AUTH_MODES, '--auth');
if (options?.branchStrategy)
partial.branchStrategy = requireEnum(
options.branchStrategy,
BRANCH_STRATEGIES,
'--branch-strategy',
);
if (options?.dryRun !== undefined) partial.dryRun = options.dryRun;
if (options?.apply !== undefined) partial.apply = options.apply;
if (options?.yes !== undefined) partial.yes = options.yes;

View file

@ -144,6 +144,49 @@ describe('ciSetupCommand', () => {
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('rejects an invalid --auth value (no insecure no-auth fallthrough)', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => {
throw new Error('process.exit called');
});
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await expect(
ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'toekn', // typo
apply: true,
yes: true,
outputDir: tempDir,
}),
).rejects.toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);
// Nothing was generated — specifically no no-auth docker-compose.
const entries = await fs.readdir(tempDir);
expect(entries).toHaveLength(0);
});
it('rejects a wrong-case --auth value (no normalization into no-auth)', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => {
throw new Error('process.exit called');
});
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await expect(
ciSetupCommand({ ci: 'github-actions', deploy: 'docker', auth: 'NONE', outputDir: tempDir }),
).rejects.toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('rejects an invalid --ci value', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => {
throw new Error('process.exit called');
});
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await expect(
ciSetupCommand({ ci: 'githubactions', deploy: 'docker', outputDir: tempDir }),
).rejects.toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('--auth none generates no Caddyfile', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({