feat(FN-4621): complete Step 6 — add dotted worktrunk CLI settings

Fusion-Task-Id: FN-4621
Fusion-Task-Lineage: fc1b0b13-9052-4378-86e3-3634d6c9db4e
This commit is contained in:
Fusion
2026-05-15 10:59:32 -07:00
committed by gsxdsm
parent 32228c1686
commit 88d9700b8c
2 changed files with 181 additions and 11 deletions

View File

@@ -15,6 +15,11 @@ vi.mock("@fusion/core", () => {
defaultModelId: undefined,
defaultNodeId: undefined,
unavailableNodePolicy: undefined,
worktrunk: {
enabled: false,
binaryPath: undefined,
onFailure: "fail",
},
};
return {
@@ -59,6 +64,9 @@ describe("settings commands", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(VALID_SETTINGS).toContain("defaultNodeId");
expect(VALID_SETTINGS).toContain("unavailableNodePolicy");
expect(VALID_SETTINGS).toContain("worktrunk.enabled");
expect(VALID_SETTINGS).toContain("worktrunk.binaryPath");
expect(VALID_SETTINGS).toContain("worktrunk.onFailure");
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
expect(parseValue("maxConcurrent", "4")).toBe(4);
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
@@ -67,6 +75,10 @@ describe("settings commands", () => {
expect(parseValue("unavailableNodePolicy", "block")).toBe("block");
expect(parseValue("unavailableNodePolicy", "fallback-local")).toBe("fallback-local");
expect(() => parseValue("unavailableNodePolicy", "invalid")).toThrow(/block, fallback-local/);
expect(parseValue("worktrunk.enabled", "true" as any)).toBe(true);
expect(parseValue("worktrunk.binaryPath", "/usr/local/bin/worktrunk" as any)).toBe("/usr/local/bin/worktrunk");
expect(parseValue("worktrunk.onFailure", "fallback-native" as any)).toBe("fallback-native");
expect(() => parseValue("worktrunk.onFailure", "ignore" as any)).toThrow(/fail, fallback-native/);
});
it("runSettingsShow without project uses global settings even if a project could resolve", async () => {
@@ -248,6 +260,106 @@ describe("settings commands", () => {
expect(output).toContain("Max Parallel Steps");
});
it("runSettingsSet supports worktrunk dotted keys in global scope", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({
worktrunk: { enabled: true, binaryPath: "/usr/local/bin/worktrunk", onFailure: "fail" },
}));
const getSettings = vi.fn().mockResolvedValue(makeSettings({
worktrunk: { enabled: false, binaryPath: "/usr/local/bin/worktrunk", onFailure: "fail" },
}));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("worktrunk.enabled", "true");
await runSettingsSet("worktrunk.onFailure", "fallback-native");
await runSettingsSet("worktrunk.binaryPath", "/usr/local/bin/worktrunk");
expect(updateSettings).toHaveBeenNthCalledWith(1, {
worktrunk: { enabled: true, binaryPath: "/usr/local/bin/worktrunk", onFailure: "fail" },
});
expect(updateSettings).toHaveBeenNthCalledWith(2, {
worktrunk: { enabled: false, binaryPath: "/usr/local/bin/worktrunk", onFailure: "fallback-native" },
});
expect(updateSettings).toHaveBeenNthCalledWith(3, {
worktrunk: { enabled: false, binaryPath: "/usr/local/bin/worktrunk", onFailure: "fail" },
});
});
it("runSettingsSet rejects invalid worktrunk onFailure enum", async () => {
const updateSettings = vi.fn();
const getSettings = vi.fn().mockResolvedValue(makeSettings());
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await expect(runSettingsSet("worktrunk.onFailure", "ignore")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Valid options: fail, fallback-native"));
});
it("runSettingsSet preserves existing project worktrunk sibling fields", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings());
const getSettingsByScope = vi.fn().mockResolvedValue({
global: makeSettings(),
project: {
worktrunk: {
enabled: true,
binaryPath: "/opt/bin/worktrunk",
onFailure: "fallback-native",
},
},
});
const getSettings = vi.fn().mockResolvedValue(makeSettings());
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettingsByScope, getSettings } as any,
});
await runSettingsSet("worktrunk.enabled", "false", "demo-project");
expect(updateSettings).toHaveBeenCalledWith({
worktrunk: {
enabled: false,
binaryPath: "/opt/bin/worktrunk",
onFailure: "fallback-native",
},
});
});
it("runSettingsShow includes Worktrunk integration section", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({
worktrunk: {
enabled: true,
binaryPath: "/usr/local/bin/worktrunk",
onFailure: "fallback-native",
},
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
await runSettingsShow("demo-project");
const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n");
expect(output).toContain("Worktrunk integration");
expect(output).toContain("Worktrunk Enabled");
expect(output).toContain("Worktrunk Binary Path");
expect(output).toContain("Worktrunk On Failure");
});
it("runSettingsShow includes Node Routing section", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({
defaultNodeId: "node-abc",

View File

@@ -18,6 +18,9 @@ export const VALID_SETTINGS = [
"maxParallelSteps",
"defaultNodeId",
"unavailableNodePolicy",
"worktrunk.enabled",
"worktrunk.binaryPath",
"worktrunk.onFailure",
] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
@@ -45,6 +48,7 @@ const BOOLEAN_SETTINGS: readonly string[] = [
"requirePlanApproval",
"ntfyEnabled",
"runStepsInNewSessions",
"worktrunk.enabled",
];
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "maxParallelSteps"];
@@ -52,9 +56,17 @@ const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "ma
const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
"worktrunk.onFailure": ["fail", "fallback-native"],
};
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel", "defaultNodeId", "worktreesDir"];
const STRING_SETTINGS: readonly string[] = [
"taskPrefix",
"ntfyTopic",
"defaultModel",
"defaultNodeId",
"worktreesDir",
"worktrunk.binaryPath",
];
// Validation ranges for numeric settings
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
@@ -130,8 +142,35 @@ export function parseValue(key: ValidSettingKey, value: string): unknown {
/**
* Format a setting value for display
*/
function getDottedValue(obj: unknown, key: string): unknown {
return key.split(".").reduce<unknown>((acc, part) => {
if (!acc || typeof acc !== "object") return undefined;
return (acc as Record<string, unknown>)[part];
}, obj);
}
function applyDottedSetting(key: string, value: unknown, current: Record<string, unknown>): Record<string, unknown> {
if (!key.includes(".")) {
return { [key]: value };
}
const [root, leaf] = key.split(".", 2);
const existingRoot = current[root];
const nested =
existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot)
? (existingRoot as Record<string, unknown>)
: {};
return {
[root]: {
...nested,
[leaf]: value,
},
};
}
function formatSettingValue(
key: keyof Settings,
key: string,
value: unknown,
_settings: GlobalSettings | Settings
): string {
@@ -152,7 +191,7 @@ function formatSettingValue(
}
if (typeof value === "string") {
const defaultValue = DEFAULT_SETTINGS[key];
const defaultValue = getDottedValue(DEFAULT_SETTINGS, key);
if (value === defaultValue) {
return `"${value}" (default)`;
}
@@ -168,6 +207,9 @@ function formatSettingValue(
function getSettingLabel(key: string): string {
if (key === "ntfyEnabled") return "ntfy Enabled";
if (key === "ntfyTopic") return "ntfy Topic";
if (key === "worktrunk.enabled") return "Worktrunk Enabled";
if (key === "worktrunk.binaryPath") return "Worktrunk Binary Path";
if (key === "worktrunk.onFailure") return "Worktrunk On Failure";
return key
.replace(/([A-Z])/g, " $1")
@@ -206,6 +248,10 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
title: "Worktrees",
keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"],
},
{
title: "Worktrunk integration",
keys: ["worktrunk.enabled", "worktrunk.binaryPath", "worktrunk.onFailure"],
},
{
title: "Tasks",
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
@@ -225,17 +271,16 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
];
for (const group of settingGroups) {
const settingsRecord = settings as Record<string, unknown>;
const hasValues = group.keys.some((key) => settingsRecord[key] !== undefined);
const hasValues = group.keys.some((key) => getDottedValue(settings, key) !== undefined);
if (!hasValues) continue;
console.log();
console.log(` ${group.title}:`);
for (const key of group.keys) {
const value = settingsRecord[key];
const value = getDottedValue(settings, key);
const label = getSettingLabel(key);
const formattedValue = formatSettingValue(key as keyof Settings, value, settings);
const formattedValue = formatSettingValue(key, value, settings);
console.log(` ${label.padEnd(25)} ${formattedValue}`);
}
}
@@ -300,16 +345,29 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
return;
}
const patch: Partial<Settings> = { [key]: parsedValue };
if (store) {
await store.updateSettings(patch);
if (key.includes(".")) {
const currentSettings = await store.getSettingsByScope();
const projectPatch = applyDottedSetting(
key,
parsedValue,
(currentSettings.project ?? {}) as Record<string, unknown>,
);
await store.updateSettings(projectPatch as Partial<Settings>);
} else {
await store.updateSettings({ [key]: parsedValue } as Partial<Settings>);
}
} else if (key.includes(".")) {
const currentGlobalSettings = await globalStore!.getSettings();
const globalPatch = applyDottedSetting(key, parsedValue, currentGlobalSettings as Record<string, unknown>);
await globalStore!.updateSettings(globalPatch as Partial<GlobalSettings>);
} else {
await globalStore!.updateSettings(patch);
await globalStore!.updateSettings({ [key]: parsedValue } as Partial<GlobalSettings>);
}
const currentSettings = store ? await store.getSettings() : await globalStore!.getSettings();
console.log();
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key as keyof Settings, parsedValue, currentSettings as Settings)}`);
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key, parsedValue, currentSettings as Settings)}`);
console.log();
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);