/** 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 { 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 } export function rejectUnknownKeys( input: Record, allowed: ReadonlyArray, 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, 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, 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, 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, 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( input: Record, key: string, values: ReadonlyArray, exportName: string, required: true, ): T export function oneOf( input: Record, key: string, values: ReadonlyArray, exportName: string, required?: false, ): T | undefined export function oneOf( input: Record, key: string, values: ReadonlyArray, 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, 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}'`, } }