fix: restore ntfyDashboardHost setting and add schema protection for global settings
The ntfyDashboardHost field was incorrectly removed in 3eeac0a0 as part of
an unrelated FN-672 commit. This restores the setting to GlobalSettings (moved
from ProjectSettings where it was misplaced), re-adds the UI field and deep
link handling, and adds schema protection so unknown keys in settings.json
survive code-level schema changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,64 @@ describe("GlobalSettingsStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema protection", () => {
|
||||
it("preserves unknown keys during updateSettings", async () => {
|
||||
await store.init();
|
||||
|
||||
// Simulate a setting that existed in an older schema version
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
raw.legacyCustomField = "preserve-me";
|
||||
raw.anotherRemovedSetting = 42;
|
||||
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
|
||||
|
||||
// Update a known field — unknown keys must survive the write cycle
|
||||
await store.updateSettings({ ntfyEnabled: true });
|
||||
|
||||
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(ondisk.legacyCustomField).toBe("preserve-me");
|
||||
expect(ondisk.anotherRemovedSetting).toBe(42);
|
||||
expect(ondisk.ntfyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("readRaw returns all keys including unknown ones", async () => {
|
||||
await store.init();
|
||||
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
raw.futureField = "hello";
|
||||
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
|
||||
|
||||
const result = await store.readRaw();
|
||||
expect(result.futureField).toBe("hello");
|
||||
expect(result.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("readRaw returns empty object for missing file", async () => {
|
||||
const emptyStore = new GlobalSettingsStore(join(dir, "nonexistent"));
|
||||
const result = await emptyStore.readRaw();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves unknown keys across multiple save cycles", async () => {
|
||||
await store.init();
|
||||
|
||||
// Inject an unknown key
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
raw.removedFeatureHost = "http://example.com";
|
||||
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
|
||||
|
||||
// Multiple save cycles with different known fields
|
||||
await store.updateSettings({ ntfyEnabled: true });
|
||||
await store.updateSettings({ ntfyTopic: "test-topic" });
|
||||
await store.updateSettings({ themeMode: "light" });
|
||||
|
||||
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(ondisk.removedFeatureHost).toBe("http://example.com");
|
||||
expect(ondisk.ntfyEnabled).toBe(true);
|
||||
expect(ondisk.ntfyTopic).toBe("test-topic");
|
||||
expect(ondisk.themeMode).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingsPath()", () => {
|
||||
it("returns the path to settings.json", () => {
|
||||
const path = store.getSettingsPath();
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
* They include UI theme preferences, default AI model selection, and
|
||||
* notification configuration.
|
||||
*
|
||||
* **Schema protection**: The store preserves any keys found in the settings
|
||||
* file that are not part of the current `GlobalSettings` schema. This prevents
|
||||
* data loss when schema changes remove fields — the values remain on disk and
|
||||
* can be restored if the field is re-added later. See `readRaw()`.
|
||||
*
|
||||
* @see {@link GlobalSettings} for the full list of global fields.
|
||||
*/
|
||||
|
||||
@@ -81,19 +86,31 @@ export class GlobalSettingsStore {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw JSON object from disk without applying defaults.
|
||||
* Returns all keys present in the file, including any that are no longer
|
||||
* part of the current GlobalSettings schema. Returns an empty object if
|
||||
* the file is missing or invalid.
|
||||
*
|
||||
* This is the foundation of schema protection — unknown keys survive
|
||||
* read-modify-write cycles because they flow through this method.
|
||||
*/
|
||||
async readRaw(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await readFile(this.settingsPath, "utf-8");
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read global settings from disk. Returns defaults merged with persisted values.
|
||||
* If the file doesn't exist or is invalid, returns defaults without throwing.
|
||||
*/
|
||||
async getSettings(): Promise<GlobalSettings> {
|
||||
try {
|
||||
const raw = await readFile(this.settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<GlobalSettings>;
|
||||
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed };
|
||||
} catch {
|
||||
// File missing, unreadable, or invalid JSON → return defaults
|
||||
return { ...DEFAULT_GLOBAL_SETTINGS };
|
||||
}
|
||||
const parsed = await this.readRaw();
|
||||
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed } as GlobalSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,15 +118,19 @@ export class GlobalSettingsStore {
|
||||
* Only fields present in the patch are overwritten; other fields are preserved.
|
||||
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
|
||||
*
|
||||
* **Schema protection**: reads the raw file (including unknown keys) before
|
||||
* merging, so fields that were removed from the TypeScript schema are not
|
||||
* silently dropped during save cycles.
|
||||
*
|
||||
* @returns The full updated settings after merge.
|
||||
*/
|
||||
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||
return this.withLock(async () => {
|
||||
const current = await this.getSettings();
|
||||
const updated = { ...current, ...patch };
|
||||
const raw = await this.readRaw();
|
||||
const merged = { ...DEFAULT_GLOBAL_SETTINGS, ...raw, ...patch };
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await this.atomicWrite(updated);
|
||||
return updated;
|
||||
await this.atomicWrite(merged as GlobalSettings);
|
||||
return merged as GlobalSettings;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -602,6 +602,10 @@ export interface GlobalSettings {
|
||||
* If undefined or empty when ntfyEnabled is true, all events are sent (backward compatible).
|
||||
* Default: ["in-review", "merged", "failed"] */
|
||||
ntfyEvents?: NtfyNotificationEvent[];
|
||||
/** Dashboard hostname for ntfy.sh deep links. When set along with ntfyEnabled
|
||||
* and ntfyTopic, notifications include a Click URL that opens the dashboard
|
||||
* directly to the task. Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
ntfyDashboardHost?: string;
|
||||
/** The default project ID for CLI operations when --project flag is not provided.
|
||||
* Used to determine which project to operate on when not in a project directory.
|
||||
* Set via `kb project set-default <name>`. */
|
||||
@@ -779,9 +783,6 @@ export interface ProjectSettings {
|
||||
/** Reference to a named script in the scripts map that runs before task execution.
|
||||
* Used for pre-task setup like environment preparation. */
|
||||
setupScript?: string;
|
||||
/** Dashboard host URL for ntfy notifications (e.g., "http://localhost:3000").
|
||||
* When set, notifications include links to the dashboard. */
|
||||
ntfyDashboardHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -811,6 +812,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed"],
|
||||
ntfyDashboardHost: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -878,6 +880,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"ntfyEvents",
|
||||
"ntfyDashboardHost",
|
||||
"defaultProjectId",
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user