FN-8452: reject invalid update flags and announce beta releases

Make update commands fail clearly on invalid arguments while helping stable users discover newer beta releases.

- Parse update and upgrade options strictly, rejecting unknown, duplicate, and malformed flags before running an update
- Show a live-registry beta availability notice for stable human-readable output without affecting JSON or cached results
- Add CLI coverage, beta bootstrap documentation, and a patch changeset

Files changed:
 .changeset/fn-8452-update-unknown-flags.md         |   7 +
 RELEASING.md                                       |   2 +-
 docs/cli-reference.md                              |   4 +
 docs/getting-started.md                            |   2 +
 packages/cli/src/__tests__/bin-update-args.test.ts |  75 ++++++++++
 packages/cli/src/bin.ts                            |  25 +---
 packages/cli/src/commands/__tests__/update.test.ts |  44 ++++++
 packages/cli/src/commands/update.ts                | 162 +++++++++++++++++++--
 8 files changed, 285 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8452

Fusion-Task-Lineage: b29a1ce5-5a40-40ec-ac18-07107fa18344

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-21 10:30:19 -07:00
parent 634295c72f
commit 5f0502e166
8 changed files with 285 additions and 36 deletions

View File

@@ -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).

View File

@@ -64,7 +64,7 @@ When you merge the Version Packages PR:
## Release tracks: beta and stable ## 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 <stable|beta>`. Fusion ships on two tracks. Users pick theirs with the `updateChannel` global setting (Settings → General → Release channel) or `fn update --channel <stable|beta>`. 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 | | Track | Cut from | Version shape | npm dist-tag | GitHub Release | Homebrew |
|-------|----------|---------------|--------------|----------------|----------| |-------|----------|---------------|--------------|----------------|----------|

View File

@@ -153,6 +153,10 @@ fn upgrade
| `--channel <stable\|beta>` | 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`. | | `--channel <stable\|beta>` | 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. | | `--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). `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).
--- ---

View File

@@ -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. 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: If update still fails, run the manual recovery commands:
```bash ```bash

View File

@@ -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 <stable|beta>, --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" });
});
});

View File

@@ -153,7 +153,7 @@ async function loadCommandHandlers() {
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js"); const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js"); const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js"); const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
const { runUpdate } = await import("./commands/update.js"); const { dispatchUpdateCliArgs } = await import("./commands/update.js");
return { return {
runDashboard, runDashboard,
@@ -289,7 +289,7 @@ async function loadCommandHandlers() {
runResearchCancel, runResearchCancel,
runResearchRetry, runResearchRetry,
runExperimentFinalize, runExperimentFinalize,
runUpdate, dispatchUpdateCliArgs,
runChatInteractive, runChatInteractive,
parseChatCliArgs, parseChatCliArgs,
}; };
@@ -816,7 +816,7 @@ async function main() {
runResearchCancel, runResearchCancel,
runResearchRetry, runResearchRetry,
runExperimentFinalize, runExperimentFinalize,
runUpdate, dispatchUpdateCliArgs,
runChatInteractive, runChatInteractive,
parseChatCliArgs, parseChatCliArgs,
} = await loadCommandHandlers(); } = await loadCommandHandlers();
@@ -938,24 +938,7 @@ async function main() {
case "update": case "update":
case "upgrade": { case "upgrade": {
// FNXC:UpdateChannels 2026-07-19-13:05: --channel <stable|beta> selects await dispatchUpdateCliArgs(args.slice(1));
// 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"),
});
break; break;
} }

View File

@@ -432,5 +432,49 @@ describe("runUpdate", () => {
await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1"); await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error checking for updates: network down"); 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");
});
}); });
}); });

View File

@@ -35,6 +35,91 @@ export type RunUpdateOptions = {
force?: boolean; force?: boolean;
}; };
const UPDATE_CLI_OPTIONS = "--check, --global, --json, --channel <stable|beta>, --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<string>();
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<void>;
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<void> {
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 = { type UpdateStatus = {
currentVersion: string; currentVersion: string;
latestVersion: string; latestVersion: string;
@@ -74,24 +159,26 @@ function readOwnCliVersion(): string | undefined {
return undefined; return undefined;
} }
async function fetchChannelTargetVersion(channel: UpdateChannel): Promise<string> { type UpdateDistTags = {
const response = await fetch(REGISTRY_URL); latest?: string;
const payload = (await response.json()) as { beta?: string;
"dist-tags"?: { };
latest?: string;
beta?: string;
};
};
const targetVersion = resolveUpdateTargetVersion(channel, { type ChannelTarget = {
latest: payload?.["dist-tags"]?.latest, targetVersion: string;
beta: payload?.["dist-tags"]?.beta, distTags: UpdateDistTags;
}); };
async function fetchChannelTargetVersion(channel: UpdateChannel): Promise<ChannelTarget> {
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) { if (typeof targetVersion !== "string" || targetVersion.length === 0) {
throw new Error(`Could not determine ${channel} version from npm registry response.`); 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 // 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)); 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 { function getLatestVersionFallback(currentVersion: string, channel: UpdateChannel): string | null {
const cached = getCachedUpdateStatus(currentVersion); const cached = getCachedUpdateStatus(currentVersion);
if (!cached) return null; if (!cached) return null;
@@ -287,8 +413,13 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
} }
let latestVersion: string; let latestVersion: string;
let betaTag: string | undefined;
let registrySucceeded = false;
try { try {
latestVersion = await fetchChannelTargetVersion(channel); const target = await fetchChannelTargetVersion(channel);
latestVersion = target.targetVersion;
betaTag = target.distTags.beta;
registrySucceeded = true;
} catch (error) { } catch (error) {
const fallbackVersion = getLatestVersionFallback(currentVersion, channel); const fallbackVersion = getLatestVersionFallback(currentVersion, channel);
if (!fallbackVersion) { if (!fallbackVersion) {
@@ -322,6 +453,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
printJson(checkStatus); printJson(checkStatus);
} else { } else {
printStatus(checkStatus, true); printStatus(checkStatus, true);
printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion });
} }
if (updateAvailable) { if (updateAvailable) {
@@ -343,6 +475,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
printJson(status); printJson(status);
} else { } else {
printStatus(status, false); printStatus(status, false);
printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion });
} }
return; return;
} }
@@ -370,4 +503,5 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
} }
printStatus(updatedStatus, false); printStatus(updatedStatus, false);
printBetaAvailabilityNotice({ channel, jsonOutput, registrySucceeded, betaTag, currentVersion, stableTarget: latestVersion });
} }