448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
/**
|
|
* One-time migrations that run on startup.
|
|
*/
|
|
|
|
import chalk from "chalk";
|
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
import { dirname, join } from "path";
|
|
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
|
|
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
|
|
import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts";
|
|
import { stripJsonComments } from "./utils/json.ts";
|
|
|
|
const MIGRATION_GUIDE_URL =
|
|
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
|
|
const EXTENSIONS_DOC_URL =
|
|
"https://github.com/earendil-works/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<string, unknown> = {};
|
|
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;
|
|
}
|
|
|
|
interface ConfigValueMigration {
|
|
location: string;
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
function migrateLegacyEnvVarString(value: string): string | undefined {
|
|
return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined;
|
|
}
|
|
|
|
function migrateStringProperty(
|
|
record: Record<string, unknown>,
|
|
key: string,
|
|
location: string,
|
|
migrations: ConfigValueMigration[],
|
|
): boolean {
|
|
const value = record[key];
|
|
if (typeof value !== "string") return false;
|
|
const migrated = migrateLegacyEnvVarString(value);
|
|
if (migrated === undefined) return false;
|
|
record[key] = migrated;
|
|
migrations.push({ location, from: value, to: migrated });
|
|
return true;
|
|
}
|
|
|
|
function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean {
|
|
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false;
|
|
const headerRecord = headers as Record<string, unknown>;
|
|
let migrated = false;
|
|
for (const [key, value] of Object.entries(headerRecord)) {
|
|
if (typeof value !== "string") continue;
|
|
const migratedValue = migrateLegacyEnvVarString(value);
|
|
if (migratedValue === undefined) continue;
|
|
headerRecord[key] = migratedValue;
|
|
migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue });
|
|
migrated = true;
|
|
}
|
|
return migrated;
|
|
}
|
|
|
|
function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] {
|
|
const authPath = join(agentDir, "auth.json");
|
|
if (!existsSync(authPath)) return [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
const authData = parsed as Record<string, unknown>;
|
|
|
|
const migrations: ConfigValueMigration[] = [];
|
|
for (const [provider, credential] of Object.entries(authData)) {
|
|
if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue;
|
|
const credentialRecord = credential as Record<string, unknown>;
|
|
if (credentialRecord.type !== "api_key") continue;
|
|
migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations);
|
|
}
|
|
|
|
if (migrations.length === 0) return [];
|
|
writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
|
|
chmodSync(authPath, 0o600);
|
|
return migrations;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] {
|
|
const modelsPath = join(agentDir, "models.json");
|
|
if (!existsSync(modelsPath)) return [];
|
|
|
|
const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
const modelsData = parsed as Record<string, unknown>;
|
|
const providers = modelsData.providers;
|
|
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return [];
|
|
|
|
const migrations: ConfigValueMigration[] = [];
|
|
for (const [provider, providerConfig] of Object.entries(providers)) {
|
|
if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue;
|
|
const providerRecord = providerConfig as Record<string, unknown>;
|
|
const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`;
|
|
migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations);
|
|
migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations);
|
|
|
|
if (Array.isArray(providerRecord.models)) {
|
|
for (let index = 0; index < providerRecord.models.length; index++) {
|
|
const modelConfig = providerRecord.models[index];
|
|
if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue;
|
|
const modelRecord = modelConfig as Record<string, unknown>;
|
|
const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index);
|
|
migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations);
|
|
}
|
|
}
|
|
|
|
const modelOverrides = providerRecord.modelOverrides;
|
|
if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) {
|
|
for (const [modelId, modelOverride] of Object.entries(modelOverrides)) {
|
|
if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue;
|
|
const modelOverrideRecord = modelOverride as Record<string, unknown>;
|
|
migrateHeadersConfig(
|
|
modelOverrideRecord.headers,
|
|
`${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`,
|
|
migrations,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (migrations.length === 0) return [];
|
|
writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
|
|
return migrations;
|
|
}
|
|
|
|
function migrateExplicitEnvVarConfigValues(): void {
|
|
const agentDir = getAgentDir();
|
|
const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)];
|
|
if (migrations.length === 0) return;
|
|
|
|
const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`);
|
|
console.log(
|
|
chalk.yellow(
|
|
[
|
|
"Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.",
|
|
...details,
|
|
].join("\n"),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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/<encoded-cwd>/. This migration moves them
|
|
* to the correct location based on the cwd in their session header.
|
|
*
|
|
* See: https://github.com/earendil-works/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;
|
|
}
|
|
|
|
function migrateKeybindingsConfigFile(): void {
|
|
const configPath = join(getAgentDir(), "keybindings.json");
|
|
if (!existsSync(configPath)) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
return;
|
|
}
|
|
const { config, migrated } = migrateKeybindingsConfig(parsed as Record<string, unknown>);
|
|
if (!migrated) return;
|
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
} catch {
|
|
// Ignore malformed files during migration
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void>((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): {
|
|
migratedAuthProviders: string[];
|
|
deprecationWarnings: string[];
|
|
} {
|
|
const migratedAuthProviders = migrateAuthToAuthJson();
|
|
migrateExplicitEnvVarConfigValues();
|
|
migrateSessionsFromAgentRoot();
|
|
migrateToolsToBin();
|
|
migrateKeybindingsConfigFile();
|
|
const deprecationWarnings = migrateExtensionSystem(cwd);
|
|
return { migratedAuthProviders, deprecationWarnings };
|
|
}
|