/** * One-time migrations that run on startup. */ import chalk from "chalk"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.js"; const MIGRATION_GUIDE_URL = "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; const EXTENSIONS_DOC_URL = "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md"; /** * Migrate legacy oauth.json and settings.json apiKeys to auth.json. * * @returns Array of provider names that were migrated */ export function migrateAuthToAuthJson(): string[] { const agentDir = getAgentDir(); const authPath = join(agentDir, "auth.json"); const oauthPath = join(agentDir, "oauth.json"); const settingsPath = join(agentDir, "settings.json"); // Skip if auth.json already exists if (existsSync(authPath)) return []; const migrated: Record = {}; const providers: string[] = []; // Migrate oauth.json if (existsSync(oauthPath)) { try { const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); for (const [provider, cred] of Object.entries(oauth)) { migrated[provider] = { type: "oauth", ...(cred as object) }; providers.push(provider); } renameSync(oauthPath, `${oauthPath}.migrated`); } catch { // Skip on error } } // Migrate settings.json apiKeys if (existsSync(settingsPath)) { try { const content = readFileSync(settingsPath, "utf-8"); const settings = JSON.parse(content); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (!migrated[provider] && typeof key === "string") { migrated[provider] = { type: "api_key", key }; providers.push(provider); } } delete settings.apiKeys; writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } } catch { // Skip on error } } if (Object.keys(migrated).length > 0) { mkdirSync(dirname(authPath), { recursive: true }); writeFileSync(authPath, JSON.stringify(migrated, null, 2), { mode: 0o600 }); } return providers; } /** * Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories. * * Bug in v0.30.0: Sessions were saved to ~/.pi/agent/ instead of * ~/.pi/agent/sessions//. This migration moves them * to the correct location based on the cwd in their session header. * * See: https://github.com/badlogic/pi-mono/issues/320 */ export function migrateSessionsFromAgentRoot(): void { const agentDir = getAgentDir(); // Find all .jsonl files directly in agentDir (not in subdirectories) let files: string[]; try { files = readdirSync(agentDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(agentDir, f)); } catch { return; } if (files.length === 0) return; for (const file of files) { try { // Read first line to get session header const content = readFileSync(file, "utf8"); const firstLine = content.split("\n")[0]; if (!firstLine?.trim()) continue; const header = JSON.parse(firstLine); if (header.type !== "session" || !header.cwd) continue; const cwd: string = header.cwd; // Compute the correct session directory (same encoding as session-manager.ts) const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const correctDir = join(agentDir, "sessions", safePath); // Create directory if needed if (!existsSync(correctDir)) { mkdirSync(correctDir, { recursive: true }); } // Move the file const fileName = file.split("/").pop() || file.split("\\").pop(); const newPath = join(correctDir, fileName!); if (existsSync(newPath)) continue; // Skip if target exists renameSync(file, newPath); } catch { // Skip files that can't be migrated } } } /** * Migrate commands/ to prompts/ if needed. * Works for both regular directories and symlinks. */ function migrateCommandsToPrompts(baseDir: string, label: string): boolean { const commandsDir = join(baseDir, "commands"); const promptsDir = join(baseDir, "prompts"); if (existsSync(commandsDir) && !existsSync(promptsDir)) { try { renameSync(commandsDir, promptsDir); console.log(chalk.green(`Migrated ${label} commands/ → prompts/`)); return true; } catch (err) { console.log( chalk.yellow( `Warning: Could not migrate ${label} commands/ to prompts/: ${err instanceof Error ? err.message : err}`, ), ); } } return false; } /** * Move fd/rg binaries from tools/ to bin/ if they exist. */ function migrateToolsToBin(): void { const agentDir = getAgentDir(); const toolsDir = join(agentDir, "tools"); const binDir = getBinDir(); if (!existsSync(toolsDir)) return; const binaries = ["fd", "rg", "fd.exe", "rg.exe"]; let movedAny = false; for (const bin of binaries) { const oldPath = join(toolsDir, bin); const newPath = join(binDir, bin); if (existsSync(oldPath)) { if (!existsSync(binDir)) { mkdirSync(binDir, { recursive: true }); } if (!existsSync(newPath)) { try { renameSync(oldPath, newPath); movedAny = true; } catch { // Ignore errors } } else { // Target exists, just delete the old one try { rmSync?.(oldPath, { force: true }); } catch { // Ignore } } } } if (movedAny) { console.log(chalk.green(`Migrated managed binaries tools/ → bin/`)); } } /** * Check for deprecated hooks/ and tools/ directories. * Note: tools/ may contain fd/rg binaries extracted by pi, so only warn if it has other files. */ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[] { const hooksDir = join(baseDir, "hooks"); const toolsDir = join(baseDir, "tools"); const warnings: string[] = []; if (existsSync(hooksDir)) { warnings.push(`${label} hooks/ directory found. Hooks have been renamed to extensions.`); } if (existsSync(toolsDir)) { // Check if tools/ contains anything other than fd/rg (which are auto-extracted binaries) try { const entries = readdirSync(toolsDir); const customTools = entries.filter((e) => { const lower = e.toLowerCase(); return ( lower !== "fd" && lower !== "rg" && lower !== "fd.exe" && lower !== "rg.exe" && !e.startsWith(".") // Ignore .DS_Store and other hidden files ); }); if (customTools.length > 0) { warnings.push( `${label} tools/ directory contains custom tools. Custom tools have been merged into extensions.`, ); } } catch { // Ignore read errors } } return warnings; } /** * Run extension system migrations (commands→prompts) and collect warnings about deprecated directories. */ function migrateExtensionSystem(cwd: string): string[] { const agentDir = getAgentDir(); const projectDir = join(cwd, CONFIG_DIR_NAME); // Migrate commands/ to prompts/ migrateCommandsToPrompts(agentDir, "Global"); migrateCommandsToPrompts(projectDir, "Project"); // Check for deprecated directories const warnings = [ ...checkDeprecatedExtensionDirs(agentDir, "Global"), ...checkDeprecatedExtensionDirs(projectDir, "Project"), ]; return warnings; } /** * Print deprecation warnings and wait for keypress. */ export async function showDeprecationWarnings(warnings: string[]): Promise { if (warnings.length === 0) return; for (const warning of warnings) { console.log(chalk.yellow(`Warning: ${warning}`)); } console.log(chalk.yellow(`\nMove your extensions to the extensions/ directory.`)); console.log(chalk.yellow(`Migration guide: ${MIGRATION_GUIDE_URL}`)); console.log(chalk.yellow(`Documentation: ${EXTENSIONS_DOC_URL}`)); console.log(chalk.dim(`\nPress any key to continue...`)); await new Promise((resolve) => { process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.once("data", () => { process.stdin.setRawMode?.(false); process.stdin.pause(); resolve(); }); }); console.log(); } /** * Run all migrations. Called once on startup. * * @returns Object with migration results and deprecation warnings */ export function runMigrations(cwd: string = process.cwd()): { migratedAuthProviders: string[]; deprecationWarnings: string[]; } { const migratedAuthProviders = migrateAuthToAuthJson(); migrateSessionsFromAgentRoot(); migrateToolsToBin(); const deprecationWarnings = migrateExtensionSystem(cwd); return { migratedAuthProviders, deprecationWarnings }; }