#!/usr/bin/env node import { constants, realpathSync } from "node:fs"; import { chmod, open, rename, rm } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; export const LEGACY_TOOL_SEARCH_DEFAULTS = { alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], showToolSearchFooterStatus: false, maxActiveGroups: 3, maxToolsPerGroup: 8, maxDynamicTools: 20, groupOverrides: {}, }; const CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS = { alwaysEnabled: [ "codegraph_explore", "lsp_diagnostics", "ctx_execute", "ctx_execute_file", "ctx_batch_execute", ], showToolSearchFooterStatus: false, maxActiveGroups: 5, maxToolsPerGroup: 8, maxDynamicTools: 28, groupOverrides: {}, }; export const CURRENT_TOOL_SEARCH_DEFAULTS = { ...CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS, bundleDefaultsVersion: 2, }; function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function exactJsonValue(left, right) { if (Array.isArray(left) || Array.isArray(right)) { return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => exactJsonValue(value, right[index])); } if (isObject(left) || isObject(right)) { if (!isObject(left) || !isObject(right)) return false; const leftKeys = Object.keys(left).sort(); const rightKeys = Object.keys(right).sort(); return exactJsonValue(leftKeys, rightKeys) && leftKeys.every((key) => exactJsonValue(left[key], right[key])); } return Object.is(left, right); } function backupTimestamp(now) { return now.toISOString().replace(/[:.]/g, "-"); } async function readFileSnapshot(path) { let handle; try { handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); const metadata = await handle.stat(); if (!metadata.isFile()) throw new Error(`settings path is not a regular file: ${path}`); return { metadata, content: await handle.readFile("utf8") }; } finally { if (handle) await handle.close().catch(() => {}); } } export async function migrateToolSearchSettings(settingsPath, options = {}) { const now = options.now ?? new Date(); let initial; try { initial = await readFileSnapshot(settingsPath); } catch (error) { if (error?.code === "ENOENT") return { status: "missing" }; throw error; } const { metadata, content: original } = initial; let settings; try { settings = JSON.parse(original); } catch (error) { throw new Error(`settings.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } if (!isObject(settings)) throw new Error("settings.json root must be a JSON object"); if (!("toolSearch" in settings)) return { status: "missing-section" }; if (!isObject(settings.toolSearch)) return { status: "custom" }; if ( exactJsonValue(settings.toolSearch, CURRENT_TOOL_SEARCH_DEFAULTS) || exactJsonValue(settings.toolSearch, CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS) ) return { status: "current" }; if (!exactJsonValue(settings.toolSearch, LEGACY_TOOL_SEARCH_DEFAULTS)) return { status: "custom" }; const updated = `${JSON.stringify({ ...settings, toolSearch: CURRENT_TOOL_SEARCH_DEFAULTS }, null, 2)}\n`; const directory = dirname(settingsPath); const backupPath = `${settingsPath}.bak.${backupTimestamp(now)}`; const temporaryPath = join( directory, `.${basename(settingsPath)}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`, ); let backup; let backupCreated = false; try { backup = await open(backupPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); backupCreated = true; await backup.writeFile(original, "utf8"); await backup.sync(); await backup.close(); backup = undefined; } catch (error) { if (backup) await backup.close().catch(() => {}); if (backupCreated) await rm(backupPath, { force: true }).catch(() => {}); throw error; } let temporary; try { temporary = await open(temporaryPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); await temporary.writeFile(updated, "utf8"); await temporary.sync(); await temporary.close(); temporary = undefined; if (options.beforeCommit) await options.beforeCommit(); const latest = await readFileSnapshot(settingsPath); if ( latest.metadata.dev !== metadata.dev || latest.metadata.ino !== metadata.ino || latest.content !== original ) throw new Error("settings.json changed during migration; original file was preserved"); await rename(temporaryPath, settingsPath); await chmod(settingsPath, 0o600); } catch (error) { if (temporary) await temporary.close().catch(() => {}); await rm(temporaryPath, { force: true }).catch(() => {}); throw error; } return { status: "migrated", backupPath }; } async function main() { const settingsPath = process.argv[2]; if (!settingsPath) { console.error("usage: migrate-tool-search-settings.mjs "); process.exitCode = 2; return; } const result = await migrateToolSearchSettings(settingsPath); if (result.status === "migrated") console.log(`migrated:${result.backupPath}`); else console.log(result.status); } function isMainModule() { if (!process.argv[1]) return false; try { return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); } catch { return false; } } if (isMainModule()) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }