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:
@@ -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",
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user