diff --git a/bin/cli/utils/storageKeyProvision.mjs b/bin/cli/utils/storageKeyProvision.mjs new file mode 100644 index 0000000000..3d9b1c3383 --- /dev/null +++ b/bin/cli/utils/storageKeyProvision.mjs @@ -0,0 +1,28 @@ +/** + * Decide whether a CLI invocation should provision (generate + persist) the + * STORAGE_ENCRYPTION_KEY into DATA_DIR/.env. + * + * Purely informational invocations must NOT create `~/.omniroute/.env` or write + * a key — they never touch encrypted storage. Generating a 32-byte key and a + * `.env` file just to print `omniroute --version` (or `--help`) is a surprising + * side effect: running a read-only command should not mutate the data dir. + * + * Returns false for: no command at all (bare `omniroute` → Commander prints + * help), `--version`/`-V` or `--help`/`-h` anywhere in the args, and the + * `help`/`completion` subcommands. Returns true for every real command + * (`serve`, `keys`, `providers`, …) so the encryption key is still provisioned + * before encrypted storage is accessed (preserves the #1622 persistence fix). + * + * @param {string[]} argv - process.argv (node + script + args). + * @returns {boolean} + */ +const INFO_FLAGS = new Set(["-h", "--help", "-V", "--version"]); +const INFO_COMMANDS = new Set(["help", "completion"]); + +export function shouldProvisionStorageKey(argv) { + const args = Array.isArray(argv) ? argv.slice(2) : []; + if (args.length === 0) return false; + if (args.some((a) => INFO_FLAGS.has(a))) return false; + if (INFO_COMMANDS.has(args[0])) return false; + return true; +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index b03bb3626c..7a3d4bccc5 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -17,6 +17,7 @@ import { homedir, platform } from "node:os"; import updateNotifier from "update-notifier"; import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs"; import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; +import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs"; // Register tsx so dynamic imports of .ts source files (referenced as .js per // TypeScript conventions) resolve correctly. The build never emits .js for @@ -92,7 +93,12 @@ loadEnvFile(); // Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env) // This ensures the key survives across upgrades and is not regenerated on each install. // See: https://github.com/diegosouzapw/OmniRoute/issues/1622 -{ +// +// Only provision for commands that actually touch encrypted storage. Purely +// informational invocations (`--version`, `--help`, `help`) must not create a +// key or write ~/.omniroute/.env — running a read-only command should never +// mutate the data dir. +if (shouldProvisionStorageKey(process.argv)) { const { randomBytes } = await import("node:crypto"); const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs"); const { join } = await import("node:path"); diff --git a/tests/unit/cli-storage-key-provision.test.ts b/tests/unit/cli-storage-key-provision.test.ts new file mode 100644 index 0000000000..8d8670c8b8 --- /dev/null +++ b/tests/unit/cli-storage-key-provision.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { shouldProvisionStorageKey } from "../../bin/cli/utils/storageKeyProvision.mjs"; + +// argv shape is [node, script, ...args] +const argv = (...args: string[]) => ["node", "omniroute", ...args]; + +test("storage key: informational commands do NOT provision a key", () => { + assert.equal(shouldProvisionStorageKey(argv()), false); // bare omniroute → help + assert.equal(shouldProvisionStorageKey(argv("--version")), false); + assert.equal(shouldProvisionStorageKey(argv("-V")), false); + assert.equal(shouldProvisionStorageKey(argv("--help")), false); + assert.equal(shouldProvisionStorageKey(argv("-h")), false); + assert.equal(shouldProvisionStorageKey(argv("help")), false); + assert.equal(shouldProvisionStorageKey(argv("completion")), false); +}); + +test("storage key: --help/--version anywhere in the args still skips", () => { + assert.equal(shouldProvisionStorageKey(argv("serve", "--help")), false); + assert.equal(shouldProvisionStorageKey(argv("keys", "list", "-h")), false); + assert.equal(shouldProvisionStorageKey(argv("--lang", "en", "--version")), false); +}); + +test("storage key: real commands DO provision (preserves #1622 persistence)", () => { + assert.equal(shouldProvisionStorageKey(argv("serve")), true); + assert.equal(shouldProvisionStorageKey(argv("keys", "list")), true); + assert.equal(shouldProvisionStorageKey(argv("providers")), true); + assert.equal(shouldProvisionStorageKey(argv("serve", "--port", "20128")), true); + // global --lang option before a real command must not suppress provisioning + assert.equal(shouldProvisionStorageKey(argv("--lang", "en", "serve")), true); +}); + +test("storage key: defensive on non-array input", () => { + // @ts-expect-error intentional bad input + assert.equal(shouldProvisionStorageKey(undefined), false); +});