import { execFileSync } from 'node:child_process' import { compare, valid as isValidSemver } from 'semver' export const PUBLISHED_CLI_TAG_PREFIX = 'cli-' export const PUBLISHED_CLI_TAG_PATTERN = /^cli-[0-9]/ export const PUBLISHED_CLI_RPC_PATTERN = /\.rpc\(\s*['"`]([a-z][a-z0-9_]*)['"`]/g export interface PublishedCliRpcCall { name: string /** null when the RPC argument expression cannot be resolved statically (e.g. a variable). */ argKeys: string[] | null } export type GitRunner = (args: string[]) => string function defaultGitRunner(args: string[]): string { return execFileSync('git', args, { encoding: 'utf8' }).trim() } export function comparePublishedCliTags(left: string, right: string): number { const leftVersion = left.slice(PUBLISHED_CLI_TAG_PREFIX.length) const rightVersion = right.slice(PUBLISHED_CLI_TAG_PREFIX.length) if (isValidSemver(leftVersion) && isValidSemver(rightVersion)) return compare(leftVersion, rightVersion) const parse = (tag: string) => tag.replace(PUBLISHED_CLI_TAG_PREFIX, '').split(/[.-]/).map(part => Number.parseInt(part, 10) || 0) const leftParts = parse(left) const rightParts = parse(right) const length = Math.max(leftParts.length, rightParts.length) for (let index = 0; index < length; index++) { const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0) if (delta !== 0) return delta } return 0 } function listPublishedCliTags(runGit: GitRunner): string[] { const output = runGit(['tag', '-l', `${PUBLISHED_CLI_TAG_PREFIX}*`]) const tags = output .split('\n') .map(tag => tag.trim()) .filter(tag => PUBLISHED_CLI_TAG_PATTERN.test(tag)) if (tags.length === 0) throw new Error(`No published CLI tags found (expected ${PUBLISHED_CLI_TAG_PREFIX})`) return tags.sort(comparePublishedCliTags) } export function resolveLatestPublishedCliTag(runGit: GitRunner = defaultGitRunner): string { return listPublishedCliTags(runGit).at(-1)! } export function resolvePublishedCliNpmVersion(tag: string): string { if (!PUBLISHED_CLI_TAG_PATTERN.test(tag)) throw new Error(`Expected a published CLI tag, got ${tag}`) return tag.slice(PUBLISHED_CLI_TAG_PREFIX.length) } type NpmRunner = (args: string[]) => string function defaultNpmRunner(args: string[]): string { return execFileSync('npm', args, { encoding: 'utf8' }).trim() } export function normalizePublishedCliNpmVersions(parsed: unknown): string[] { if (Array.isArray(parsed)) return parsed.filter((version): version is string => typeof version === 'string') if (typeof parsed === 'string') return [parsed] throw new Error(`Unexpected @capgo/cli versions response: ${JSON.stringify(parsed)}`) } export function resolvePublishedCliNpmInstallVersion( tag: string, runNpm: NpmRunner = defaultNpmRunner, ): string { const targetVersion = resolvePublishedCliNpmVersion(tag) const publishedVersions = normalizePublishedCliNpmVersions(JSON.parse(runNpm(['view', '@capgo/cli', 'versions', '--json']))) if (publishedVersions.includes(targetVersion)) return targetVersion const installable = publishedVersions .filter(version => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${version}`, tag) <= 0) .sort((left, right) => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${left}`, `${PUBLISHED_CLI_TAG_PREFIX}${right}`)) .at(-1) if (!installable) { throw new Error( `No published @capgo/cli npm version found for git tag ${tag}. ` + `Latest npm release is ${publishedVersions.at(-1) ?? 'unknown'}.`, ) } return installable } export function resolvePublishedCliRpcSourceTag( latestTag: string, npmInstallVersion: string, runGit: GitRunner = defaultGitRunner, ): string { const latestVersion = resolvePublishedCliNpmVersion(latestTag) if (latestVersion === npmInstallVersion) return latestTag const installTag = `${PUBLISHED_CLI_TAG_PREFIX}${npmInstallVersion}` try { runGit(['rev-parse', '-q', '--verify', `refs/tags/${installTag}`]) return installTag } catch { throw new Error( `Published CLI npm version ${npmInstallVersion} has no matching git tag ${installTag}. ` + `Cannot verify RPC contract without the CLI source for the package under test.`, ) } } function findMatchingCloseParen(source: string, openParenIndex: number): number { let depth = 0 let inString: '"' | '\'' | '`' | null = null let escaped = false for (let index = openParenIndex; index < source.length; index++) { const char = source[index] if (inString) { if (escaped) { escaped = false continue } if (char === '\\') { escaped = true continue } if (char === inString) inString = null continue } if (char === '"' || char === '\'' || char === '`') { inString = char continue } if (char === '(') depth++ else if (char === ')') { depth-- if (depth === 0) return index } } return -1 } function extractKeysFromObjectLiteral(source: string, objectStart: number): string[] { let depth = 0 let inString: '"' | '\'' | '`' | null = null let escaped = false for (let index = objectStart; index < source.length; index++) { const char = source[index] if (inString) { if (escaped) { escaped = false continue } if (char === '\\') { escaped = true continue } if (char === inString) inString = null continue } if (char === '"' || char === '\'' || char === '`') { inString = char continue } if (char === '{') { depth++ continue } if (char === '}') { depth-- if (depth === 0) { const argsSource = source.slice(objectStart + 1, index) const keys = new Set() for (const segment of splitTopLevelArgSegments(argsSource)) { const trimmed = segment.trim() if (!trimmed) continue const explicit = trimmed.match(/^([a-zA-Z_][\w]*)\s*:/) if (explicit) { keys.add(explicit[1]!) continue } const shorthand = trimmed.match(/^([a-zA-Z_][\w]*)$/) if (shorthand) keys.add(shorthand[1]!) } return [...keys].sort() } } } return [] } function extractArgKeysFromRpcArgExpression(source: string, argStart: number, rpcCloseParen: number): string[] { const keys = new Set() let inString: '"' | '\'' | '`' | null = null let escaped = false let braceDepth = 0 for (let index = argStart; index < rpcCloseParen; index++) { const char = source[index] if (inString) { if (escaped) { escaped = false continue } if (char === '\\') { escaped = true continue } if (char === inString) inString = null continue } if (char === '"' || char === '\'' || char === '`') { inString = char continue } if (char === '{') { if (braceDepth === 0) { for (const key of extractKeysFromObjectLiteral(source, index)) keys.add(key) } braceDepth++ continue } if (char === '}') braceDepth-- } return [...keys].sort() } export function extractArgKeysFromRpcCall(source: string, afterRpcNameIndex: number): string[] | null { const rpcOpenParenIndex = source.lastIndexOf('(', afterRpcNameIndex) if (rpcOpenParenIndex === -1) return [] const rpcCloseParen = findMatchingCloseParen(source, rpcOpenParenIndex) if (rpcCloseParen === -1) return [] let cursor = afterRpcNameIndex const castMatch = source.slice(cursor).match(/^\s*as\s+any/) if (castMatch) cursor += castMatch[0].length const remainder = source.slice(cursor).trimStart() if (remainder.startsWith(')')) return [] const commaMatch = source.slice(cursor).match(/^\s*,/) if (!commaMatch) return null cursor += commaMatch[0].length const afterComma = source.slice(cursor).trimStart() if (afterComma.startsWith('{')) { const objectStart = source.indexOf('{', cursor) if (objectStart === -1 || objectStart >= rpcCloseParen) return null return extractKeysFromObjectLiteral(source, objectStart) } const keys = extractArgKeysFromRpcArgExpression(source, cursor, rpcCloseParen) if (keys.length > 0) return keys const argExpression = source.slice(cursor, rpcCloseParen).trim() return argExpression ? null : [] } function splitTopLevelArgSegments(source: string): string[] { const segments: string[] = [] let start = 0 let depth = 0 let inString: '"' | '\'' | '`' | null = null let escaped = false for (let index = 0; index < source.length; index++) { const char = source[index] if (inString) { if (escaped) { escaped = false continue } if (char === '\\') { escaped = true continue } if (char === inString) inString = null continue } if (char === '"' || char === '\'' || char === '`') { inString = char continue } if (char === '{' || char === '[' || char === '(') depth++ else if (char === '}' || char === ']' || char === ')') depth-- else if (char === ',' && depth === 0) { segments.push(source.slice(start, index)) start = index + 1 } } if (start < source.length) segments.push(source.slice(start)) return segments } function formatPublishedCliRpcCallKey(call: PublishedCliRpcCall): string { if (call.argKeys === null) return `${call.name}(?)` return `${call.name}(${call.argKeys.join(',')})` } function comparePublishedCliRpcCalls(left: PublishedCliRpcCall, right: PublishedCliRpcCall): number { const byName = left.name.localeCompare(right.name) if (byName !== 0) return byName return formatPublishedCliRpcCallKey(left).localeCompare(formatPublishedCliRpcCallKey(right)) } function rpcNameEndIndex(source: string, match: RegExpMatchArray): number { return (match.index ?? 0) + match[0].length } export function extractPublishedCliRpcCallsFromSource(source: string): PublishedCliRpcCall[] { const calls = new Map() for (const match of source.matchAll(PUBLISHED_CLI_RPC_PATTERN)) { const name = match[1]! const argKeys = extractArgKeysFromRpcCall(source, rpcNameEndIndex(source, match)) const call = { name, argKeys } calls.set(formatPublishedCliRpcCallKey(call), call) } return [...calls.values()].sort(comparePublishedCliRpcCalls) } // git grep exits 1 when nothing matches: that is an empty result, not a failure. function isGitGrepNoMatch(error: unknown) { return !!error && typeof error === 'object' && 'status' in error && error.status === 1 } export function extractPublishedCliRpcCalls(tag: string, runGit: GitRunner = defaultGitRunner): PublishedCliRpcCall[] { let output: string try { output = runGit(['grep', '-l', '-E', String.raw`\.rpc\(["'\`][a-z][a-z0-9_]*["'\`]`, tag, '--', 'cli/src']) } catch (error) { if (isGitGrepNoMatch(error)) return [] throw error } const files = output .split('\n') .map(line => line.trim()) .filter(Boolean) .map((line) => { const tagPrefix = `${tag}:` return line.startsWith(tagPrefix) ? line.slice(tagPrefix.length) : line }) const calls = new Map() for (const file of files) { const source = runGit(['show', `${tag}:${file}`]) for (const call of extractPublishedCliRpcCallsFromSource(source)) { calls.set(formatPublishedCliRpcCallKey(call), call) } } return [...calls.values()].sort(comparePublishedCliRpcCalls) } // Newer CLIs stop calling RPCs directly, but customers still run older releases that do. // Walk back from sourceTag to the newest published CLI that still calls RPCs so their grants stay protected. export function resolvePublishedCliRpcContract( sourceTag: string, runGit: GitRunner = defaultGitRunner, ): { rpcSourceTag: string, rpcCalls: PublishedCliRpcCall[] } { const candidates = listPublishedCliTags(runGit) .filter(tag => comparePublishedCliTags(tag, sourceTag) <= 0) .reverse() for (const tag of candidates) { const rpcCalls = extractPublishedCliRpcCalls(tag, runGit) if (rpcCalls.length > 0) return { rpcSourceTag: tag, rpcCalls } } return { rpcSourceTag: sourceTag, rpcCalls: [] } } export function formatPublishedCliRpcCall(call: PublishedCliRpcCall): string { if (call.argKeys === null) return `${call.name}()` if (call.argKeys.length === 0) return `${call.name}()` return `${call.name}({ ${call.argKeys.join(', ')} })` } export function rpcCallMatchesOverload( call: PublishedCliRpcCall, argNames: string[] | null, defaultCount: number, argCount: number, ): boolean { if (call.argKeys === null) return true const names = argNames ?? [] if (call.argKeys.length === 0) return argCount === 0 || defaultCount === argCount const provided = new Set(call.argKeys) if (!call.argKeys.every(key => names.includes(key))) return false const requiredNames = names.slice(0, Math.max(argCount - defaultCount, 0)) return requiredNames.every(name => provided.has(name)) }