fix(cli): route update flag through verified updater

Normalize the unambiguous root --update alias before output handling and
through direct createCli().serve calls. Reuse the existing output-value scan
and canonical update command, including selected environment, installation
ownership, Windows-aware launch, verification, maintenance and failure facts.
Retain option data and help/check precedence. Reject remaining Incur install
requests: update:false does not disable its explicit installer in0.6.1.
Remove the superseded output-only normalizer name and update its consumers.

Validation:123focused checks/7files pass, including compiled CLI with an owned
rejecting fake npm and direct serve across5formats. Original argument owner
fails6new controls/7pass; isolated baseline fails8workflow checks/2controls
pass. Static/docs/discovery1561 pass. Both scoped source reviews are clear.
Six completed equivalent current-version workflows compare baseline canonical
update with the alias; complete replies identical. Native Windows upgrade,
real package-manager installation and full install costs remain unverified.

The first baseline escaped a Vitest-only builtin mock and installed REA in the
pinned Node global prefix. Removed the newly added package and verified original
Node/npm/Corepack tools. Re-ran using the real builtin spy with synchronized
ESM exports, an independently rejecting PATH shim and owned npm prefix/cache.
Keep heavy tests/profiling stopped. Independent updater output bounds remain
tracked in #4888 rather than changing collection policy in this routing fix.

Fixes #4883
This commit is contained in:
morluto committed 2026-10-18 05:33:21 +08:00
1 parent c555caad65
commit 4f7b5fb602
8 files changed
+374 -17

No files matched your search

+3
View File
@@ -132,6 +132,9 @@ For a CLI installed with `npm install --global` or `bun add --global`:
rea update rea update
``` ```
`rea --update` is an alias for this verified workflow and accepts the same global
output flags. Use `rea update` when combining it with other command controls.
Bun global updates require Bun 1.2.17 or later. With an older Bun release, Bun global updates require Bun 1.2.17 or later. With an older Bun release,
upgrade Bun manually before running `rea update`. upgrade Bun manually before running `rea update`.
+2 -2
View File
@@ -74,13 +74,13 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
const { createCliResultOutput } = const { createCliResultOutput } =
await import("../dist/cli-runtime/cliResultOutput.js"); await import("../dist/cli-runtime/cliResultOutput.js");
const { const {
normalizeCliOutputArguments, normalizeCliArguments,
renderCliOutputArgumentError, renderCliOutputArgumentError,
renderEmptyFilteredCliOutput, renderEmptyFilteredCliOutput,
validateCliOutputArguments, validateCliOutputArguments,
} = await import("../dist/cli-runtime/cliOutput.js"); } = await import("../dist/cli-runtime/cliOutput.js");
guardClosedStdout(); guardClosedStdout();
const cliArgs = normalizeCliOutputArguments(args); const cliArgs = normalizeCliArguments(args);
const outputArguments = validateCliOutputArguments(cliArgs); const outputArguments = validateCliOutputArguments(cliArgs);
const configurationError = cliConfigurationError(process.env); const configurationError = cliConfigurationError(process.env);
if (configurationError !== undefined) { if (configurationError !== undefined) {
+13
View File
@@ -12,6 +12,7 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman
import { registerFridaCommands } from "./cli/fridaCommands.js"; import { registerFridaCommands } from "./cli/fridaCommands.js";
import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js"; import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
import { Cli } from "incur"; import { Cli } from "incur";
import { normalizeCliArguments } from "./cliOutput.js";
import { createLogger, parseLogLevel } from "./logger.js"; import { createLogger, parseLogLevel } from "./logger.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
@@ -94,6 +95,15 @@ export const createCli = (
"Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.", "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.",
}, },
sync: false, sync: false,
update: {
// Incur's update:false only suppresses notices. Reject its explicit
// installer so ambiguous flags cannot bypass REA's verified workflow.
install: () => {
throw new Error(
"Use 'rea update' to update the identified installation. The '--update' alias accepts only global output flags.",
);
},
},
}); });
registerSetupCommands(cli, logger, environment); registerSetupCommands(cli, logger, environment);
@@ -136,5 +146,8 @@ export const createCli = (
registerApplicationCommands(cli, logger); registerApplicationCommands(cli, logger);
presentOmittableDefaults(cli); presentOmittableDefaults(cli);
if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput); if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput);
const serve = cli.serve.bind(cli);
cli.serve = (arguments_ = process.argv.slice(2), options) =>
serve(normalizeCliArguments(arguments_), options);
return cli; return cli;
}; };
+21 -5
View File
@@ -11,15 +11,23 @@ const VALUED_OUTPUT_FLAGS = new Set([
"--token-offset", "--token-offset",
]); ]);
const ROOT_UPDATE_OUTPUT_FLAGS = new Set([
"--json",
"--full-output",
"--token-count",
]);
/** /**
* Split `--flag=value` forms of the valued output flags. Incur reads these * Route the root update alias and split `--flag=value` output forms. Incur reads these
* flags only in spaced form, so `--format=json` otherwise reaches the command * flags only in spaced form, so `--format=json` otherwise reaches the command
* parser and fails as an unknown flag. Command options accept both forms. * parser and fails as an unknown flag. Command options accept both forms.
*/ */
export const normalizeCliOutputArguments = ( export const normalizeCliArguments = (
arguments_: readonly string[], arguments_: readonly string[],
): string[] => { ): string[] => {
const normalized: string[] = []; const normalized: string[] = [];
const updates = new Set<number>();
let rootUpdate = true;
for (let index = 0; index < arguments_.length; index += 1) { for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index]!; const argument = arguments_[index]!;
if (VALUED_OUTPUT_FLAGS.has(argument) && arguments_[index + 1]) { if (VALUED_OUTPUT_FLAGS.has(argument) && arguments_[index + 1]) {
@@ -33,9 +41,17 @@ export const normalizeCliOutputArguments = (
const flag = argument.slice(0, separator); const flag = argument.slice(0, separator);
if (separator >= 0 && VALUED_OUTPUT_FLAGS.has(flag)) if (separator >= 0 && VALUED_OUTPUT_FLAGS.has(flag))
normalized.push(flag, argument.slice(separator + 1)); normalized.push(flag, argument.slice(separator + 1));
else normalized.push(argument); else {
if (argument === "--update") updates.add(normalized.length);
else if (!ROOT_UPDATE_OUTPUT_FLAGS.has(argument)) rootUpdate = false;
normalized.push(argument);
}
} }
return normalized; // Output option values were consumed above; flag-shaped data is never an alias.
// Help/check/command/unknown tokens retain Incur's parsing and precedence.
return rootUpdate && updates.size > 0
? ["update", ...normalized.filter((_, index) => !updates.has(index))]
: normalized;
}; };
export type CliOutputArgumentValidation = export type CliOutputArgumentValidation =
@@ -89,7 +105,7 @@ export const parseCliOutputArguments = (
for (let index = 0; index < arguments_.length; index += 1) { for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index]; const argument = arguments_[index];
// Incur scans builtins across `--` and reads spaced value forms, which // Incur scans builtins across `--` and reads spaced value forms, which
// normalizeCliOutputArguments has produced. Mirror those effective values. // normalizeCliArguments has produced. Mirror those effective values.
if (argument === "--json") { if (argument === "--json") {
format = "json"; format = "json";
continue; continue;
@@ -0,0 +1,107 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { expect } from "vitest";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"root update flag uses the verified workflow and preserves executable output policies",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-update-alias-");
const bin = join(home, "bin");
const script = join(home, "npm-fixture.mjs");
const log = join(home, "commands.jsonl");
await mkdir(bin);
await writeFile(log, "");
await writeFile(
script,
`
import {appendFileSync} from 'node:fs';
import {join} from 'node:path';
const args=process.argv.slice(2);
appendFileSync(process.env.REA_TEST_NPM_LOG, JSON.stringify(args)+'\\n');
if(args[0]==='root') console.log(join(process.env.USERPROFILE,'unowned','node_modules'));
else if(args[0]==='prefix') console.log(join(process.env.USERPROFILE,'unowned'));
else if(args[0]==='view') {
if(process.env.REA_TEST_NPM_FAILURE==='1') {process.stderr.write('owned fake registry denied');process.exitCode=2;}
else console.log(JSON.stringify(${JSON.stringify(PRODUCT_IDENTITY.packageVersion)}));
} else {process.stderr.write('No installation or unexpected package-manager commands are allowed');process.exitCode=97;}
`,
);
await writeFile(
join(bin, process.platform === "win32" ? "npm.cmd" : "npm"),
process.platform === "win32"
? '@echo off\r\n"%REA_TEST_NODE%" "%REA_TEST_NPM_SCRIPT%" %*\r\n'
: '#!/bin/sh\nexec "$REA_TEST_NODE" "$REA_TEST_NPM_SCRIPT" "$@"\n',
{ mode: 0o700 },
);
const run = (arguments_: readonly string[], failure = false) =>
cli.run({
arguments: arguments_,
cwd: home,
timeoutMs: 20_000,
environment: {
USERPROFILE: home,
PATH: bin + delimiter + (process.env.PATH ?? ""),
REA_TEST_NODE: process.execPath,
REA_TEST_NPM_SCRIPT: script,
REA_TEST_NPM_LOG: log,
REA_TEST_NPM_FAILURE: failure ? "1" : "0",
CI: "1",
NO_UPDATE_NOTIFIER: "1",
},
});
const canonical = await run(["update", "--json"]);
expect(canonical.exitCode, canonical.stderr).toBe(0);
expect(canonical.json).toMatchObject({
status: "current",
currentVersion: PRODUCT_IDENTITY.packageVersion,
latestVersion: PRODUCT_IDENTITY.packageVersion,
});
const alias = await run(["--json", "--update"]);
expect(alias.exitCode, alias.stderr).toBe(0);
expect(alias.json).toEqual(canonical.json);
const filtered = await run([
"--update",
"--format=json",
"--full-output",
"--filter-output=status",
]);
expect(filtered.exitCode, filtered.stderr).toBe(0);
expect(filtered.json).toMatchObject({
ok: true,
data: "current",
meta: { command: expect.stringContaining("update") },
});
const failed = await run(["--update", "--json"], true);
expect(failed.exitCode).toBe(1);
expect(failed.json).toMatchObject({
status: "failed",
reason: "version-check",
remediation: expect.stringContaining("owned fake registry denied"),
});
const before = await readFile(log, "utf8");
const ambiguous = await run(["providers", "--update", "--json"]);
expect(ambiguous.exitCode).toBe(1);
expect(ambiguous.json).toMatchObject({
code: "UPDATE_FAILED",
message: expect.stringContaining("rea update"),
});
expect(await readFile(log, "utf8")).toBe(before);
const help = await run(["--update", "--help"]);
expect(help.exitCode, help.stderr).toBe(0);
expect(help.stdout).toContain("update");
expect(await readFile(log, "utf8")).toBe(before);
const commands: unknown[] = before
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(commands.length).toBeGreaterThan(0);
expect(commands).toEqual(
expect.arrayContaining([expect.arrayContaining(["view"])]),
);
for (const command of commands) expect(command).not.toContain("install");
},
);
+60 -8
View File
@@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { import {
normalizeCliOutputArguments, normalizeCliArguments,
renderCliOutputArgumentError, renderCliOutputArgumentError,
renderEmptyFilteredCliOutput, renderEmptyFilteredCliOutput,
validateCliOutputArguments, validateCliOutputArguments,
@@ -64,12 +64,7 @@ describe("CLI output flag value forms", () => {
"preserves a flag-shaped spaced value owned by %s", "preserves a flag-shaped spaced value owned by %s",
(flag) => { (flag) => {
expect( expect(
normalizeCliOutputArguments([ normalizeCliArguments([flag, "--format=json", "providers", "--json"]),
flag,
"--format=json",
"providers",
"--json",
]),
).toEqual([flag, "--format=json", "providers", "--json"]); ).toEqual([flag, "--format=json", "providers", "--json"]);
}, },
); );
@@ -95,7 +90,7 @@ describe("CLI output flag value forms", () => {
it("splits only the equals forms of valued output flags", () => { it("splits only the equals forms of valued output flags", () => {
expect( expect(
normalizeCliOutputArguments([ normalizeCliArguments([
"inspect-sqlite-database", "inspect-sqlite-database",
"--table=a=b", "--table=a=b",
"--format=json", "--format=json",
@@ -541,3 +536,60 @@ describe("compiled CLI artifact diagnostics", () => {
CLI_INTEGRATION_TIMEOUT_MS, CLI_INTEGRATION_TIMEOUT_MS,
); );
}); });
describe("CLI update root alias", () => {
it.each([
[["--update"], ["update"]],
[
["--json", "--update", "--filter-output=status"],
["update", "--json", "--filter-output", "status"],
],
[
["--format=yaml", "--update", "--full-output"],
["update", "--format", "yaml", "--full-output"],
],
[
["--update", "--token-limit=100", "--token-count"],
["update", "--token-limit", "100", "--token-count"],
],
[["--update", "--update"], ["update"]],
[
["--filter-output", "--update"],
["--filter-output", "--update"],
],
[
["--filter-output=--update", "--update"],
["update", "--filter-output", "--update"],
],
[
["--update", "--help"],
["--update", "--help"],
],
[
["--incur-update-check", "--update"],
["--incur-update-check", "--update"],
],
[
["providers", "--update"],
["providers", "--update"],
],
[
["--", "--update"],
["--", "--update"],
],
[
["--config", "--update"],
["--config", "--update"],
],
[
["update", "--json"],
["update", "--json"],
],
])(
"routes only an unambiguous root update while retaining option data: %j",
(input, expected) => {
expect(normalizeCliArguments(input)).toEqual(expected);
expect(normalizeCliArguments(expected)).toEqual(expected);
},
);
});
+2 -2
View File
@@ -23,7 +23,7 @@ describe("executable dispatcher", () => {
), ),
workspace.write( workspace.write(
"dist/cli-runtime/cliOutput.js", "dist/cli-runtime/cliOutput.js",
"export const normalizeCliOutputArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n",
), ),
workspace.write( workspace.write(
"dist/cli-runtime/cliResultOutput.js", "dist/cli-runtime/cliResultOutput.js",
@@ -64,7 +64,7 @@ describe("executable dispatcher", () => {
), ),
workspace.write( workspace.write(
"dist/cli-runtime/cliOutput.js", "dist/cli-runtime/cliOutput.js",
"export const normalizeCliOutputArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n",
), ),
workspace.write( workspace.write(
"dist/cli-runtime/cliResultOutput.js", "dist/cli-runtime/cliResultOutput.js",
+166
View File
@@ -0,0 +1,166 @@
import childProcess from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { createCli } from "../../src/cli.js";
import { PRODUCT_IDENTITY } from "../../src/identity.js";
const mocks = vi.hoisted(() => ({
run: vi.fn(),
host: vi.fn(() => ({ owner: "selected-update-host" })),
dependencyInstall: vi.fn(() => {
throw new Error("Dependency installer must not run in this test.");
}),
}));
vi.mock("../../src/application/Update.js", async (original) => ({
...(await original<typeof import("../../src/application/Update.js")>()),
runUpdate: mocks.run,
}));
vi.mock("../../src/application/UpdateRuntime.js", () => ({
systemUpdateHost: mocks.host,
}));
const initialExitCode = process.exitCode;
const current = {
status: "current",
currentVersion: PRODUCT_IDENTITY.packageVersion,
latestVersion: PRODUCT_IDENTITY.packageVersion,
installMethod: "unknown",
};
beforeEach(async () => {
vi.clearAllMocks();
const root = await createTestTempDirectory("rea-update-api-sandbox-");
await writeFile(
join(root, process.platform === "win32" ? "npm.cmd" : "npm"),
process.platform === "win32"
? "@echo off\r\necho Test package manager refuses installation 1>&2\r\nexit /b 97\r\n"
: "#!/bin/sh\nprintf '%s\\n' 'Test package manager refuses installation' >&2\nexit 97\n",
{ mode: 0o700 },
);
vi.stubEnv("PATH", root);
vi.stubEnv("npm_config_user_agent", "npm/test");
vi.stubEnv("npm_config_prefix", join(root, "global"));
vi.stubEnv("npm_config_cache", join(root, "cache"));
// Externalized dependencies bypass Vitest module factories for Node builtins.
// Patch the real builtin and synchronize named ESM exports; PATH/prefix above
// independently prevent any real installation if this guard stops working.
vi.spyOn(childProcess, "execFile").mockImplementation(
mocks.dependencyInstall,
);
syncBuiltinESMExports();
vi.stubEnv("CI", "1");
vi.stubEnv("NO_UPDATE_NOTIFIER", "1");
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("No network in this test."))),
);
mocks.run.mockResolvedValue(current);
});
afterEach(() => {
vi.restoreAllMocks();
syncBuiltinESMExports();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
process.exitCode = initialExitCode;
});
const serve = async (
cli: ReturnType<typeof createCli>,
arguments_: string[],
) => {
process.exitCode = 0;
let stdout = "";
let exitCode = 0;
await cli.serve(arguments_, {
env: {},
stdout: (text) => {
stdout += text;
},
exit: (code) => {
exitCode = code;
},
});
return { stdout, exitCode: exitCode || Number(process.exitCode ?? 0) };
};
it.each(["json", "jsonl", "yaml", "toon", "md"])(
"uses the verified update command and selected environment through direct serve with %s output",
async (format) => {
const selected = { REA_SELECTED_UPDATE: "original" };
const cli = createCli(selected);
selected.REA_SELECTED_UPDATE = "changed";
const expected = await serve(cli, ["update", "--format", format]);
const actual = await serve(cli, ["--format", format, "--update"]);
expect(actual).toEqual(expected);
expect(actual.exitCode).toBe(0);
expect(actual.stdout).toContain(PRODUCT_IDENTITY.packageVersion);
expect(mocks.run).toHaveBeenCalledTimes(2);
expect(mocks.run).toHaveBeenLastCalledWith(
PRODUCT_IDENTITY.packageVersion,
{ owner: "selected-update-host" },
"structured",
);
expect(mocks.host).toHaveBeenLastCalledWith(undefined, undefined, {
REA_SELECTED_UPDATE: "original",
});
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
},
);
it("preserves filtering, full output and unsuccessful update exit status", async () => {
const cli = createCli({});
const flags = ["--json", "--full-output", "--filter-output", "status"];
const filtered = await serve(cli, ["--update", ...flags]);
const canonical = await serve(cli, ["update", ...flags]);
expect(filtered.exitCode).toBe(canonical.exitCode);
for (const output of [filtered, canonical])
expect(JSON.parse(output.stdout)).toMatchObject({
ok: true,
data: "current",
meta: { command: expect.stringContaining("update") },
});
mocks.run.mockResolvedValue({
status: "failed",
currentVersion: PRODUCT_IDENTITY.packageVersion,
latestVersion: "99.0.0",
reason: "install",
remediation: "EACCES owned installation",
});
const failed = await serve(cli, ["--update", "--json"]);
expect(failed.exitCode).toBe(1);
expect(JSON.parse(failed.stdout)).toMatchObject({
status: "failed",
reason: "install",
remediation: "EACCES owned installation",
});
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});
it.each([
["--update", "--help"],
["--incur-update-check", "--update"],
])("preserves non-installing precedence for %j", async (...arguments_) => {
await serve(createCli({}), arguments_);
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});
it("refuses ambiguous built-in installation rather than selecting another updater", async () => {
const result = await serve(createCli({}), [
"providers",
"--update",
"--json",
]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "UPDATE_FAILED",
message: expect.stringContaining("rea update"),
});
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});