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;
|
||||
|
||||
|
||||
@@ -190,6 +190,27 @@ function AppInner() {
|
||||
.catch(() => {/* keep empty array on failure */});
|
||||
}, []);
|
||||
|
||||
// Handle deep link to task on mount (from ntfy Click URLs)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const taskId = params.get("task");
|
||||
if (!taskId) return;
|
||||
|
||||
// Clean URL immediately without reloading
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("task");
|
||||
url.searchParams.delete("project");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
||||
fetchTaskDetail(taskId)
|
||||
.then((task) => {
|
||||
handleDetailOpen(task);
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Favorite provider/model toggle handlers (shared by ListView bulk edit)
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
|
||||
@@ -1584,6 +1584,28 @@ export function SettingsModal({
|
||||
<small>When a task fails during execution (high priority)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
|
||||
<input
|
||||
id="ntfyDashboardHost"
|
||||
type="text"
|
||||
placeholder="http://localhost:3000"
|
||||
value={form.ntfyDashboardHost || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Base URL for deep links in notifications. When set, clicking a notification
|
||||
opens the dashboard directly to the task.
|
||||
</small>
|
||||
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
|
||||
<small className="field-error">
|
||||
Must be a valid URL starting with http:// or https://
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user