Skip to content
← Public packages

@kentcdodds/codemod-runner

Run codemods over your own saved packages: scan, dry-run with real publish checks and diffs, apply, and revert.

src/sessions.ts

173 lines · 4.7 KB · TypeScript
import { kody } from 'kody:runtime'
import { mapPool } from './async-pool.ts'

export type SessionInfo = {
	id: string
	base_commit: string
	published_commit: string | null
}

/**
 * Opens a fresh throwaway session for a package. Omitting `conversation_id`
 * guarantees a brand-new session, so the runner can never resume (or
 * clobber) an editing session the user has in flight. The user's own
 * sessions live in their own overlays; unpublished pushed work surfaces as
 * base/published drift and is skipped by callers.
 */
export async function openFreshSession(input: {
	packageId: string
}): Promise<SessionInfo> {
	const session = (await kody.repoOpenSession({
		target: { kind: 'package', package_id: input.packageId },
	})) as SessionInfo
	return session
}

export async function discardSession(sessionId: string): Promise<void> {
	try {
		await kody.repoDiscardSession({ session_id: sessionId })
	} catch {
		// Best-effort: published sessions and expired sessions may refuse.
	}
}

/**
 * Enumerates the session's file paths with an every-line regex match.
 * Limitation: files with zero lines (completely empty files) do not match
 * and are invisible to codemods run through this runner.
 */
export async function listSessionFiles(
	sessionId: string,
): Promise<Array<string>> {
	const result = (await kody.repoSearch({
		session_id: sessionId,
		pattern: '^',
		mode: 'regex',
		output_mode: 'files',
		limit: 5000,
	})) as { files: Array<{ path: string }>; truncated: boolean }
	if (result.truncated) {
		throw new Error('Package has too many files for the codemod runner.')
	}
	return result.files.map((entry) => entry.path).sort()
}

/** Paths whose contents match a JS regex. Used to skip full-tree reads on scan. */
export async function searchSessionFiles(
	sessionId: string,
	pattern: string,
): Promise<Array<string>> {
	const result = (await kody.repoSearch({
		session_id: sessionId,
		pattern,
		mode: 'regex',
		output_mode: 'files',
		limit: 5000,
	})) as { files: Array<{ path: string }>; truncated: boolean }
	if (result.truncated) {
		throw new Error('Package has too many matching files for the codemod runner.')
	}
	return result.files.map((entry) => entry.path).sort()
}

const fileReadConcurrency = 16

export async function readSessionFiles(
	sessionId: string,
	paths: Array<string>,
): Promise<Record<string, string>> {
	const entries = await mapPool(paths, fileReadConcurrency, async (path) => {
		const result = (await kody.repoReadFile({
			session_id: sessionId,
			path,
		})) as { content: string | null }
		return [path, result.content] as const
	})
	const files: Record<string, string> = {}
	for (const [path, content] of entries) {
		if (typeof content === 'string') {
			files[path] = content
		}
	}
	return files
}

export type WriteOutcome = { diff: string }

export async function writeSessionFiles(
	sessionId: string,
	files: Record<string, string>,
): Promise<WriteOutcome> {
	const entries = Object.entries(files).map(([path, content]) => ({
		path,
		content,
	}))
	if (entries.length === 0) return { diff: '' }
	const result = (await kody.repoWriteFile({
		session_id: sessionId,
		files: entries,
	})) as { edits: Array<{ path: string; diff: string }> }
	return {
		diff: result.edits.map((edit) => edit.diff).join('\n'),
	}
}

export async function deleteSessionFiles(
	sessionId: string,
	paths: Array<string>,
): Promise<void> {
	if (paths.length === 0) return
	for (const path of paths) {
		if (/\s/.test(path)) {
			throw new Error(
				`Cannot delete "${path}": paths with whitespace are not supported.`,
			)
		}
	}
	await kody.repoRunCommands({
		session_id: sessionId,
		commands: paths.map((path) => `git rm ${path}`).join('\n'),
	})
}

export type CheckOutcome = {
	ok: boolean
	failed: Array<string>
}

export async function runSessionChecks(
	sessionId: string,
): Promise<CheckOutcome> {
	const result = (await kody.repoRunChecks({
		session_id: sessionId,
	})) as {
		ok: boolean
		results: Array<{ kind: string; ok: boolean; message: string }>
	}
	return {
		ok: result.ok,
		failed: result.results
			.filter((entry) => !entry.ok)
			.map((entry) => `${entry.kind}: ${entry.message}`.slice(0, 500)),
	}
}

export type PublishOutcome =
	| { status: 'ok'; publishedCommit: string }
	| { status: 'blocked'; message: string }

export async function publishSession(
	sessionId: string,
): Promise<PublishOutcome> {
	const result = (await kody.repoPublishSession({
		session_id: sessionId,
	})) as {
		status: 'ok' | 'checks_outdated' | 'base_moved'
		published_commit: string | null
		message: string
	}
	if (result.status === 'ok' && result.published_commit) {
		return { status: 'ok', publishedCommit: result.published_commit }
	}
	return { status: 'blocked', message: `${result.status}: ${result.message}` }
}