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:
gsxdsm
2026-04-03 09:02:41 -07:00
parent 549f1aa362
commit 6ab7f24bba
5 changed files with 140 additions and 15 deletions

View File

@@ -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();

View File

@@ -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;
});
}

View File

@@ -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;