diff --git a/.changeset/fn-8452-update-unknown-flags.md b/.changeset/fn-8452-update-unknown-flags.md new file mode 100644 index 0000000000..8d916c4915 --- /dev/null +++ b/.changeset/fn-8452-update-unknown-flags.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reject unknown `fn update` flags and document the beta install bootstrap. +category: fix +dev: Strict argv allow-list for update/upgrade; duplicate options rejected; optional stable-channel beta availability notice; docs for npm @beta bootstrap (FN-8452 / #2368). diff --git a/RELEASING.md b/RELEASING.md index 89f34fa364..78d1e6e43a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -64,7 +64,7 @@ When you merge the Version Packages PR: ## Release tracks: beta and stable -Fusion ships on two tracks. Users pick theirs with the `updateChannel` global setting (Settings → General → Release channel) or `fn update --channel `. +Fusion ships on two tracks. Users pick theirs with the `updateChannel` global setting (Settings → General → Release channel) or `fn update --channel `. Older global installs that predate `--channel` can bootstrap with `npm install -g @runfusion/fusion@beta`. | Track | Cut from | Version shape | npm dist-tag | GitHub Release | Homebrew | |-------|----------|---------------|--------------|----------------|----------| diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 58f80022ce..204e3387e0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -153,6 +153,10 @@ fn upgrade | `--channel ` | Select the release track and persist it to global settings (`updateChannel`), shared with the dashboard and desktop updater. `stable` follows the npm `latest` dist-tag; `beta` follows the newer of `latest` and `beta`. | | `--force` | Install the resolved channel target even when it is not newer than the current version — the explicit beta → stable downgrade path. | +Unknown options and positional arguments are rejected with an error and non-zero exit code. Repeating any option, including `--channel`, is also rejected rather than silently choosing a value. + +If your installed CLI predates `--channel`, bootstrap onto beta with `npm install -g @runfusion/fusion@beta`. Once installed, use `fn update --channel beta` to persist the beta track. + `fn upgrade` is an alias for `fn update`. Installs always pin the exact resolved version rather than a dist-tag, so a beta-channel install can never silently land on stable (or vice versa). --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 32e1ce9c4c..65ae7b0a18 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -62,6 +62,8 @@ fusion --help If `fn update` fails with an npm bin-link collision (for example `EEXIST` / `File exists` mentioning `fn` or `fusion`), Fusion now retries once with `--force` automatically. +Older CLI installations may predate `fn update --channel beta`. Bootstrap directly onto the beta dist-tag with `npm install -g @runfusion/fusion@beta`, then use `fn update --channel beta` to persist that track. `fn update` rejects unknown or duplicate options with a non-zero exit code, so check spelling when it reports an option error. + If update still fails, run the manual recovery commands: ```bash diff --git a/packages/cli/src/__tests__/bin-update-args.test.ts b/packages/cli/src/__tests__/bin-update-args.test.ts new file mode 100644 index 0000000000..50bc4b9c5c --- /dev/null +++ b/packages/cli/src/__tests__/bin-update-args.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatchUpdateCliArgs, parseUpdateCliArgs } from "../commands/update.js"; + +describe("parseUpdateCliArgs", () => { + it("parses every supported option without changing channel semantics", () => { + expect(parseUpdateCliArgs(["--check", "--global", "--json", "--channel", "beta", "--force"])).toEqual({ + options: { check: true, global: true, json: true, channel: "beta", force: true }, + }); + expect(parseUpdateCliArgs(["--channel", "nightly"])).toEqual({ options: { channel: "nightly" } }); + }); + + it.each([ + ["--totally-bogus-flag-xyz"], + ["--beta"], + ["-x"], + ["something"], + ])("rejects unknown update argv token %s", (token) => { + expect(parseUpdateCliArgs([token])).toEqual({ + error: `Error: unknown option '${token}'. Valid options: --check, --global, --json, --channel , --force.`, + }); + }); + + it("rejects a missing channel value and duplicate options", () => { + expect(parseUpdateCliArgs(["--channel"])).toEqual({ + error: "Error: --channel requires a value: stable or beta.", + }); + expect(parseUpdateCliArgs(["--channel", "--check"])).toEqual({ + error: "Error: --channel requires a value: stable or beta.", + }); + expect(parseUpdateCliArgs(["--channel", "beta", "--channel", "stable"])).toEqual({ + error: "Error: duplicate option '--channel'.", + }); + expect(parseUpdateCliArgs(["--check", "--check"])).toEqual({ + error: "Error: duplicate option '--check'.", + }); + }); +}); + +describe("bin update/upgrade argv dispatch", () => { + it.each(["update", "upgrade"])("does not run an update for an unknown %s flag", async (command) => { + const runUpdate = vi.fn(async () => { + throw new Error("Already up to date."); + }); + const writeError = vi.fn(); + const exit = vi.fn(); + + await dispatchUpdateCliArgs(["--totally-bogus-flag-xyz"], { runUpdate, writeError, exit }); + + expect(exit).toHaveBeenCalledWith(1); + expect(writeError).toHaveBeenCalledWith(expect.stringContaining("--totally-bogus-flag-xyz")); + expect(runUpdate, `${command} must not reach success/status handling`).not.toHaveBeenCalled(); + expect(writeError.mock.calls.flat().join("\n")).not.toContain("Already up to date."); + }); + + it("blocks a stray positional without printing a successful JSON status", async () => { + const runUpdate = vi.fn(async () => undefined); + const writeError = vi.fn(); + const exit = vi.fn(); + + await dispatchUpdateCliArgs(["--check", "--json", "something"], { runUpdate, writeError, exit }); + + expect(exit).toHaveBeenCalledWith(1); + expect(writeError).toHaveBeenCalledWith(expect.stringContaining("something")); + expect(runUpdate).not.toHaveBeenCalled(); + expect(writeError.mock.calls.flat().join("\n")).not.toContain('"updated":false'); + }); + + it("passes valid argv through the same dispatch used by bin.ts", async () => { + const runUpdate = vi.fn(async () => undefined); + + await dispatchUpdateCliArgs(["--check", "--json", "--channel", "beta"], { runUpdate }); + + expect(runUpdate).toHaveBeenCalledWith({ check: true, json: true, channel: "beta" }); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index f4eebb8337..b898dd3d6f 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -153,7 +153,7 @@ async function loadCommandHandlers() { const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js"); const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js"); const { runExperimentFinalize } = await import("./commands/experiment-finalize.js"); - const { runUpdate } = await import("./commands/update.js"); + const { dispatchUpdateCliArgs } = await import("./commands/update.js"); return { runDashboard, @@ -289,7 +289,7 @@ async function loadCommandHandlers() { runResearchCancel, runResearchRetry, runExperimentFinalize, - runUpdate, + dispatchUpdateCliArgs, runChatInteractive, parseChatCliArgs, }; @@ -816,7 +816,7 @@ async function main() { runResearchCancel, runResearchRetry, runExperimentFinalize, - runUpdate, + dispatchUpdateCliArgs, runChatInteractive, parseChatCliArgs, } = await loadCommandHandlers(); @@ -938,24 +938,7 @@ async function main() { case "update": case "upgrade": { - // FNXC:UpdateChannels 2026-07-19-13:05: --channel selects - // and persists the release track; --force installs the channel target - // even when not newer (the explicit beta → stable downgrade path). - // A bare trailing --channel (or one followed by another flag) errors - // instead of being silently ignored (PR #2345 review). - const channelFlagIndex = args.indexOf("--channel"); - const channelValue = channelFlagIndex !== -1 ? args[channelFlagIndex + 1] : undefined; - if (channelFlagIndex !== -1 && (channelValue === undefined || channelValue.startsWith("--"))) { - console.error("Error: --channel requires a value: stable or beta."); - process.exit(1); - } - await runUpdate({ - check: args.includes("--check"), - global: args.includes("--global") ? true : undefined, - json: args.includes("--json"), - channel: channelValue, - force: args.includes("--force"), - }); + await dispatchUpdateCliArgs(args.slice(1)); break; } diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index 23dec8a5e5..4f7b86fae9 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -432,5 +432,49 @@ describe("runUpdate", () => { await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith("Error checking for updates: network down"); }); + + it.each([ + [{ latest: "1.2.3", beta: "1.3.0-beta.1" }, "1.2.3", undefined, true], + [{ latest: "1.4.0", beta: "1.3.0-beta.1" }, "1.2.3", undefined, false], + [{ latest: "1.2.3", beta: "1.3.0-beta.1" }, "1.3.0-beta.1", undefined, false], + [{ latest: "1.2.3" }, "1.2.3", undefined, false], + [{ latest: "1.2.3", beta: "1.3.0-beta.1" }, "1.2.3", "beta", false], + ])("shows a stable beta notice only for the strict live-registry predicate", async (distTags, currentVersion, channel, expectNotice) => { + if (channel) getConfiguredUpdateChannelMock.mockResolvedValue(channel); + readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: currentVersion })); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": distTags }) })); + + await runUpdate({ check: true }); + + expect(logSpy.mock.calls.flat().join("\n").includes("A newer beta")).toBe(expectNotice); + }); + + it("never announces beta from a cached registry fallback or JSON output", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + getCachedUpdateStatusMock.mockReturnValue({ + updateAvailable: true, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + channel: "stable", + }); + + await runUpdate({ check: true }); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain("A newer beta"); + + logSpy.mockClear(); + process.exitCode = 0; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.1" } }) })); + await runUpdate({ check: true, json: true }); + + const output = logSpy.mock.calls[0]?.[0] as string; + expect(JSON.parse(output)).toEqual({ + currentVersion: "1.2.3", + latestVersion: "1.2.3", + updateAvailable: false, + updated: false, + channel: "stable", + }); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain("A newer beta"); + }); }); }); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 35df2841df..7376a2871e 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -35,6 +35,91 @@ export type RunUpdateOptions = { force?: boolean; }; +const UPDATE_CLI_OPTIONS = "--check, --global, --json, --channel , --force"; + +type UpdateCliParseResult = + | { options: RunUpdateOptions; error?: never } + | { options?: never; error: string }; + +/* +FNXC:UpdateArgumentHonesty 2026-07-21-12:00: +Update and upgrade must fail closed for unknown or duplicate options. A documented +flag that ships only in a newer CLI must never become a false “Already up to date.” +success on an older build, and typos must not silently no-op (FN-8452 / #2368). +This parser owns argv structure only: it requires a non-flag --channel value but +passes that raw value to runUpdate for semantic stable/beta validation. Reject +repeated options rather than silently choosing first or last for the same +honesty guarantee. +*/ +export function parseUpdateCliArgs(args: string[]): UpdateCliParseResult { + const options: RunUpdateOptions = {}; + const seen = new Set(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (seen.has(arg)) { + return { error: `Error: duplicate option '${arg}'.` }; + } + + switch (arg) { + case "--check": + seen.add(arg); + options.check = true; + break; + case "--global": + seen.add(arg); + options.global = true; + break; + case "--json": + seen.add(arg); + options.json = true; + break; + case "--force": + seen.add(arg); + options.force = true; + break; + case "--channel": { + seen.add(arg); + const channel = args[index + 1]; + if (channel === undefined || channel.startsWith("-")) { + return { error: "Error: --channel requires a value: stable or beta." }; + } + options.channel = channel; + index += 1; + break; + } + default: + return { error: `Error: unknown option '${arg}'. Valid options: ${UPDATE_CLI_OPTIONS}.` }; + } + } + + return { options }; +} + +type UpdateCommandDependencies = { + runUpdate?: (options: RunUpdateOptions) => Promise; + writeError?: (message: string) => void; + exit?: (code: number) => void; +}; + +/** + * Dispatch the strict argv parser used by both `fn update` and `fn upgrade`. + * Kept injectable so the bin wiring can be tested without a registry request. + */ +export async function dispatchUpdateCliArgs( + args: string[], + dependencies: UpdateCommandDependencies = {}, +): Promise { + const parsed = parseUpdateCliArgs(args); + if ("error" in parsed) { + (dependencies.writeError ?? console.error)(parsed.error); + (dependencies.exit ?? process.exit)(1); + return; + } + + await (dependencies.runUpdate ?? runUpdate)(parsed.options); +} + type UpdateStatus = { currentVersion: string; latestVersion: string; @@ -74,24 +159,26 @@ function readOwnCliVersion(): string | undefined { return undefined; } -async function fetchChannelTargetVersion(channel: UpdateChannel): Promise { - const response = await fetch(REGISTRY_URL); - const payload = (await response.json()) as { - "dist-tags"?: { - latest?: string; - beta?: string; - }; - }; +type UpdateDistTags = { + latest?: string; + beta?: string; +}; - const targetVersion = resolveUpdateTargetVersion(channel, { - latest: payload?.["dist-tags"]?.latest, - beta: payload?.["dist-tags"]?.beta, - }); +type ChannelTarget = { + targetVersion: string; + distTags: UpdateDistTags; +}; + +async function fetchChannelTargetVersion(channel: UpdateChannel): Promise { + const response = await fetch(REGISTRY_URL); + const payload = (await response.json()) as { "dist-tags"?: UpdateDistTags }; + const distTags = payload?.["dist-tags"] ?? {}; + const targetVersion = resolveUpdateTargetVersion(channel, distTags); if (typeof targetVersion !== "string" || targetVersion.length === 0) { throw new Error(`Could not determine ${channel} version from npm registry response.`); } - return targetVersion; + return { targetVersion, distTags }; } // FNXC:UpdateChannels 2026-07-19-16:20: the version comes from the npm @@ -239,6 +326,45 @@ function printJson(status: UpdateStatus): void { console.log(JSON.stringify(status)); } +/* +FNXC:UpdateBetaNotice 2026-07-21-12:15: +Stable, human-readable output may mention but never install a newer beta so +release-note readers can discover it without changing stable resolution +(FN-8452 / #2368 suggested fix #3). Show the notice only when channel is stable, +JSON is off, this run fetched the live registry, beta is non-empty, and beta is +strictly newer than both current and stable target. Cache-only and registry +failure paths must never invent a beta notice. +*/ +function printBetaAvailabilityNotice({ + channel, + jsonOutput, + registrySucceeded, + betaTag, + currentVersion, + stableTarget, +}: { + channel: UpdateChannel; + jsonOutput: boolean; + registrySucceeded: boolean; + betaTag: string | undefined; + currentVersion: string; + stableTarget: string; +}): void { + if ( + channel !== "stable" || + jsonOutput || + !registrySucceeded || + typeof betaTag !== "string" || + betaTag.length === 0 || + !isVersionNewer(betaTag, currentVersion) || + !isVersionNewer(betaTag, stableTarget) + ) { + return; + } + + console.log(`A newer beta (${betaTag}) is available. To opt in, run \`fn update --channel beta\` or \`npm install -g @runfusion/fusion@beta\`.`); +} + function getLatestVersionFallback(currentVersion: string, channel: UpdateChannel): string | null { const cached = getCachedUpdateStatus(currentVersion); if (!cached) return null; @@ -287,8 +413,13 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { } let latestVersion: string; + let betaTag: string | undefined; + let registrySucceeded = false; try { - latestVersion = await fetchChannelTargetVersion(channel); + const target = await fetchChannelTargetVersion(channel); + latestVersion = target.targetVersion; + betaTag = target.distTags.beta; + registrySucceeded = true; } catch (error) { const fallbackVersion = getLatestVersionFallback(currentVersion, channel); if (!fallbackVersion) { @@ -322,6 +453,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { printJson(checkStatus); } else { printStatus(checkStatus, true); + printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion }); } if (updateAvailable) { @@ -343,6 +475,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { printJson(status); } else { printStatus(status, false); + printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion }); } return; } @@ -370,4 +503,5 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { } printStatus(updatedStatus, false); + printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion }); }