feat(FN-1637): merge fusion/fn-1637
This commit is contained in:
@@ -387,6 +387,55 @@ describe("createBackupManager", () => {
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should canonicalize legacy .kb/backups to .fusion/backups in settings", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
const kbDir = join(tempDir, ".fusion");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
writeFileSync(join(kbDir, "fusion.db"), "test");
|
||||
|
||||
const settings: Partial<ProjectSettings> = {
|
||||
autoBackupDir: ".kb/backups", // Legacy value
|
||||
};
|
||||
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
|
||||
// Use fake timers and create a backup
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const backup = await manager.createBackup();
|
||||
|
||||
// Verify the backup was created in the canonical .fusion/backups directory
|
||||
expect(backup.path).toContain(".fusion/backups");
|
||||
expect(backup.path).not.toContain(".kb/backups");
|
||||
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should preserve non-legacy custom .kb/* directories", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
const kbDir = join(tempDir, ".fusion");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
writeFileSync(join(kbDir, "fusion.db"), "test");
|
||||
|
||||
const settings: Partial<ProjectSettings> = {
|
||||
autoBackupDir: ".kb/my-custom-backups", // Custom path, not the legacy default
|
||||
};
|
||||
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
|
||||
// Use fake timers and create a backup
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const backup = await manager.createBackup();
|
||||
|
||||
// Verify the backup was created in the custom .kb/my-custom-backups directory
|
||||
expect(backup.path).toContain(".kb/my-custom-backups");
|
||||
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBackupCommand", () => {
|
||||
|
||||
@@ -4,6 +4,25 @@ import { join } from "node:path";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
|
||||
/**
|
||||
* Legacy backup directory default value from the old .kb storage structure.
|
||||
* Projects that were created before the .fusion rename may still have this
|
||||
* value persisted in their config. It is canonicalized to the new default
|
||||
* so that all backup operations use a consistent directory.
|
||||
*/
|
||||
const LEGACY_BACKUP_DIR = ".kb/backups";
|
||||
|
||||
/**
|
||||
* Canonicalizes the backup directory from legacy defaults.
|
||||
* Only the exact legacy default alias is transformed — custom paths are preserved.
|
||||
*/
|
||||
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
|
||||
if (dir === LEGACY_BACKUP_DIR) {
|
||||
return ".fusion/backups";
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a database backup file.
|
||||
*/
|
||||
@@ -285,6 +304,7 @@ export function validateBackupDir(dir: string): boolean {
|
||||
|
||||
/**
|
||||
* Factory function to create a BackupManager with project settings.
|
||||
* Applies defensive canonicalization for the legacy backup directory default.
|
||||
* @param kbDir - Absolute path to the .fusion directory
|
||||
* @param settings - Project settings containing backup configuration
|
||||
* @returns Configured BackupManager instance
|
||||
@@ -294,7 +314,7 @@ export function createBackupManager(
|
||||
settings?: Partial<ProjectSettings>
|
||||
): BackupManager {
|
||||
return new BackupManager(kbDir, {
|
||||
backupDir: settings?.autoBackupDir,
|
||||
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
|
||||
retention: settings?.autoBackupRetention,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -938,6 +938,70 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backup Directory Canonicalization ─────────────────────────────
|
||||
|
||||
describe("autoBackupDir canonicalization", () => {
|
||||
it("getSettings returns .fusion/backups when persisted config contains legacy .kb/backups", async () => {
|
||||
// Directly set the legacy backup dir in the SQLite config to simulate legacy projects
|
||||
const db = (store as any).db;
|
||||
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
|
||||
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
|
||||
existingSettings.autoBackupDir = ".kb/backups";
|
||||
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoBackupDir).toBe(".fusion/backups");
|
||||
});
|
||||
|
||||
it("getSettingsFast returns .fusion/backups when persisted config contains legacy .kb/backups", async () => {
|
||||
const db = (store as any).db;
|
||||
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
|
||||
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
|
||||
existingSettings.autoBackupDir = ".kb/backups";
|
||||
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
|
||||
|
||||
const settings = await store.getSettingsFast();
|
||||
expect(settings.autoBackupDir).toBe(".fusion/backups");
|
||||
});
|
||||
|
||||
it("getSettingsByScope returns .fusion/backups in project when persisted config contains legacy .kb/backups", async () => {
|
||||
const db = (store as any).db;
|
||||
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
|
||||
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
|
||||
existingSettings.autoBackupDir = ".kb/backups";
|
||||
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
|
||||
|
||||
const { project } = await store.getSettingsByScope();
|
||||
expect(project.autoBackupDir).toBe(".fusion/backups");
|
||||
});
|
||||
|
||||
it("autoBackupDir: null removes the override and falls back to default .fusion/backups", async () => {
|
||||
// First set a custom backup dir
|
||||
await store.updateSettings({ autoBackupDir: "custom/backups" });
|
||||
let settings = await store.getSettings();
|
||||
expect(settings.autoBackupDir).toBe("custom/backups");
|
||||
|
||||
// Then clear it with null (null-as-delete semantics)
|
||||
await store.updateSettings({ autoBackupDir: null as unknown as undefined });
|
||||
settings = await store.getSettings();
|
||||
// Should fall back to the default .fusion/backups (which is the canonical form)
|
||||
expect(settings.autoBackupDir).toBe(".fusion/backups");
|
||||
});
|
||||
|
||||
it("non-legacy custom .kb/* directories are preserved (not canonicalized)", async () => {
|
||||
// Custom path like ".kb/my-custom-backups" should NOT be canonicalized
|
||||
await store.updateSettings({ autoBackupDir: ".kb/my-custom-backups" });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoBackupDir).toBe(".kb/my-custom-backups");
|
||||
});
|
||||
|
||||
it("getSettings preserves explicit .fusion/backups setting", async () => {
|
||||
await store.updateSettings({ autoBackupDir: ".fusion/backups" });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoBackupDir).toBe(".fusion/backups");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt Overrides Tests ─────────────────────────────────────────
|
||||
|
||||
describe("promptOverrides settings", () => {
|
||||
|
||||
@@ -16,6 +16,34 @@ import { getTaskMergeBlocker } from "./task-merge.js";
|
||||
import { ensureMemoryFile } from "./project-memory.js";
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
|
||||
/**
|
||||
* Legacy backup directory default value from the old .kb storage structure.
|
||||
* Projects that were created before the .fusion rename may still have this
|
||||
* value persisted in their config. It is canonicalized to the new default
|
||||
* so that all backup operations use a consistent directory.
|
||||
*/
|
||||
const LEGACY_BACKUP_DIR = ".kb/backups";
|
||||
|
||||
/**
|
||||
* Canonicalizes a settings object by resolving legacy defaults.
|
||||
* Currently handles the .kb/backups → .fusion/backups migration.
|
||||
*
|
||||
* This function applies only the exact-match legacy alias transformation.
|
||||
* Other custom .kb/* paths are preserved as-is.
|
||||
*/
|
||||
function canonicalizeSettings(settings: Settings): Settings {
|
||||
// Canonicalize the legacy backup directory default to the new location.
|
||||
// Only the exact legacy default value is transformed — custom paths like
|
||||
// ".kb/my-custom-backups" are preserved unchanged.
|
||||
if ((settings as Partial<ProjectSettings>).autoBackupDir === LEGACY_BACKUP_DIR) {
|
||||
return {
|
||||
...settings,
|
||||
autoBackupDir: ".fusion/backups",
|
||||
};
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
"task:moved": [data: { task: Task; from: Column; to: Column }];
|
||||
@@ -621,17 +649,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
*
|
||||
* Returns the combined view that most consumers should use. Project-level
|
||||
* values in `.fusion/config.json` override global values from `~/.pi/fusion/settings.json`.
|
||||
*
|
||||
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
|
||||
*/
|
||||
async getSettings(): Promise<Settings> {
|
||||
const [globalSettings, config] = await Promise.all([
|
||||
this.globalSettingsStore.getSettings(),
|
||||
this.readConfig(),
|
||||
]);
|
||||
return {
|
||||
return canonicalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...config.settings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,6 +673,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* read-heavy paths like the settings page that don't need workflow steps.
|
||||
*
|
||||
* Note: Do NOT use this method when you need workflow steps — use `getSettings()` instead.
|
||||
*
|
||||
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
|
||||
*/
|
||||
async getSettingsFast(): Promise<Settings> {
|
||||
const [globalSettings, row] = await Promise.all([
|
||||
@@ -652,17 +684,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
|
||||
|
||||
return {
|
||||
return canonicalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...projectSettings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings separated by scope. Returns both the global and
|
||||
* project-level settings independently (useful for the UI to show
|
||||
* which scope a value comes from).
|
||||
*
|
||||
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
|
||||
*/
|
||||
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||
const [globalSettings, config] = await Promise.all([
|
||||
@@ -680,7 +714,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
return { global: globalSettings, project: projectSettings };
|
||||
// Apply canonicalization to both the project settings and the merged result
|
||||
const canonicalizedProject = canonicalizeSettings(projectSettings as Settings);
|
||||
|
||||
return { global: globalSettings, project: canonicalizedProject };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user