File size: 5,436 Bytes
d522ffa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | import { existsSync, readFileSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve, win32 } from 'node:path';
import { migratedMarker, skipMarker } from './paths.js';
export interface MarkerRun {
readonly startedAt: string;
readonly completedAt: string;
readonly migratorVersion: string;
readonly summary: Record<string, unknown>;
}
export interface MarkerData {
readonly version: 1;
readonly first_migrated_at: string;
readonly last_migrated_at: string;
readonly migrator_version: string;
readonly target_path: string;
/** All target homes covered by this marker. Absent in legacy markers. */
readonly target_paths?: readonly string[];
readonly runs: readonly MarkerRun[];
}
export interface MigrationSuppressionInput {
readonly sourceHome: string;
readonly targetHome: string;
}
/**
* Decide whether a migration prompt should be suppressed for one target home.
*
* A completed marker covers every target recorded in `target_paths`; the
* legacy `target_path` field remains authoritative when that list is absent.
* Unreadable markers are treated conservatively as completed so upgrading does
* not start prompting users who had already dismissed the migration.
*/
export function shouldSuppressMigration(input: MigrationSuppressionInput): boolean {
if (existsSync(skipMarker(input.targetHome))) return true;
const markerPath = migratedMarker(input.sourceHome);
if (!existsSync(markerPath)) return false;
try {
const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as {
readonly target_path?: unknown;
readonly target_paths?: unknown;
};
const targetPaths = markerTargetPaths(parsed);
if (targetPaths.length === 0) return true;
return targetPaths.some((targetPath) => sameTargetPath(targetPath, input.targetHome));
} catch {
return true;
}
}
export async function readMarker(sourceHome: string): Promise<MarkerData | undefined> {
try {
const text = await readFile(migratedMarker(sourceHome), 'utf-8');
const parsed = JSON.parse(text) as Partial<MarkerData>;
if (parsed.version !== 1) return undefined;
// A partially-written or hand-edited marker may keep `version` but lack a
// valid `runs` array; treating it as absent avoids `appendMarkerRun`
// throwing on `[...existing.runs, run]` and aborting a healthy rerun.
if (!Array.isArray(parsed.runs)) return undefined;
return parsed as MarkerData;
} catch {
return undefined;
}
}
export async function writeMarker(
sourceHome: string,
run: MarkerRun & { readonly targetPath: string },
): Promise<void> {
const data: MarkerData = {
version: 1,
first_migrated_at: run.startedAt,
last_migrated_at: run.completedAt,
migrator_version: run.migratorVersion,
target_path: run.targetPath,
target_paths: [run.targetPath],
runs: [
{
startedAt: run.startedAt,
completedAt: run.completedAt,
migratorVersion: run.migratorVersion,
summary: run.summary,
},
],
};
await writeFile(migratedMarker(sourceHome), JSON.stringify(data, null, 2), 'utf-8');
}
export async function appendMarkerRun(
sourceHome: string,
run: MarkerRun & { readonly targetPath: string },
): Promise<void> {
const existing = await readMarker(sourceHome);
if (existing === undefined) throw new Error('appendMarkerRun: no existing marker');
const updated: MarkerData = {
...existing,
last_migrated_at: run.completedAt,
migrator_version: run.migratorVersion,
// Record the latest run's target so a rerun to a different KIMI_CODE_HOME
// updates the marker — otherwise `detectPendingMigration` keeps prompting
// for the new target even though it was just migrated.
target_path: run.targetPath,
target_paths: appendTargetPath(markerTargetPaths(existing), run.targetPath),
runs: [
...existing.runs,
{
startedAt: run.startedAt,
completedAt: run.completedAt,
migratorVersion: run.migratorVersion,
summary: run.summary,
},
],
};
await writeFile(migratedMarker(sourceHome), JSON.stringify(updated, null, 2), 'utf-8');
}
function markerTargetPaths(marker: {
readonly target_path?: unknown;
readonly target_paths?: unknown;
}): string[] {
const targetPaths = Array.isArray(marker.target_paths)
? marker.target_paths.filter((targetPath): targetPath is string => typeof targetPath === 'string')
: [];
if (typeof marker.target_path === 'string') {
return appendTargetPath(targetPaths, marker.target_path);
}
return targetPaths;
}
function appendTargetPath(targetPaths: readonly string[], targetPath: string): string[] {
if (targetPaths.some((existing) => sameTargetPath(existing, targetPath))) {
return [...targetPaths];
}
return [...targetPaths, targetPath];
}
function sameTargetPath(left: string, right: string): boolean {
if (process.platform === 'win32') {
return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase();
}
const leftIsWindowsAbsolute = win32.isAbsolute(left);
const rightIsWindowsAbsolute = win32.isAbsolute(right);
if (leftIsWindowsAbsolute || rightIsWindowsAbsolute) {
if (!leftIsWindowsAbsolute || !rightIsWindowsAbsolute) return false;
return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase();
}
return resolve(left) === resolve(right);
}
|