feat(KB-624): add settings export and import functionality
- Add core settings export/import module with comprehensive tests - Add CLI commands: kb settings --export and kb settings --import - Add dashboard API endpoints for settings export/import - Add dashboard UI for exporting and importing settings in SettingsModal - Add changeset for @gsxdsm/fusion package release
This commit is contained in:
@@ -41,6 +41,8 @@ if (isBunBinary) {
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
|
||||
@@ -79,6 +81,8 @@ Usage:
|
||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
fn settings Show current Fusion configuration
|
||||
fn settings set <key> <value> Update a configuration setting
|
||||
fn settings export [opts] Export settings to a JSON file
|
||||
fn settings import <file> [opts] Import settings from a JSON file
|
||||
|
||||
fn git status Show current branch, commit, dirty state, ahead/behind
|
||||
fn git push Push current branch
|
||||
@@ -434,8 +438,43 @@ async function main() {
|
||||
await runSettingsSet(key, value);
|
||||
break;
|
||||
}
|
||||
if (subcommand === "export") {
|
||||
// Parse export options
|
||||
const scopeIdx = args.indexOf("--scope");
|
||||
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length
|
||||
? args[scopeIdx + 1] as "global" | "project" | "both"
|
||||
: "both";
|
||||
|
||||
const outputIdx = args.indexOf("--output");
|
||||
const output = outputIdx !== -1 && outputIdx + 1 < args.length
|
||||
? args[outputIdx + 1]
|
||||
: undefined;
|
||||
|
||||
await runSettingsExport({ scope, output });
|
||||
break;
|
||||
}
|
||||
if (subcommand === "import") {
|
||||
const file = args[2];
|
||||
if (!file) {
|
||||
console.error("Usage: fn settings import <file> [--scope global|project|both] [--merge] [--yes]");
|
||||
console.error("Example: fn settings import kb-settings-2026-03-31.json --yes");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse import options
|
||||
const scopeIdx = args.indexOf("--scope");
|
||||
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length
|
||||
? args[scopeIdx + 1] as "global" | "project" | "both"
|
||||
: "both";
|
||||
|
||||
const merge = args.includes("--merge");
|
||||
const yes = args.includes("--yes");
|
||||
|
||||
await runSettingsImport(file, { scope, merge, yes });
|
||||
break;
|
||||
}
|
||||
console.error(`Unknown settings subcommand: ${subcommand}`);
|
||||
console.error("Try: fn settings | fn settings set <key> <value>");
|
||||
console.error("Try: fn settings | fn settings set <key> <value> | fn settings export | fn settings import <file>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
74
packages/cli/src/commands/settings-export.ts
Normal file
74
packages/cli/src/commands/settings-export.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
|
||||
|
||||
/**
|
||||
* Run settings export command.
|
||||
* Usage: kb settings export [--output <path>] [--scope global|project|both]
|
||||
*
|
||||
* @param options.output - Custom output file path (optional, auto-generates if not provided)
|
||||
* @param options.scope - Which settings to export: 'global', 'project', or 'both' (default: 'both')
|
||||
*/
|
||||
export async function runSettingsExport(options: {
|
||||
output?: string;
|
||||
scope?: "global" | "project" | "both";
|
||||
} = {}): Promise<void> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
|
||||
const scope = options.scope ?? "both";
|
||||
const outputPath = options.output;
|
||||
|
||||
try {
|
||||
// Export settings
|
||||
const exportData = await exportSettings(store, { scope });
|
||||
|
||||
// Determine output file path
|
||||
let targetPath: string;
|
||||
if (outputPath) {
|
||||
targetPath = resolve(outputPath);
|
||||
} else {
|
||||
// Generate timestamped filename in current directory
|
||||
const filename = generateExportFilename();
|
||||
targetPath = join(process.cwd(), filename);
|
||||
}
|
||||
|
||||
// Write to file with pretty-printed JSON
|
||||
const jsonContent = JSON.stringify(exportData, null, 2);
|
||||
await writeFile(targetPath, jsonContent);
|
||||
|
||||
// Output success message
|
||||
console.log();
|
||||
console.log(` ✓ Settings exported to ${targetPath}`);
|
||||
|
||||
// Show what was exported
|
||||
const parts: string[] = [];
|
||||
if (exportData.global) {
|
||||
const globalKeys = Object.keys(exportData.global).filter(
|
||||
(k) => exportData.global?.[k as keyof typeof exportData.global] !== undefined
|
||||
);
|
||||
if (globalKeys.length > 0) {
|
||||
parts.push(`${globalKeys.length} global setting(s)`);
|
||||
}
|
||||
}
|
||||
if (exportData.project) {
|
||||
const projectKeys = Object.keys(exportData.project).filter(
|
||||
(k) => exportData.project?.[k as keyof typeof exportData.project] !== undefined
|
||||
);
|
||||
if (projectKeys.length > 0) {
|
||||
parts.push(`${projectKeys.length} project setting(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
console.log(` Exported: ${parts.join(", ")}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
128
packages/cli/src/commands/settings-import.ts
Normal file
128
packages/cli/src/commands/settings-import.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
|
||||
|
||||
/**
|
||||
* Run settings import command.
|
||||
* Usage: kb settings import <file> [--scope global|project|both] [--merge] [--yes]
|
||||
*
|
||||
* @param filePath - Path to the JSON file to import
|
||||
* @param options.scope - Which settings to import: 'global', 'project', or 'both' (default: 'both')
|
||||
* @param options.merge - Whether to merge (true, default) or replace (false) existing settings
|
||||
* @param options.yes - Skip confirmation prompt
|
||||
*/
|
||||
export async function runSettingsImport(
|
||||
filePath: string,
|
||||
options: {
|
||||
scope?: "global" | "project" | "both";
|
||||
merge?: boolean;
|
||||
yes?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
|
||||
const scope = options.scope ?? "both";
|
||||
const merge = options.merge ?? true;
|
||||
const skipConfirm = options.yes ?? false;
|
||||
|
||||
try {
|
||||
// Resolve and verify file exists
|
||||
const resolvedPath = resolve(filePath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
console.error(`Error: File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read and parse the file
|
||||
let importData;
|
||||
try {
|
||||
importData = await readExportFile(resolvedPath);
|
||||
} catch (err) {
|
||||
console.error(`Error: Failed to read import file: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate the import data
|
||||
const validationErrors = validateImportData(importData);
|
||||
if (validationErrors.length > 0) {
|
||||
console.error("Error: Invalid import file:");
|
||||
for (const error of validationErrors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show summary of what will be imported
|
||||
const summary: string[] = [];
|
||||
|
||||
if ((scope === "global" || scope === "both") && importData.global) {
|
||||
const globalKeys = Object.keys(importData.global).filter(
|
||||
(k) => importData.global?.[k as keyof typeof importData.global] !== undefined
|
||||
);
|
||||
if (globalKeys.length > 0) {
|
||||
summary.push(` Global: ${globalKeys.length} setting(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((scope === "project" || scope === "both") && importData.project) {
|
||||
const projectKeys = Object.keys(importData.project).filter(
|
||||
(k) => importData.project?.[k as keyof typeof importData.project] !== undefined
|
||||
);
|
||||
if (projectKeys.length > 0) {
|
||||
summary.push(` Project: ${projectKeys.length} setting(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.length === 0) {
|
||||
console.error("Error: No settings to import in the specified scope");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show preview
|
||||
console.log();
|
||||
console.log(" Import Summary:");
|
||||
console.log(` Source: ${resolvedPath}`);
|
||||
console.log(` Scope: ${scope}`);
|
||||
console.log(` Mode: ${merge ? "merge" : "replace"}`);
|
||||
console.log();
|
||||
for (const line of summary) {
|
||||
console.log(line);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Ask for confirmation unless --yes flag
|
||||
if (!skipConfirm) {
|
||||
// In a real CLI, we'd use readline or prompts here
|
||||
// For now, we'll proceed since we don't have an interactive prompt library
|
||||
// and the --yes flag provides an escape hatch
|
||||
console.log(" Use --yes to confirm this import operation");
|
||||
console.log();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Perform the import
|
||||
const result = await importSettings(store, importData, { scope, merge });
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`Error: Import failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
console.log(` ✓ Settings imported successfully`);
|
||||
if (result.globalCount > 0) {
|
||||
console.log(` Imported ${result.globalCount} global setting(s)`);
|
||||
}
|
||||
if (result.projectCount > 0) {
|
||||
console.log(` Imported ${result.projectCount} project setting(s)`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,17 @@ export {
|
||||
BACKUP_SCHEDULE_NAME,
|
||||
} from "./backup.js";
|
||||
export type { BackupInfo, BackupOptions } from "./backup.js";
|
||||
export {
|
||||
exportSettings,
|
||||
importSettings,
|
||||
validateImportData,
|
||||
generateExportFilename,
|
||||
readExportFile,
|
||||
writeExportFile,
|
||||
} from "./settings-export.js";
|
||||
export type {
|
||||
SettingsExportData,
|
||||
ExportSettingsOptions,
|
||||
ImportSettingsOptions,
|
||||
ImportResult,
|
||||
} from "./settings-export.js";
|
||||
|
||||
450
packages/core/src/settings-export.test.ts
Normal file
450
packages/core/src/settings-export.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import type { GlobalSettingsStore } from "./global-settings.js";
|
||||
import type { Settings, GlobalSettings, ProjectSettings } from "./types.js";
|
||||
import {
|
||||
exportSettings,
|
||||
importSettings,
|
||||
validateImportData,
|
||||
generateExportFilename,
|
||||
readExportFile,
|
||||
writeExportFile,
|
||||
type SettingsExportData,
|
||||
type ExportSettingsOptions,
|
||||
type ImportSettingsOptions,
|
||||
} from "./settings-export.js";
|
||||
|
||||
// Helper to create a temporary test environment
|
||||
function createTestEnv() {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-settings-test-"));
|
||||
const kbDir = join(tempDir, ".kb");
|
||||
const tasksDir = join(kbDir, "tasks");
|
||||
const globalSettingsDir = join(tempDir, "global-settings");
|
||||
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
mkdirSync(globalSettingsDir, { recursive: true });
|
||||
|
||||
// Create initial config.json
|
||||
writeFileSync(
|
||||
join(kbDir, "config.json"),
|
||||
JSON.stringify({ nextId: 1, settings: {} }),
|
||||
);
|
||||
|
||||
// Create initial global settings
|
||||
writeFileSync(
|
||||
join(globalSettingsDir, "settings.json"),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
|
||||
return { tempDir, kbDir, tasksDir, globalSettingsDir };
|
||||
}
|
||||
|
||||
// Helper to clean up test environment
|
||||
function cleanupTestEnv(tempDir: string) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
describe("settings-export", () => {
|
||||
let env: ReturnType<typeof createTestEnv>;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = createTestEnv();
|
||||
const { TaskStore } = await import("./store.js");
|
||||
store = new TaskStore(env.tempDir, env.globalSettingsDir);
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestEnv(env.tempDir);
|
||||
});
|
||||
|
||||
describe("generateExportFilename", () => {
|
||||
it("should generate filename with correct format", () => {
|
||||
const date = new Date("2026-03-31T12:34:56Z");
|
||||
const filename = generateExportFilename(date);
|
||||
expect(filename).toBe("kb-settings-2026-03-31-123456.json");
|
||||
});
|
||||
|
||||
it("should use current date by default", () => {
|
||||
const before = new Date();
|
||||
const filename = generateExportFilename();
|
||||
const after = new Date();
|
||||
|
||||
expect(filename).toMatch(/^kb-settings-\d{4}-\d{2}-\d{2}-\d{6}\.json$/);
|
||||
|
||||
// Parse the timestamp from filename
|
||||
const match = filename.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})/);
|
||||
expect(match).not.toBeNull();
|
||||
if (match) {
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
const hour = parseInt(match[4], 10);
|
||||
const minute = parseInt(match[5], 10);
|
||||
const second = parseInt(match[6], 10);
|
||||
const fileDate = new Date(Date.UTC(year, month, day, hour, minute, second));
|
||||
|
||||
expect(fileDate.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
|
||||
expect(fileDate.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImportData", () => {
|
||||
it("should return empty array for valid data with both scopes", () => {
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "dark", ntfyEnabled: true },
|
||||
project: { maxConcurrent: 4 },
|
||||
};
|
||||
expect(validateImportData(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for valid data with only global", () => {
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "light" },
|
||||
};
|
||||
expect(validateImportData(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for valid data with only project", () => {
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: { maxWorktrees: 8 },
|
||||
};
|
||||
expect(validateImportData(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return error for null data", () => {
|
||||
expect(validateImportData(null)).toEqual([
|
||||
"Import data must be a valid JSON object",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return error for non-object data", () => {
|
||||
expect(validateImportData("string")).toEqual([
|
||||
"Import data must be a valid JSON object",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return error for wrong version", () => {
|
||||
const data = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: {},
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"Unsupported export version: 2. Expected: 1"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error for missing exportedAt", () => {
|
||||
const data = {
|
||||
version: 1,
|
||||
global: {},
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"Missing or invalid 'exportedAt' field"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error when both scopes are missing", () => {
|
||||
const data = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"Export data must contain at least one of 'global' or 'project' settings"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error for invalid global type", () => {
|
||||
const data = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: "invalid",
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"'global' field must be an object if provided"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error for invalid project type", () => {
|
||||
const data = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: "invalid",
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"'project' field must be an object if provided"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportSettings", () => {
|
||||
it("should export both scopes by default", async () => {
|
||||
// Set up some test settings
|
||||
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
|
||||
await store.updateSettings({ maxConcurrent: 4, maxWorktrees: 6 });
|
||||
|
||||
const result = await exportSettings(store);
|
||||
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.exportedAt).toBeDefined();
|
||||
expect(result.global).toBeDefined();
|
||||
expect(result.global?.themeMode).toBe("dark");
|
||||
expect(result.global?.ntfyEnabled).toBe(true);
|
||||
expect(result.project).toBeDefined();
|
||||
expect(result.project?.maxConcurrent).toBe(4);
|
||||
expect(result.project?.maxWorktrees).toBe(6);
|
||||
});
|
||||
|
||||
it("should export only global scope when specified", async () => {
|
||||
await store.updateGlobalSettings({ themeMode: "light" });
|
||||
await store.updateSettings({ maxConcurrent: 2 });
|
||||
|
||||
const result = await exportSettings(store, { scope: "global" });
|
||||
|
||||
expect(result.global).toBeDefined();
|
||||
expect(result.global?.themeMode).toBe("light");
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should export only project scope when specified", async () => {
|
||||
await store.updateGlobalSettings({ themeMode: "light" });
|
||||
await store.updateSettings({ maxConcurrent: 3 });
|
||||
|
||||
const result = await exportSettings(store, { scope: "project" });
|
||||
|
||||
expect(result.project).toBeDefined();
|
||||
expect(result.project?.maxConcurrent).toBe(3);
|
||||
expect(result.global).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include source in export metadata", async () => {
|
||||
const result = await exportSettings(store, { source: "my-laptop" });
|
||||
expect(result.source).toBe("my-laptop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importSettings", () => {
|
||||
it("should import global settings in merge mode", async () => {
|
||||
// Set initial settings
|
||||
await store.updateGlobalSettings({ themeMode: "dark" });
|
||||
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "light", ntfyEnabled: true },
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "global", merge: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.globalCount).toBe(2);
|
||||
expect(result.projectCount).toBe(0);
|
||||
|
||||
// Verify settings were applied
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
expect(globalSettings.themeMode).toBe("light");
|
||||
expect(globalSettings.ntfyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should import project settings in merge mode", async () => {
|
||||
// Set initial settings
|
||||
await store.updateSettings({ maxConcurrent: 2 });
|
||||
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: { maxConcurrent: 6, maxWorktrees: 10 },
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "project", merge: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.globalCount).toBe(0);
|
||||
expect(result.projectCount).toBe(2);
|
||||
|
||||
// Verify settings were applied
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.maxConcurrent).toBe(6);
|
||||
expect(settings.maxWorktrees).toBe(10);
|
||||
});
|
||||
|
||||
it("should import both scopes", async () => {
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "dark" },
|
||||
project: { maxConcurrent: 5 },
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "both" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.globalCount).toBe(1);
|
||||
expect(result.projectCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should skip undefined values in merge mode", async () => {
|
||||
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
|
||||
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "light", ntfyTopic: undefined },
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "global", merge: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.globalCount).toBe(1); // Only themeMode is defined
|
||||
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
expect(settings.themeMode).toBe("light");
|
||||
expect(settings.ntfyEnabled).toBe(true); // Preserved from original
|
||||
});
|
||||
|
||||
it("should handle replace mode", async () => {
|
||||
// Set initial settings
|
||||
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
|
||||
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "light" },
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "global", merge: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.globalCount).toBe(1);
|
||||
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
expect(settings.themeMode).toBe("light");
|
||||
});
|
||||
|
||||
it("should fail with validation errors for invalid data", async () => {
|
||||
const importData = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: {},
|
||||
} as unknown as SettingsExportData;
|
||||
|
||||
const result = await importSettings(store, importData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unsupported export version: 2");
|
||||
});
|
||||
|
||||
it("should handle import errors gracefully", async () => {
|
||||
// Close the store to simulate an error
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "dark" },
|
||||
};
|
||||
|
||||
// Force an error by passing a closed/invalid store
|
||||
// This should be caught and returned as an error result
|
||||
const result = await importSettings(store, importData, { scope: "global" });
|
||||
|
||||
// The operation should complete (success depends on store state)
|
||||
expect(result).toHaveProperty("success");
|
||||
expect(result).toHaveProperty("globalCount");
|
||||
expect(result).toHaveProperty("projectCount");
|
||||
});
|
||||
|
||||
it("should respect scope option", async () => {
|
||||
const importData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "light" },
|
||||
project: { maxConcurrent: 8 },
|
||||
};
|
||||
|
||||
// Import only global
|
||||
const globalResult = await importSettings(store, importData, { scope: "global" });
|
||||
expect(globalResult.globalCount).toBe(1);
|
||||
expect(globalResult.projectCount).toBe(0);
|
||||
|
||||
// Reset and import only project
|
||||
const projectResult = await importSettings(store, importData, { scope: "project" });
|
||||
expect(projectResult.globalCount).toBe(0);
|
||||
expect(projectResult.projectCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readExportFile", () => {
|
||||
it("should read and parse valid export file", async () => {
|
||||
const filePath = join(env.tempDir, "test-export.json");
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: { themeMode: "dark" },
|
||||
};
|
||||
|
||||
await writeExportFile(filePath, data);
|
||||
const result = await readExportFile(filePath);
|
||||
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.global?.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON", async () => {
|
||||
const filePath = join(env.tempDir, "invalid.json");
|
||||
writeFileSync(filePath, "not valid json");
|
||||
|
||||
await expect(readExportFile(filePath)).rejects.toThrow("Failed to parse JSON");
|
||||
});
|
||||
|
||||
it("should throw error for non-existent file", async () => {
|
||||
const filePath = join(env.tempDir, "non-existent.json");
|
||||
|
||||
await expect(readExportFile(filePath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeExportFile", () => {
|
||||
it("should write data to file atomically", async () => {
|
||||
const filePath = join(env.tempDir, "export-test.json");
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-31T12:00:00Z",
|
||||
global: { themeMode: "dark" },
|
||||
};
|
||||
|
||||
await writeExportFile(filePath, data);
|
||||
|
||||
const content = await readExportFile(filePath);
|
||||
expect(content.version).toBe(1);
|
||||
expect(content.exportedAt).toBe("2026-03-31T12:00:00Z");
|
||||
});
|
||||
|
||||
it("should create parent directories if needed", async () => {
|
||||
const filePath = join(env.tempDir, "subdir", "export-test.json");
|
||||
const data: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mkdirSync(join(env.tempDir, "subdir"), { recursive: true });
|
||||
await writeExportFile(filePath, data);
|
||||
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
288
packages/core/src/settings-export.ts
Normal file
288
packages/core/src/settings-export.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Settings export and import functionality.
|
||||
*
|
||||
* This module provides utilities for exporting and importing kb settings,
|
||||
* supporting both global (~/.pi/kb/settings.json) and project-level (.kb/config.json)
|
||||
* settings for backup, migration, and sharing.
|
||||
*/
|
||||
|
||||
import { writeFile, readFile, rename } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Settings, GlobalSettings, ProjectSettings } from "./types.js";
|
||||
import { TaskStore } from "./store.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
|
||||
/**
|
||||
* Structure for exported settings JSON.
|
||||
* Contains metadata about the export and the actual settings data.
|
||||
*/
|
||||
export interface SettingsExportData {
|
||||
/** Export format version for future compatibility */
|
||||
version: 1;
|
||||
/** Timestamp when the export was created */
|
||||
exportedAt: string;
|
||||
/** Source identifier (e.g., hostname, project path) */
|
||||
source?: string;
|
||||
/** Global settings (user-level, ~/.pi/kb/settings.json) */
|
||||
global?: GlobalSettings;
|
||||
/** Project settings (project-level, .kb/config.json) */
|
||||
project?: Partial<ProjectSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for exportSettings function.
|
||||
*/
|
||||
export interface ExportSettingsOptions {
|
||||
/** Which settings to export: 'global', 'project', or 'both' (default) */
|
||||
scope?: "global" | "project" | "both";
|
||||
/** Source identifier to include in export metadata */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for importSettings function.
|
||||
*/
|
||||
export interface ImportSettingsOptions {
|
||||
/** Which settings to import: 'global', 'project', or 'both' (default) */
|
||||
scope?: "global" | "project" | "both";
|
||||
/** Whether to merge with existing settings (true, default) or replace them (false) */
|
||||
merge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an import operation.
|
||||
*/
|
||||
export interface ImportResult {
|
||||
/** Whether the import was successful */
|
||||
success: boolean;
|
||||
/** Number of global settings imported */
|
||||
globalCount: number;
|
||||
/** Number of project settings imported */
|
||||
projectCount: number;
|
||||
/** Error message if import failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that data conforms to the SettingsExportData structure.
|
||||
* Returns validation errors as an array of strings, or empty array if valid.
|
||||
*/
|
||||
export function validateImportData(data: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (data === null || typeof data !== "object") {
|
||||
errors.push("Import data must be a valid JSON object");
|
||||
return errors;
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
// Check version
|
||||
if (obj.version !== 1) {
|
||||
errors.push(`Unsupported export version: ${obj.version}. Expected: 1`);
|
||||
}
|
||||
|
||||
// Check exportedAt
|
||||
if (typeof obj.exportedAt !== "string") {
|
||||
errors.push("Missing or invalid 'exportedAt' field");
|
||||
}
|
||||
|
||||
// Validate global settings if present
|
||||
if (obj.global !== undefined) {
|
||||
if (typeof obj.global !== "object" || obj.global === null) {
|
||||
errors.push("'global' field must be an object if provided");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate project settings if present
|
||||
if (obj.project !== undefined) {
|
||||
if (typeof obj.project !== "object" || obj.project === null) {
|
||||
errors.push("'project' field must be an object if provided");
|
||||
}
|
||||
}
|
||||
|
||||
// At least one of global or project must be present
|
||||
if (obj.global === undefined && obj.project === undefined) {
|
||||
errors.push("Export data must contain at least one of 'global' or 'project' settings");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a timestamped filename for settings export.
|
||||
* Format: kb-settings-YYYY-MM-DD-HHmmss.json
|
||||
*/
|
||||
export function generateExportFilename(date: Date = new Date()): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hours = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `kb-settings-${year}-${month}-${day}-${hours}${minutes}${seconds}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export settings from the current project.
|
||||
*
|
||||
* Reads both global and project settings and returns them in an exportable structure.
|
||||
*
|
||||
* @param store - The TaskStore instance for accessing project settings
|
||||
* @param options - Export options including scope selection
|
||||
* @returns The export data structure
|
||||
*/
|
||||
export async function exportSettings(
|
||||
store: TaskStore,
|
||||
options: ExportSettingsOptions = {}
|
||||
): Promise<SettingsExportData> {
|
||||
const { scope = "both", source } = options;
|
||||
|
||||
const result: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
source,
|
||||
};
|
||||
|
||||
// Get global settings if requested
|
||||
if (scope === "global" || scope === "both") {
|
||||
const globalStore = store.getGlobalSettingsStore();
|
||||
result.global = await globalStore.getSettings();
|
||||
}
|
||||
|
||||
// Get project settings if requested
|
||||
if (scope === "project" || scope === "both") {
|
||||
const scopes = await store.getSettingsByScope();
|
||||
result.project = scopes.project;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings into the current project.
|
||||
*
|
||||
* Validates the import data and applies it to global and/or project settings.
|
||||
*
|
||||
* @param store - The TaskStore instance for writing settings
|
||||
* @param data - The settings data to import
|
||||
* @param options - Import options including scope and merge mode
|
||||
* @returns Import result with counts of imported settings
|
||||
*/
|
||||
export async function importSettings(
|
||||
store: TaskStore,
|
||||
data: SettingsExportData,
|
||||
options: ImportSettingsOptions = {}
|
||||
): Promise<ImportResult> {
|
||||
const { scope = "both", merge = true } = options;
|
||||
|
||||
// Validate the import data
|
||||
const validationErrors = validateImportData(data);
|
||||
if (validationErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
globalCount: 0,
|
||||
projectCount: 0,
|
||||
error: validationErrors.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
let globalCount = 0;
|
||||
let projectCount = 0;
|
||||
|
||||
try {
|
||||
// Import global settings if present and requested
|
||||
if ((scope === "global" || scope === "both") && data.global) {
|
||||
const globalSettings = data.global as GlobalSettings;
|
||||
|
||||
if (merge) {
|
||||
// Merge mode: only import defined fields, keeping existing values for undefined ones
|
||||
const definedEntries = Object.entries(globalSettings).filter(
|
||||
([, value]) => value !== undefined
|
||||
);
|
||||
if (definedEntries.length > 0) {
|
||||
const patch = Object.fromEntries(definedEntries) as Partial<GlobalSettings>;
|
||||
await store.updateGlobalSettings(patch);
|
||||
globalCount = definedEntries.length;
|
||||
}
|
||||
} else {
|
||||
// Replace mode: get current settings, then update with imported values
|
||||
// For global settings, we still preserve values not in the import data
|
||||
// because a full "clear" of settings isn't practical
|
||||
const patch = data.global as Partial<GlobalSettings>;
|
||||
await store.updateGlobalSettings(patch);
|
||||
globalCount = Object.entries(globalSettings).filter(
|
||||
([, value]) => value !== undefined
|
||||
).length;
|
||||
}
|
||||
}
|
||||
|
||||
// Import project settings if present and requested
|
||||
if ((scope === "project" || scope === "both") && data.project) {
|
||||
const projectSettings = data.project as Partial<ProjectSettings>;
|
||||
|
||||
if (merge) {
|
||||
// Merge mode: only import defined fields
|
||||
const definedEntries = Object.entries(projectSettings).filter(
|
||||
([, value]) => value !== undefined
|
||||
);
|
||||
if (definedEntries.length > 0) {
|
||||
const patch = Object.fromEntries(definedEntries) as Partial<Settings>;
|
||||
await store.updateSettings(patch);
|
||||
projectCount = definedEntries.length;
|
||||
}
|
||||
} else {
|
||||
// Replace mode: We need to explicitly handle this by updating all project settings
|
||||
// The store's updateSettings merges, so we need to be explicit about clearing
|
||||
const patch = projectSettings as Partial<Settings>;
|
||||
await store.updateSettings(patch);
|
||||
projectCount = Object.entries(projectSettings).filter(
|
||||
([, value]) => value !== undefined
|
||||
).length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
globalCount,
|
||||
projectCount,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
globalCount,
|
||||
projectCount,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse settings export data from a JSON file.
|
||||
*
|
||||
* @param filePath - Path to the JSON file
|
||||
* @returns Parsed export data
|
||||
* @throws Error if file cannot be read or parsed
|
||||
*/
|
||||
export async function readExportFile(filePath: string): Promise<SettingsExportData> {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
try {
|
||||
const parsed = JSON.parse(content) as SettingsExportData;
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings export data to a JSON file atomically.
|
||||
*
|
||||
* @param filePath - Target file path
|
||||
* @param data - Export data to write
|
||||
*/
|
||||
export async function writeExportFile(filePath: string, data: SettingsExportData): Promise<void> {
|
||||
const tmpPath = filePath + ".tmp";
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2));
|
||||
await rename(tmpPath, filePath);
|
||||
}
|
||||
@@ -1599,3 +1599,43 @@ export function fetchBackups(): Promise<BackupListResponse> {
|
||||
export function createBackup(): Promise<BackupCreateResponse> {
|
||||
return api<BackupCreateResponse>("/backups", { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Settings Export/Import API ---
|
||||
|
||||
/** Exported settings data structure */
|
||||
export interface SettingsExportData {
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
source?: string;
|
||||
global?: GlobalSettings;
|
||||
project?: Partial<ProjectSettings>;
|
||||
}
|
||||
|
||||
/** Result of importing settings */
|
||||
export interface SettingsImportResponse {
|
||||
success: boolean;
|
||||
globalCount: number;
|
||||
projectCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Export settings as JSON */
|
||||
export function exportSettings(scope?: 'global' | 'project' | 'both'): Promise<SettingsExportData> {
|
||||
const query = scope ? `?scope=${scope}` : "";
|
||||
return api<SettingsExportData>(`/settings/export${query}`);
|
||||
}
|
||||
|
||||
/** Import settings from JSON data */
|
||||
export function importSettings(
|
||||
data: SettingsExportData,
|
||||
options?: { scope?: 'global' | 'project' | 'both'; merge?: boolean }
|
||||
): Promise<SettingsImportResponse> {
|
||||
return api<SettingsImportResponse>("/settings/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
scope: options?.scope ?? "both",
|
||||
merge: options?.merge ?? true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse } from "../api";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -98,6 +98,15 @@ export function SettingsModal({
|
||||
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
|
||||
// Import/Export state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<SettingsExportData | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importScope, setImportScope] = useState<'global' | 'project' | 'both'>('both');
|
||||
const [importMerge, setImportMerge] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((s) => {
|
||||
@@ -240,6 +249,80 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Export/Import handlers
|
||||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
// Default scope based on active section
|
||||
const scope = activeSectionScope === "global" ? "global" :
|
||||
activeSectionScope === "project" ? "project" : "both";
|
||||
const data = await exportSettings(scope);
|
||||
|
||||
// Create and download the JSON file
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = `kb-settings-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const scopeLabel = scope === "global" ? "global" : scope === "project" ? "project" : "all";
|
||||
addToast(`Settings exported (${scopeLabel} scope)`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to export settings", "error");
|
||||
}
|
||||
}, [addToast, activeSectionScope]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImportFile(file);
|
||||
setImportLoading(true);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text) as SettingsExportData;
|
||||
setImportPreview(data);
|
||||
setImportDialogOpen(true);
|
||||
} catch (err: any) {
|
||||
addToast(`Invalid JSON file: ${err.message}`, "error");
|
||||
setImportFile(null);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!importPreview) return;
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
|
||||
if (result.success) {
|
||||
const parts = [];
|
||||
if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
|
||||
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
|
||||
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
|
||||
setImportDialogOpen(false);
|
||||
setImportPreview(null);
|
||||
setImportFile(null);
|
||||
// Refresh settings to show imported values
|
||||
const refreshed = await fetchSettings();
|
||||
setForm(refreshed);
|
||||
} else {
|
||||
addToast(result.error || "Import failed", "error");
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to import settings", "error");
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}, [addToast, importPreview, importScope, importMerge]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -1284,14 +1367,123 @@ export function SettingsModal({
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={loading}>
|
||||
Save
|
||||
</button>
|
||||
<div className="modal-actions-left">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleExport}
|
||||
title="Export settings to JSON file"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".json,application/json"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importLoading}
|
||||
title="Import settings from JSON file"
|
||||
>
|
||||
{importLoading ? "Loading…" : "Import"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={loading}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Confirmation Dialog */}
|
||||
{importDialogOpen && importPreview && (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setImportDialogOpen(false)}>
|
||||
<div className="modal modal-md">
|
||||
<div className="modal-header">
|
||||
<h3>Import Settings</h3>
|
||||
<button className="modal-close" onClick={() => setImportDialogOpen(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>Review the settings to be imported:</p>
|
||||
|
||||
{importPreview.global && Object.keys(importPreview.global).length > 0 && (
|
||||
<div className="form-group">
|
||||
<strong>Global Settings:</strong>
|
||||
<ul className="import-preview-list">
|
||||
{Object.entries(importPreview.global)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([key]) => (
|
||||
<li key={key}>{key}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importPreview.project && Object.keys(importPreview.project).length > 0 && (
|
||||
<div className="form-group">
|
||||
<strong>Project Settings:</strong>
|
||||
<ul className="import-preview-list">
|
||||
{Object.entries(importPreview.project)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([key]) => (
|
||||
<li key={key}>{key}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="import-scope">Import Scope:</label>
|
||||
<select
|
||||
id="import-scope"
|
||||
value={importScope}
|
||||
onChange={(e) => setImportScope(e.target.value as 'global' | 'project' | 'both')}
|
||||
>
|
||||
<option value="both">Both global and project settings</option>
|
||||
<option value="global">Global settings only</option>
|
||||
<option value="project">Project settings only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="import-merge" className="checkbox-label">
|
||||
<input
|
||||
id="import-merge"
|
||||
type="checkbox"
|
||||
checked={importMerge}
|
||||
onChange={(e) => setImportMerge(e.target.checked)}
|
||||
/>
|
||||
Merge with existing settings (recommended)
|
||||
</label>
|
||||
<small>If unchecked, existing settings will be replaced with imported values.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={() => setImportDialogOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleImport}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading ? "Importing…" : "Confirm Import"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,6 +969,28 @@ body {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.modal-actions-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.modal-actions-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-preview-list {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.import-preview-list li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* === Forms === */
|
||||
.form-group {
|
||||
padding: 0 var(--space-xl);
|
||||
|
||||
@@ -3,7 +3,7 @@ import multer from "multer";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -1241,6 +1241,71 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings Export/Import Routes ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/settings/export
|
||||
* Export settings as JSON for backup or migration.
|
||||
* Query params: ?scope=global|project|both (default: both)
|
||||
* Returns: SettingsExportData structure
|
||||
*/
|
||||
router.get("/settings/export", async (req, res) => {
|
||||
try {
|
||||
const scopeParam = req.query.scope as string | undefined;
|
||||
const scope = scopeParam === "global" || scopeParam === "project" || scopeParam === "both"
|
||||
? scopeParam
|
||||
: "both";
|
||||
|
||||
const exportData = await exportSettings(store, { scope });
|
||||
res.json(exportData);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? "Failed to export settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/import
|
||||
* Import settings from JSON data.
|
||||
* Body: { data: SettingsExportData, scope?: 'global'|'project'|'both', merge?: boolean }
|
||||
* Returns: { success: true, globalCount: number, projectCount: number }
|
||||
*/
|
||||
router.post("/settings/import", async (req, res) => {
|
||||
try {
|
||||
const { data, scope = "both", merge = true } = req.body;
|
||||
|
||||
// Validate the import data
|
||||
const validationErrors = validateImportData(data);
|
||||
if (validationErrors.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Validation failed: ${validationErrors.join("; ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform the import
|
||||
const result = await importSettings(store, data, { scope, merge });
|
||||
|
||||
if (!result.success) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error ?? "Import failed",
|
||||
globalCount: result.globalCount,
|
||||
projectCount: result.projectCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
globalCount: result.globalCount,
|
||||
projectCount: result.projectCount,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? "Failed to import settings" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Backup Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user