kimi-code / apps /vscode /src /migration /legacy-migration.manager.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f59fbe2 verified
Raw History Blame Contribute Delete
22.7 kB
import { readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, resolve, win32 } from "node:path";
import {
detectMigration,
runMigration,
defaultPlansSourceDir,
shouldSuppressMigration,
type MigrationPlan,
type MigrationReport,
type MigrationScope,
} from "@moonshot-ai/migration-legacy";
const FULL_MIGRATION_SCOPE = {
config: true,
mcp: true,
userHistory: true,
skills: true,
sessions: true,
} satisfies MigrationScope;
export type LegacyMigrationSourceOrigin = "default" | "legacy-vscode-setting";
export type LegacyMigrationWarningCode =
| "invalid-share-dir"
| "relative-share-dir"
| "source-equals-target"
| "source-not-directory"
| "source-unreadable"
| "legacy-session-unreadable"
| "detection-failed";
export interface LegacyMigrationWarning {
readonly code: LegacyMigrationWarningCode;
readonly message: string;
readonly sourceHome?: string;
}
export interface LegacyMigrationReauthItem {
readonly sourceHome: string;
readonly name: string;
}
export interface LegacyMigrationNotices {
readonly oauthLoginsRequiringRelogin: readonly LegacyMigrationReauthItem[];
readonly mcpOauthServersRequiringReauth: readonly LegacyMigrationReauthItem[];
}
export interface LegacyMigrationSourcePreview {
readonly sourceHome: string;
readonly origin: LegacyMigrationSourceOrigin;
readonly hasConfig: boolean;
readonly hasMcp: boolean;
readonly hasUserHistory: boolean;
readonly hasSkills: boolean;
readonly hasPlans: boolean;
readonly totalSessions: number;
readonly sessionIssues: number;
}
export interface LegacyMigrationPromptModel {
readonly kind: "legacy-migration";
readonly message: string;
readonly actions: readonly [
{ readonly id: "now"; readonly label: "Migrate Now" },
{ readonly id: "later"; readonly label: "Later" },
];
readonly sources: readonly LegacyMigrationSourcePreview[];
readonly notices: LegacyMigrationNotices;
}
export interface LegacyMigrationDiscovery {
readonly prompt: LegacyMigrationPromptModel | null;
readonly suppressedSources: readonly LegacyMigrationSourcePreview[];
readonly warnings: readonly LegacyMigrationWarning[];
readonly notices: LegacyMigrationNotices;
}
export interface LegacyMigrationManagerOptions {
/** Harness-resolved homeDir. */
readonly targetHome: string;
/** Defaults to the legacy kimi-cli home (`~/.kimi`). Injectable for isolated tests. */
readonly defaultSourceHome?: string;
/** Defaults to the legacy kimi-cli plans dir (`~/.kimi/plans`). Injectable for isolated tests. */
readonly plansSourceDir?: string;
/** First workspace root. Used only to resolve a relative legacy KIMI_SHARE_DIR. */
readonly workspaceRoot?: string | null;
/** The removed `kimi.environmentVariables` VS Code setting, read once for migration. */
readonly legacyEnvironmentVariables?: unknown;
}
export type LegacyMigrationFailureCode =
| "run-failed"
| "legacy-config-unreadable"
| "legacy-mcp-unreadable"
| "session-failed";
export interface LegacyMigrationFailure {
readonly code: LegacyMigrationFailureCode;
readonly sourceHome: string;
readonly item?: string;
readonly message: string;
}
export interface LegacyMigrationTotals {
readonly configFiles: number;
readonly mcpServers: number;
readonly userHistoryEntries: number;
readonly skills: number;
readonly planFiles: number;
readonly sessions: number;
readonly alreadyMigratedSessions: number;
readonly skippedItems: number;
readonly conflicts: number;
readonly failures: number;
}
export interface LegacyMigrationSourceResult {
readonly source: LegacyMigrationSourcePreview;
readonly status: "completed" | "partial" | "failed";
readonly report?: MigrationReport;
readonly failures: readonly LegacyMigrationFailure[];
readonly error?: unknown;
}
export interface LegacyMigrationRunResult {
readonly status: "completed" | "partial" | "failed" | "nothing-to-migrate";
readonly message: string;
readonly sources: readonly LegacyMigrationSourceResult[];
readonly suppressedSources: readonly LegacyMigrationSourcePreview[];
readonly totals: LegacyMigrationTotals;
readonly warnings: readonly LegacyMigrationWarning[];
readonly notices: LegacyMigrationNotices;
readonly manualActions: readonly string[];
}
interface InspectedSource {
readonly preview: LegacyMigrationSourcePreview;
readonly plan: MigrationPlan;
}
interface InspectionResult {
readonly pending: readonly InspectedSource[];
readonly suppressed: readonly LegacyMigrationSourcePreview[];
readonly warnings: readonly LegacyMigrationWarning[];
readonly notices: LegacyMigrationNotices;
}
interface SourceCandidate {
readonly sourceHome: string;
readonly origin: LegacyMigrationSourceOrigin;
}
/**
* Coordinates legacy kimi-cli migration for the VS Code host while keeping all
* data translation inside the shared migration package.
*/
export class LegacyMigrationManager {
readonly targetHome: string;
private readonly defaultSourceHome: string;
private readonly workspaceRoot: string | null;
private readonly legacyEnvironmentVariables: unknown;
private readonly plansSourceDir: string;
constructor(options: LegacyMigrationManagerOptions) {
if (options.targetHome.trim().length === 0) {
throw new Error("LegacyMigrationManager requires a non-empty targetHome.");
}
this.targetHome = resolve(options.targetHome);
this.defaultSourceHome = resolve(options.defaultSourceHome ?? join(homedir(), ".kimi"));
this.workspaceRoot =
options.workspaceRoot === undefined || options.workspaceRoot === null
? null
: resolve(options.workspaceRoot);
this.legacyEnvironmentVariables = options.legacyEnvironmentVariables;
this.plansSourceDir = options.plansSourceDir ?? defaultPlansSourceDir();
}
/** Detect first-launch work without changing the source or target. */
async discover(): Promise<LegacyMigrationDiscovery> {
const inspection = await this.inspect(false);
const sources = inspection.pending.map((source) => source.preview);
return {
prompt:
sources.length === 0
? null
: {
kind: "legacy-migration",
message:
"Legacy Kimi data was found. Migrate config, MCP servers, history, skills, and sessions into Kimi Code? Your old data will be kept.",
actions: [
{ id: "now", label: "Migrate Now" },
{ id: "later", label: "Later" },
],
sources,
notices: inspection.notices,
},
suppressedSources: inspection.suppressed,
warnings: inspection.warnings,
notices: inspection.notices,
};
}
/** Run the migration selected from the first-launch prompt. */
async migrateNow(): Promise<LegacyMigrationRunResult> {
return this.execute(false);
}
/**
* Explicit command-palette retry. Marker suppression is intentionally
* bypassed because a completed marker may also describe a partially failed
* run, while the shared migrator itself remains idempotent.
*/
async retry(): Promise<LegacyMigrationRunResult> {
return this.execute(true);
}
private async execute(ignoreMarker: boolean): Promise<LegacyMigrationRunResult> {
const inspection = await this.inspect(ignoreMarker);
const sourceResults: LegacyMigrationSourceResult[] = [];
for (const source of inspection.pending) {
try {
const report = await runMigration({
// The shared package owns its migration/schema version. It must not
// be coupled to the VS Code extension's release version.
plan: source.plan,
scope: FULL_MIGRATION_SCOPE,
source: source.preview.sourceHome,
target: this.targetHome,
plansSourceDir: this.plansSourceDir,
});
const failures = failuresFromReport(source, report);
sourceResults.push({
source: source.preview,
status: failures.length === 0 ? "completed" : "partial",
report,
failures,
});
} catch (error) {
sourceResults.push({
source: source.preview,
status: "failed",
failures: [
{
code: "run-failed",
sourceHome: source.preview.sourceHome,
message: formatError(error),
},
],
error,
});
}
}
const status = runStatus(sourceResults);
const totals = aggregateTotals(sourceResults);
const manualActions = aggregateManualActions(sourceResults);
return {
status,
message: runMessage(status, totals),
sources: sourceResults,
suppressedSources: inspection.suppressed,
totals,
warnings: inspection.warnings,
notices: mergeRunNotices(inspection.notices, sourceResults),
manualActions,
};
}
private async inspect(ignoreMarker: boolean): Promise<InspectionResult> {
const { candidates, warnings } = this.sourceCandidates();
const pending: InspectedSource[] = [];
const suppressed: LegacyMigrationSourcePreview[] = [];
const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = [];
const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = [];
for (const candidate of candidates) {
const sourceCheck = await checkSourceDirectory(candidate.sourceHome);
if (sourceCheck === "missing") continue;
if (sourceCheck === "not-directory") {
warnings.push({
code: "source-not-directory",
sourceHome: candidate.sourceHome,
message: `Legacy migration source is not a directory: ${candidate.sourceHome}`,
});
continue;
}
if (sourceCheck === "unreadable") {
warnings.push({
code: "source-unreadable",
sourceHome: candidate.sourceHome,
message: `Legacy migration source cannot be read: ${candidate.sourceHome}`,
});
continue;
}
let plan: MigrationPlan;
try {
plan = await detectMigration({
sourcePath: candidate.sourceHome,
plansSourcePath: this.plansSourceDir,
});
} catch (error) {
warnings.push({
code: "detection-failed",
sourceHome: candidate.sourceHome,
message: `Unable to inspect legacy data at ${candidate.sourceHome}: ${formatError(error)}`,
});
continue;
}
oauthLoginsRequiringRelogin.push(
...plan.oauthCredentials.map((name) => ({ sourceHome: candidate.sourceHome, name })),
);
mcpOauthServersRequiringReauth.push(
...plan.detectedMcpOauthServers.map((name) => ({
sourceHome: candidate.sourceHome,
name,
})),
);
const hasSkills = plan.hasSkills;
const sessionScanFailures = plan.sessionScanFailures ?? [];
warnings.push(
...sessionScanFailures.map((failure) => ({
code: "legacy-session-unreadable" as const,
sourceHome: candidate.sourceHome,
message: `${failure.reason} Source: ${failure.sourcePath}`,
})),
);
const preview: LegacyMigrationSourcePreview = {
sourceHome: candidate.sourceHome,
origin: candidate.origin,
hasConfig: plan.hasConfig,
hasMcp: plan.hasMcp,
hasUserHistory: plan.hasUserHistory,
hasSkills,
hasPlans: plan.hasPlans,
totalSessions: plan.totalSessions,
sessionIssues: sessionScanFailures.length,
};
if (!hasMigratableData(preview)) continue;
if (
!ignoreMarker &&
shouldSuppressMigration({
sourceHome: candidate.sourceHome,
targetHome: this.targetHome,
})
) {
suppressed.push(preview);
continue;
}
pending.push({
preview,
plan,
});
}
return {
pending,
suppressed,
warnings,
notices: {
oauthLoginsRequiringRelogin: dedupeReauthItems(oauthLoginsRequiringRelogin),
mcpOauthServersRequiringReauth: dedupeReauthItems(
mcpOauthServersRequiringReauth,
),
},
};
}
private sourceCandidates(): {
candidates: SourceCandidate[];
warnings: LegacyMigrationWarning[];
} {
const warnings: LegacyMigrationWarning[] = [];
const candidates: SourceCandidate[] = [
{ sourceHome: this.defaultSourceHome, origin: "default" },
];
const shareDir = readLegacyShareDir(this.legacyEnvironmentVariables);
if (shareDir.kind === "invalid") {
warnings.push({
code: "invalid-share-dir",
message: shareDir.message,
});
} else if (shareDir.kind === "value") {
let sourceHome: string | undefined;
if (isAbsolute(shareDir.value)) {
sourceHome = resolve(shareDir.value);
} else if (this.workspaceRoot === null) {
warnings.push({
code: "invalid-share-dir",
message:
"The legacy KIMI_SHARE_DIR is relative, but no workspace is open; this migration source was ignored.",
});
} else {
sourceHome = resolve(this.workspaceRoot, shareDir.value);
warnings.push({
code: "relative-share-dir",
sourceHome,
message: `The legacy relative KIMI_SHARE_DIR was resolved against the workspace: ${sourceHome}`,
});
}
if (sourceHome !== undefined && samePath(sourceHome, this.targetHome)) {
warnings.push({
code: "source-equals-target",
sourceHome,
message: "The legacy KIMI_SHARE_DIR resolves to the Kimi Code home and was ignored.",
});
} else if (
sourceHome !== undefined &&
!candidates.some((candidate) => samePath(candidate.sourceHome, sourceHome))
) {
candidates.push({ sourceHome, origin: "legacy-vscode-setting" });
}
}
return { candidates, warnings };
}
}
function readLegacyShareDir(
environmentVariables: unknown,
): { readonly kind: "missing" } | { readonly kind: "value"; readonly value: string } | {
readonly kind: "invalid";
readonly message: string;
} {
if (environmentVariables === undefined) return { kind: "missing" };
if (
typeof environmentVariables !== "object" ||
environmentVariables === null ||
Array.isArray(environmentVariables)
) {
return {
kind: "invalid",
message: "The legacy kimi.environmentVariables setting is invalid and was ignored.",
};
}
const value = (environmentVariables as Record<string, unknown>)["KIMI_SHARE_DIR"];
if (value === undefined) return { kind: "missing" };
if (typeof value !== "string" || value.trim().length === 0) {
return {
kind: "invalid",
message: "The legacy KIMI_SHARE_DIR must be a non-empty string and was ignored.",
};
}
return { kind: "value", value };
}
async function checkSourceDirectory(
sourceHome: string,
): Promise<"ok" | "missing" | "not-directory" | "unreadable"> {
try {
const sourceStat = await stat(sourceHome);
if (!sourceStat.isDirectory()) return "not-directory";
} catch (error) {
return isMissingError(error) ? "missing" : "unreadable";
}
try {
await readdir(sourceHome);
return "ok";
} catch {
return "unreadable";
}
}
function isMissingError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { readonly code?: unknown }).code === "ENOENT"
);
}
function hasMigratableData(source: LegacyMigrationSourcePreview): boolean {
return (
source.hasConfig ||
source.hasMcp ||
source.hasUserHistory ||
source.hasSkills ||
source.hasPlans ||
source.totalSessions > 0 ||
source.sessionIssues > 0
);
}
function failuresFromReport(
source: InspectedSource,
report: MigrationReport,
): LegacyMigrationFailure[] {
const failures: LegacyMigrationFailure[] = [];
if (report.summary.config.sourceUnreadable) {
failures.push({
code: "legacy-config-unreadable",
sourceHome: source.preview.sourceHome,
item: "config.toml",
message: "The legacy config could not be read or parsed; review it manually.",
});
}
if (report.summary.mcp.sourceUnreadable) {
failures.push({
code: "legacy-mcp-unreadable",
sourceHome: source.preview.sourceHome,
item: "mcp.json",
message: "The legacy mcp.json could not be parsed; review it manually.",
});
}
failures.push(
...report.summary.sessions.sessionsFailed.map((failure) => ({
code: "session-failed" as const,
sourceHome: source.preview.sourceHome,
item: failure.sourcePath,
message: failure.reason,
})),
);
return failures;
}
function runStatus(
sources: readonly LegacyMigrationSourceResult[],
): LegacyMigrationRunResult["status"] {
if (sources.length === 0) return "nothing-to-migrate";
if (sources.every((source) => source.status === "failed")) return "failed";
if (sources.some((source) => source.status !== "completed")) return "partial";
return "completed";
}
function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): LegacyMigrationTotals {
let configFiles = 0;
let mcpServers = 0;
let userHistoryEntries = 0;
let skills = 0;
let planFiles = 0;
let sessions = 0;
let alreadyMigratedSessions = 0;
let skippedItems = 0;
let conflicts = 0;
let failures = 0;
for (const source of sources) {
failures += source.failures.length;
const summary = source.report?.summary;
if (summary === undefined) continue;
configFiles += summary.config.migrated ? 1 : 0;
mcpServers += summary.mcp.mergedServers.length;
userHistoryEntries += summary.userHistory.copied;
skills += summary.skills.copied;
planFiles += summary.plans.copied;
sessions += summary.sessions.sessionsMigrated;
alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated;
skippedItems +=
summary.userHistory.skippedExisting +
summary.skills.skippedExisting +
summary.sessions.sessionsSkippedPlaceholder +
summary.sessions.sessionsSkippedEmpty +
summary.sessions.sessionsSkippedMalformed;
conflicts +=
summary.config.configConflicts.length +
summary.mcp.keptNewForConflicts.length +
summary.sessions.sessionsConflicts.length;
}
return {
configFiles,
mcpServers,
userHistoryEntries,
skills,
planFiles,
sessions,
alreadyMigratedSessions,
skippedItems,
conflicts,
failures,
};
}
function aggregateManualActions(
sources: readonly LegacyMigrationSourceResult[],
): readonly string[] {
const actions: string[] = [];
for (const source of sources) {
for (const failure of source.failures) {
if (failure.code === "run-failed") {
actions.push(
`Fix access to ${source.source.sourceHome} or the Kimi Code home, then run “Kimi Code: Migrate Legacy Data” again.`,
);
} else {
actions.push(
`${failure.message} Source: ${failure.item ?? source.source.sourceHome}`,
);
}
}
const summary = source.report?.summary;
if (summary === undefined) continue;
if (summary.config.wroteSiblingDueToConflict) {
actions.push("Review and merge config.migrated-from-kimi-cli.toml.");
}
if (summary.config.wroteTuiSibling) {
actions.push("Review and merge tui.migrated-from-kimi-cli.toml.");
}
if (summary.mcp.wroteSiblingDueToConflict) {
actions.push("Review and merge mcp.migrated-from-kimi-cli.json.");
}
if (summary.sessions.sessionsConflicts.length > 0) {
actions.push(
`${summary.sessions.sessionsConflicts.length} legacy session(s) conflicted with existing target sessions and were kept unchanged.`,
);
}
}
return [...new Set(actions)];
}
function mergeRunNotices(
detectionNotices: LegacyMigrationNotices,
sources: readonly LegacyMigrationSourceResult[],
): LegacyMigrationNotices {
const oauth = [...detectionNotices.oauthLoginsRequiringRelogin];
const mcpOauth = [...detectionNotices.mcpOauthServersRequiringReauth];
for (const source of sources) {
const notices = source.report?.notices;
if (notices === undefined) continue;
oauth.push(
...notices.oauthLoginsRequiringRelogin.map((name) => ({
sourceHome: source.source.sourceHome,
name,
})),
);
mcpOauth.push(
...notices.mcpOauthServersRequiringReauth.map((name) => ({
sourceHome: source.source.sourceHome,
name,
})),
);
}
return {
oauthLoginsRequiringRelogin: dedupeReauthItems(oauth),
mcpOauthServersRequiringReauth: dedupeReauthItems(mcpOauth),
};
}
function dedupeReauthItems(
items: readonly LegacyMigrationReauthItem[],
): readonly LegacyMigrationReauthItem[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = `${pathKey(item.sourceHome)}\0${item.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function runMessage(
status: LegacyMigrationRunResult["status"],
totals: LegacyMigrationTotals,
): string {
if (status === "nothing-to-migrate") return "No legacy Kimi data needs migration.";
if (status === "failed") {
return "Legacy migration failed. Fix the reported path or data error, then retry from the command palette.";
}
const migrated = `${totals.configFiles} config, ${totals.mcpServers} MCP server(s), ${totals.userHistoryEntries} history item(s), ${totals.skills} skill(s), ${totals.planFiles} plan file(s), and ${totals.sessions} session(s)`;
if (status === "partial") {
return `Legacy migration completed with ${totals.failures} failure(s): ${migrated}. Review the details and retry from the command palette.`;
}
return `Legacy migration complete: ${migrated}. Old data was kept.`;
}
function formatError(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const messages: string[] = [];
let current: unknown = error;
while (current instanceof Error) {
messages.push(current.message || current.name);
current = current.cause;
}
return messages.join(": ");
}
function samePath(left: string, right: string): boolean {
return pathKey(left) === pathKey(right);
}
function pathKey(path: string): string {
if (process.platform === "win32") return win32.resolve(path).toLowerCase();
const windowsAbsolute = win32.isAbsolute(path);
return windowsAbsolute ? win32.resolve(path).toLowerCase() : resolve(path);
}