Files
heygen-com--hyperframes/scripts/cli-options.ts
James Russo 17b0db1d3e chore: add release prepare command (#1165)
## What

- Add `bun run release:prepare <version>` as the maintainer-facing stable release entrypoint.
- Make the first run draft missing changelog artifacts and intentionally exit before tagging; rerunning after manual review delegates to `set-version`.
- Tighten the direct `set-version` guard so stable releases also fail when generated TODO changelog copy is still present.
- Update maintainer docs to recommend `release:prepare` while keeping `changelog:draft` as the lower-level regeneration tool.

## Why

Stable releases should be hard to run without reviewed GitHub release notes and Mintlify changelog copy. This keeps the existing manual rewrite step, but makes the expected path one command that engineers can rerun after review.

## How

- Added `scripts/release-prepare.ts` with parsing, draft/review/set-version action selection, and command forwarding.
- Added focused script tests for parser behavior, action selection, command forwarding, and TODO detection.
- Extracted shared script CLI parsing helpers so `changelog:draft` and `release:prepare` use the same option handling.
- Adjusted `changelog:draft --write` so an existing release file is left unchanged unless `--force` is passed, while still allowing a missing docs entry to be added.

## Test plan

- [x] Unit tests added/updated: `bun run test:scripts`
- [x] Format check: `bun run format:check`
- [x] Lint: `bun run lint`
- [x] Typecheck: `bun run --filter '*' typecheck`
- [x] Fallow audit: `bunx fallow audit --base origin/main --fail-on-issues`
- [x] Manual CLI checks: `bun run release:prepare --help`; `bun run set-version 9.9.9` fails before mutation when changelog artifacts are missing
- [x] Documentation updated
2026-06-02 20:34:29 -04:00

175 lines
4.3 KiB
TypeScript

export type InlineValueOption<Key extends string> = {
prefix: string;
key: Key;
};
export const CLI_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
type ParserConfig<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
> = {
inlineValueOptions: Array<InlineValueOption<ValueKey>>;
valueOptions: Map<string, ValueKey>;
booleanOptions: Map<string, BooleanKey>;
parsePositional: (arg: string, index: number) => number;
fail: (message: string) => never;
};
export function parseMappedArgument<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
) {
const arg = args[index];
if (applyInlineValueOption(arg, parsed, config.inlineValueOptions)) {
return index;
}
if (applyBooleanOption(arg, parsed, config.booleanOptions)) {
return index;
}
return applyValueOrPositionalOption(args, index, parsed, config, arg);
}
function applyInlineValueOption<Parsed extends object, ValueKey extends keyof Parsed & string>(
arg: string,
parsed: Parsed,
inlineOptions: Array<InlineValueOption<ValueKey>>,
) {
const option = inlineOptions.find((candidate) => arg.startsWith(candidate.prefix));
if (!option) {
return false;
}
parsed[option.key] = arg.slice(option.prefix.length) as Parsed[ValueKey];
return true;
}
function applyBooleanOption<Parsed extends object, BooleanKey extends keyof Parsed & string>(
arg: string,
parsed: Parsed,
booleanOptions: Map<string, BooleanKey>,
) {
const option = booleanOptions.get(arg);
if (!option) {
return false;
}
parsed[option] = true as Parsed[BooleanKey];
return true;
}
function applyValueOrPositionalOption<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
arg: string,
) {
const option = config.valueOptions.get(arg);
if (!option) {
return config.parsePositional(arg, index);
}
parsed[option] = readNextArg(args, index, arg, config.fail) as Parsed[ValueKey];
return index + 1;
}
export function parseVersionOptionArgument<
Parsed extends { version?: string },
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: Omit<ParserConfig<Parsed, ValueKey, BooleanKey>, "parsePositional"> & {
printUsage: () => void;
},
) {
return parseMappedArgument(args, index, parsed, {
...config,
parsePositional: (arg, positionalIndex) =>
parseVersionOrHelp(arg, positionalIndex, parsed, config),
});
}
function parseVersionOrHelp<Parsed extends { version?: string }>(
arg: string,
index: number,
parsed: Parsed,
config: { printUsage: () => void; fail: (message: string) => never },
) {
if (arg === "--help" || arg === "-h") {
config.printUsage();
process.exit(0);
}
parsed.version = parseVersionPositionalArg(arg, parsed.version, config.fail);
return index;
}
export function parseVersionPositionalArg(
arg: string,
currentVersion: string | undefined,
fail: (message: string) => never,
) {
if (arg.startsWith("--")) {
fail(`Unknown option: ${arg}`);
}
if (currentVersion) {
fail(`Unexpected positional argument: ${arg}`);
}
return arg.replace(/^v/, "");
}
export function readNextArg(
args: string[],
index: number,
flag: string,
fail: (message: string) => never,
) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
fail(`Missing value for ${flag}`);
}
return value;
}
export function validateCliVersion(
version: string,
pattern: RegExp,
fail: (message: string) => never,
) {
if (!pattern.test(version)) {
fail(`Invalid semver: ${version}`);
}
}
export function validateCliDate(date: string, fail: (message: string) => never) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
fail(`Invalid date: ${date}. Expected YYYY-MM-DD.`);
}
}
export function optionalFlagArg(flag: string, enabled: boolean) {
return enabled ? [flag] : [];
}
export function optionalValueArg(flag: string, value: string | undefined) {
return value ? [flag, value] : [];
}