Skip to content

src/input.ts

161 lines · 4.9 KB · TypeScript
Raw
/** Strict input helpers for agent-facing exports (reject unknown keys, one contract). */

export type PrLocator = {
	owner: string
	repo: string
	prNumber: number
	prUrl: string
	/** Lowercased `owner/repo#n` storage key. */
	key: string
}

export const locatorKeys = ['prUrl', 'owner', 'repo', 'prNumber'] as const

export type LocatorInput = {
	prUrl?: string
	owner?: string
	repo?: string
	prNumber?: number
}

export function asRecord(raw: unknown, exportName: string): Record<string, unknown> {
	if (raw == null) return {}
	if (typeof raw !== 'object' || Array.isArray(raw)) {
		throw new Error(`${exportName}: pass a JSON object of params.`)
	}
	return raw as Record<string, unknown>
}

export function rejectUnknownKeys(
	input: Record<string, unknown>,
	allowed: ReadonlyArray<string>,
	exportName: string,
) {
	const unknown = Object.keys(input).filter(
		(key) => !allowed.includes(key) && input[key] !== undefined,
	)
	if (unknown.length) {
		throw new Error(
			`${exportName}: unknown param(s) ${unknown.map((k) => JSON.stringify(k)).join(', ')}. Accepted: ${allowed.join(', ')}.`,
		)
	}
}

export function parseLocator(input: Record<string, unknown>, exportName: string): PrLocator {
	const prUrl = typeof input.prUrl === 'string' ? input.prUrl.trim() : ''
	if (prUrl) {
		const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/(?:pull|issues)\/(\d+)/i)
		if (!match) {
			throw new Error(
				`${exportName}: could not parse a GitHub PR URL from ${JSON.stringify(prUrl)}. Pass https://github.com/owner/repo/pull/123.`,
			)
		}
		return makeLocator(match[1], match[2], Number(match[3]))
	}
	const owner = typeof input.owner === 'string' ? input.owner.trim() : ''
	const repo = typeof input.repo === 'string' ? input.repo.trim() : ''
	const prNumber = Number(input.prNumber)
	if (!owner || !repo || !Number.isInteger(prNumber) || prNumber <= 0) {
		throw new Error(`${exportName}: pass prUrl, or owner + repo + prNumber.`)
	}
	return makeLocator(owner, repo, prNumber)
}

export function makeLocator(owner: string, repo: string, prNumber: number): PrLocator {
	return {
		owner,
		repo,
		prNumber,
		prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
		key: `${owner}/${repo}#${prNumber}`.toLowerCase(),
	}
}

export function optionalString(
	input: Record<string, unknown>,
	key: string,
	exportName: string,
): string | undefined {
	const value = input[key]
	if (value == null) return undefined
	if (typeof value !== 'string') throw new Error(`${exportName}: ${key} must be a string.`)
	const trimmed = value.trim()
	return trimmed || undefined
}

export function requiredString(
	input: Record<string, unknown>,
	key: string,
	exportName: string,
	hint = '',
): string {
	const value = optionalString(input, key, exportName)
	if (!value) throw new Error(`${exportName}: ${key} is required.${hint ? ` ${hint}` : ''}`)
	return value
}

export function optionalBoolean(
	input: Record<string, unknown>,
	key: string,
	exportName: string,
): boolean | undefined {
	const value = input[key]
	if (value == null) return undefined
	if (typeof value !== 'boolean') throw new Error(`${exportName}: ${key} must be true or false.`)
	return value
}

export function oneOf<T extends string>(
	input: Record<string, unknown>,
	key: string,
	values: ReadonlyArray<T>,
	exportName: string,
	required: true,
): T
export function oneOf<T extends string>(
	input: Record<string, unknown>,
	key: string,
	values: ReadonlyArray<T>,
	exportName: string,
	required?: false,
): T | undefined
export function oneOf<T extends string>(
	input: Record<string, unknown>,
	key: string,
	values: ReadonlyArray<T>,
	exportName: string,
	required = false,
): T | undefined {
	const value = input[key]
	if (value == null || value === '') {
		if (required) throw new Error(`${exportName}: ${key} is required (${values.join(' | ')}).`)
		return undefined
	}
	// Case-insensitive: 'medium' and 'Medium' both resolve to the canonical spelling.
	const match = typeof value === 'string' ? values.find((v) => v.toLowerCase() === value.trim().toLowerCase()) : undefined
	if (!match) {
		throw new Error(`${exportName}: ${key} must be one of ${values.join(' | ')}; got ${JSON.stringify(value)}.`)
	}
	return match
}

/**
 * Shell-safe example local CLI call for a package export.
 *
 * `@kodycodes/cli` rejects `--local` together with `--invoke` (`--invoke` runs
 * in Kody cloud), so the local form is a one-line static-import passthrough via
 * `--code` — the same thin passthrough `--invoke` would mint.
 */
export function exampleInvoke(
	exportSubpath: string,
	params: Record<string, unknown>,
	purpose: string,
	pkg = '@kentcdodds/ship-pr',
): { purpose: string; command: string } {
	const ref = exportSubpath ? `kody:${pkg}/${exportSubpath.replace(/^\.\//, '')}` : `kody:${pkg}`
	const json = JSON.stringify(params).replace(/'/g, `'\\''`)
	return {
		purpose,
		command: `npx @kodycodes/cli execute --local --code 'import run from "${ref}"; export default (p) => run(p)' --params '${json}'`,
	}
}